From 099226947ebdad841faa297abe3240be48b33f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 6 Jul 2026 18:38:14 +0200 Subject: [PATCH 1/6] Add pure network-summary helpers for the profiler-cli Introduce src/profile-query/network-summary.ts with the small, UI-independent building blocks used by every network consumer: interval union (wall-clock in-flight time), peak concurrency, the long-lived (long duration, tiny transfer) heuristic, and cache classification. These are pure functions with no profile dependencies; the callers land in the following commits. --- src/profile-query/network-summary.ts | 71 ++++++++++++ .../profile-query/network-summary.test.ts | 102 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/profile-query/network-summary.ts create mode 100644 src/test/unit/profile-query/network-summary.test.ts diff --git a/src/profile-query/network-summary.ts b/src/profile-query/network-summary.ts new file mode 100644 index 0000000000..2b73a01428 --- /dev/null +++ b/src/profile-query/network-summary.ts @@ -0,0 +1,71 @@ +/* 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/. */ + +/** + * 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'; +} + +/** + * Interval union: total wall-clock time covered by any interval. Intervals are + * [start, end] pairs (callers clamp them to the range of interest first). + */ +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; +} 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..372be214a3 --- /dev/null +++ b/src/test/unit/profile-query/network-summary.test.ts @@ -0,0 +1,102 @@ +/* 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, +} from 'firefox-profiler/profile-query/network-summary'; + +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'); + }); + }); +}); From e5d9cd6daf06ec51497adeebd2e34d05850767f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 7 Jul 2026 14:43:49 +0200 Subject: [PATCH 2/6] Lead thread network summary with wall-clock time in the cli The summary now leads with the interval-union in-flight wall-clock time and peak concurrency, computed over all requests intersecting the range. The phase totals are relabeled "summed across concurrent requests" so they no longer read as wall-clock time (they previously showed e.g. "Download: 24m3s" for a 2m28s profile). --- profiler-cli/guide.txt | 6 ++ profiler-cli/schemas.txt | 1 + profiler-cli/src/formatters.ts | 6 +- .../src/test/unit/network-formatting.test.ts | 61 ++++++++++++++----- src/profile-query/formatters/marker-info.ts | 51 ++++++++++++---- src/profile-query/types.ts | 6 ++ .../unit/profile-query/marker-utils.test.ts | 12 ++-- 7 files changed, 110 insertions(+), 33 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 65884b75a1..76a086885a 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -349,6 +349,12 @@ 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 diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index c39c05eab3..2c951c55fd 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -154,6 +154,7 @@ profiler-cli thread network --json filters?: { searchString?, minDuration?, maxDuration?, limit? }, summary: { cacheHit, cacheMiss, cacheUnknown, + inFlightMs, inFlightPercentage, peakConcurrency, rangeDurationMs, phaseTotals: { dns?, tcp?, tls?, ttfb?, download?, mainThread? } }, requests: [{ diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 45e702c79c..08dc5b1951 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -1268,6 +1268,10 @@ export function formatThreadNetworkResult( // 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 +1286,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)}`); } diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index 6ddd9e0fe7..b8c0193640 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -33,6 +33,22 @@ function makeRequest( }; } +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 { @@ -43,12 +59,7 @@ function makeResult( friendlyThreadName: 'GeckoMain', totalRequestCount: 1, filteredRequestCount: 1, - summary: { - cacheHit: 0, - cacheMiss: 0, - cacheUnknown: 1, - phaseTotals: {}, - }, + summary: makeSummary(), requests: [makeRequest()], ...overrides, }; @@ -137,12 +148,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 +158,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 +188,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); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index df1b41a339..fa389915ab 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -8,7 +8,13 @@ import { getCategories, getMarkerSchemaByName, getStringTable, + getCommittedRange, } from 'firefox-profiler/selectors/profile'; +import { + intervalUnionMs, + peakConcurrency, + classifyCache, +} from '../network-summary'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { formatFromMarkerSchema, @@ -1157,6 +1163,8 @@ export function collectThreadNetwork( const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); const fullMarkerList = threadSelectors.getFullMarkerList(state); const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); + const range = getCommittedRange(state); + const rangeDurationMs = range.end - range.start; // Filter to completed (STOP) network markers only. // STOP markers are the merged markers that carry full timing data. @@ -1170,6 +1178,23 @@ export function collectThreadNetwork( }); const totalRequestCount = stopIndexes.length; + // Wall-clock: interval union and peak concurrency over all requests + // intersecting the range, independent of the display filters below. + const inFlightIntervals: Array<[number, number]> = []; + for (const i of stopIndexes) { + const marker = fullMarkerList[i]; + const start = marker.start; + const end = marker.end ?? marker.start; + if (end < range.start || start > range.end) { + continue; + } + inFlightIntervals.push([ + Math.max(start, range.start), + Math.min(end, range.end), + ]); + } + const inFlightMs = intervalUnionMs(inFlightIntervals); + // Apply filters let filteredIndexes = stopIndexes; @@ -1205,18 +1230,15 @@ export function collectThreadNetwork( 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++; + switch (classifyCache(data.cache)) { + case 'hit': + cacheHit++; + break; + case 'miss': + cacheMiss++; + break; + default: + cacheUnknown++; } const phases = buildNetworkPhases(data); @@ -1286,6 +1308,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/types.ts b/src/profile-query/types.ts index 37eac9df8c..b3e24da086 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -455,6 +455,12 @@ 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[]; diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 592d9c6537..080101d087 100644 --- a/src/test/unit/profile-query/marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -938,37 +938,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 () { From 12a822e373dff9fa7eaeae512ac0c79f6b22f670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 7 Jul 2026 14:45:59 +0200 Subject: [PATCH 3/6] Report incomplete network requests in `pq thread network` Requests still in flight when the recording stopped are now included in the request list and counted separately ("N request(s) did not complete during the recording"). Their duration is measured until the end of the recording, and durations are clamped to the current range so a request that started before the range does not report more time than the profile covers. --- profiler-cli/schemas.txt | 3 +- profiler-cli/src/formatters.ts | 25 +++++++-- .../src/test/unit/network-formatting.test.ts | 27 ++++++++++ src/profile-query/formatters/marker-info.ts | 52 +++++++++++++------ src/profile-query/types.ts | 9 ++++ .../unit/profile-query/marker-utils.test.ts | 39 +++++++++++++- 6 files changed, 134 insertions(+), 21 deletions(-) diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 2c951c55fd..7ff4983ff7 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -150,6 +150,7 @@ profiler-cli thread network --json type: "thread-network", threadHandle, friendlyThreadName, totalRequestCount, + incompleteCount, filteredRequestCount, filters?: { searchString?, minDuration?, maxDuration?, limit? }, summary: { @@ -159,7 +160,7 @@ profiler-cli thread network --json }, requests: [{ markerHandle, url, httpStatus?, httpVersion?, cacheStatus?, - transferSizeKB?, startTime, duration, + transferSizeKB?, startTime, duration, incomplete, startedBeforeRecording, phases: { dns?, tcp?, tls?, ttfb?, download?, mainThread? } }], context: SessionContext diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 08dc5b1951..40aca0178b 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -1249,10 +1249,13 @@ 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; @@ -1263,6 +1266,11 @@ export function formatThreadNetworkResult( 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 @@ -1316,8 +1324,17 @@ 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.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}` : ''; diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index b8c0193640..52cc424081 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -28,6 +28,8 @@ function makeRequest( url: 'https://example.com/resource', startTime: 0, duration: 100, + incomplete: false, + startedBeforeRecording: false, phases: {}, ...overrides, }; @@ -58,6 +60,7 @@ function makeResult( threadHandle: 't-0', friendlyThreadName: 'GeckoMain', totalRequestCount: 1, + incompleteCount: 0, filteredRequestCount: 1, summary: makeSummary(), requests: [makeRequest()], @@ -297,4 +300,28 @@ 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'); + }); }); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index fa389915ab..f9b57501de 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -1166,22 +1166,38 @@ export function collectThreadNetwork( const range = getCommittedRange(state); const rangeDurationMs = range.end - range.start; - // 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)) { + // The displayable request set is completed (STATUS_STOP) markers plus + // requests still in flight at the end (STATUS_START only, no stop event). + // "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 and must not be miscounted as in flight. + const requestIndexes = allMarkerIndexes.filter((i) => { + const marker = fullMarkerList[i]; + if (!isNetworkMarker(marker)) { return false; } - const data = m.data as NetworkPayload; - return data.status === 'STATUS_STOP'; + const status = (marker.data as NetworkPayload).status; + return status === 'STATUS_STOP' || status === 'STATUS_START'; }); - const totalRequestCount = stopIndexes.length; + const incompleteCount = requestIndexes.filter( + (i) => (fullMarkerList[i].data as NetworkPayload).status === 'STATUS_START' + ).length; + const totalRequestCount = requestIndexes.length - incompleteCount; + + // In-range duration: a request can start before the range or end after it + // (requests still in flight at the recording end). Clamping keeps the + // reported duration from exceeding what the profile can show. + const durationOf = (markerIndex: MarkerIndex): number => { + const marker = fullMarkerList[markerIndex]; + const start = Math.max(marker.start, range.start); + const end = Math.min(marker.end ?? marker.start, range.end); + return Math.max(0, end - start); + }; // Wall-clock: interval union and peak concurrency over all requests // intersecting the range, independent of the display filters below. const inFlightIntervals: Array<[number, number]> = []; - for (const i of stopIndexes) { + for (const i of requestIndexes) { const marker = fullMarkerList[i]; const start = marker.start; const end = marker.end ?? marker.start; @@ -1196,7 +1212,7 @@ export function collectThreadNetwork( const inFlightMs = intervalUnionMs(inFlightIntervals); // Apply filters - let filteredIndexes = stopIndexes; + let filteredIndexes = requestIndexes; if (searchString) { const lowerSearch = searchString.toLowerCase(); @@ -1208,8 +1224,7 @@ export function collectThreadNetwork( if (minDuration !== undefined || maxDuration !== undefined) { filteredIndexes = filteredIndexes.filter((i) => { - const data = fullMarkerList[i].data as NetworkPayload; - const duration = data.endTime - data.startTime; + const duration = durationOf(i); if (minDuration !== undefined && duration < minDuration) { return false; } @@ -1272,8 +1287,10 @@ export function collectThreadNetwork( // Build per-request entries const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { - const data = fullMarkerList[i].data as NetworkPayload; - const duration = data.endTime - data.startTime; + const marker = fullMarkerList[i]; + const data = marker.data as NetworkPayload; + const duration = durationOf(i); + const inFlight = data.status === 'STATUS_START'; return { markerHandle: markerMap.handleForMarker(threadIndexes, i), @@ -1282,8 +1299,12 @@ export function collectThreadNetwork( httpVersion: data.httpVersion, cacheStatus: data.cache, transferSizeKB: data.count !== undefined ? data.count / 1024 : undefined, - startTime: data.startTime, + startTime: marker.start, duration, + incomplete: inFlight, + // Completed request whose START predates the recording: the reported + // duration is a lower bound (clamped to the range). + startedBeforeRecording: marker.incomplete === true && !inFlight, phases: buildNetworkPhases(data), }; }); @@ -1296,6 +1317,7 @@ export function collectThreadNetwork( threadHandle: displayThreadHandle, friendlyThreadName, totalRequestCount, + incompleteCount, filteredRequestCount, filters: searchString !== undefined || diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index b3e24da086..576b52c6b3 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -436,6 +436,13 @@ export type NetworkRequestEntry = { transferSizeKB?: number; startTime: number; duration: number; + // 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,6 +451,8 @@ export type ThreadNetworkResult = { threadHandle: string; friendlyThreadName: string; totalRequestCount: number; + // Requests still in flight when the recording stopped. + incompleteCount: number; filteredRequestCount: number; filters?: { searchString?: string; diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 080101d087..95f6372a3b 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,40 @@ 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('filters by searchString case-insensitively', function () { const { store, threadMap, markerMap } = setupWithNetworkMarkers([ { From 591f2919fd7f2687e6cd3347c6b0c11962e36264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Tue, 7 Jul 2026 14:49:03 +0200 Subject: [PATCH 4/6] Sort thread network requests slowest-first by default in the cli Requests are now sorted by in-range duration descending by default, with --sort start to get chronological order back. A limited window (the default 20 rows) previously showed the first 20 requests chronologically; agents need the slowest ones. The order is reflected in the header and exposed as an explicit `sort` field in JSON. --- profiler-cli/README.md | 3 ++- profiler-cli/guide.txt | 6 ++++-- profiler-cli/schemas.txt | 1 + profiler-cli/src/commands/thread.ts | 12 +++++++++++ profiler-cli/src/formatters.ts | 10 +++++---- profiler-cli/src/protocol.ts | 3 +++ .../src/test/unit/network-formatting.test.ts | 11 ++++++++++ src/profile-query/formatters/marker-info.ts | 21 ++++++++++++++++--- src/profile-query/index.ts | 2 ++ src/profile-query/types.ts | 4 ++++ 10 files changed, 63 insertions(+), 10 deletions(-) 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 76a086885a..ac676da6f3 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -68,7 +68,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 +321,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 diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 7ff4983ff7..5aca6eb5f0 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -152,6 +152,7 @@ profiler-cli thread network --json totalRequestCount, incompleteCount, filteredRequestCount, + sort: "duration" | "start", filters?: { searchString?, minDuration?, maxDuration?, limit? }, summary: { cacheHit, cacheMiss, cacheUnknown, 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 40aca0178b..cbc94ef686 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -1259,9 +1259,11 @@ export function formatThreadNetworkResult( : ''; 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}` @@ -1358,11 +1360,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..df92362611 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -33,6 +33,7 @@ export type { ThreadMarkersResult, ThreadNetworkResult, NetworkRequestEntry, + NetworkRequestSort, NetworkPhaseTimings, ThreadFunctionsResult, ThreadPageLoadResult, @@ -77,6 +78,7 @@ import type { ThreadSamplesBottomUpResult, ThreadMarkersResult, ThreadNetworkResult, + NetworkRequestSort, ThreadFunctionsResult, ThreadPageLoadResult, FilterStackResult, @@ -133,6 +135,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 52cc424081..aada4a3885 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -62,6 +62,7 @@ function makeResult( totalRequestCount: 1, incompleteCount: 0, filteredRequestCount: 1, + sort: 'duration', summary: makeSummary(), requests: [makeRequest()], ...overrides, @@ -324,4 +325,14 @@ describe('formatThreadNetworkResult', function () { 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'); + }); }); diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index f9b57501de..75e4250572 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -41,6 +41,7 @@ import type { ThreadMarkersResult, ThreadNetworkResult, NetworkRequestEntry, + NetworkRequestSort, NetworkPhaseTimings, MarkerGroupData, DurationStats, @@ -1149,9 +1150,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 = @@ -1278,12 +1281,23 @@ 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 sortedIndexes = filteredIndexes.slice(); + if (sort === 'duration') { + sortedIndexes.sort((a, b) => durationOf(b) - durationOf(a)); + } else { + sortedIndexes.sort( + (a, b) => fullMarkerList[a].start - fullMarkerList[b].start + ); + } + + // Apply limit after accumulating summary stats and sorting. // limit === 0 means "show all" (no limit). const limitedIndexes = limit !== undefined && limit > 0 - ? filteredIndexes.slice(0, limit) - : filteredIndexes; + ? sortedIndexes.slice(0, limit) + : sortedIndexes; // Build per-request entries const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { @@ -1319,6 +1333,7 @@ export function collectThreadNetwork( totalRequestCount, incompleteCount, filteredRequestCount, + sort, filters: searchString !== undefined || minDuration !== undefined || diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 425b80d626..3c32a0b2d2 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, @@ -1048,6 +1049,7 @@ export class ProfileQuerier { minDuration?: number; maxDuration?: number; limit?: number; + sort?: NetworkRequestSort; } ): Promise> { const result = collectThreadNetwork( diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 576b52c6b3..285efb4451 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -427,6 +427,8 @@ export type NetworkPhaseTimings = { mainThread?: number; }; +export type NetworkRequestSort = 'start' | 'duration'; + export type NetworkRequestEntry = { markerHandle: string; url: string; @@ -454,6 +456,8 @@ export type ThreadNetworkResult = { // 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; From 1386975044e1e49658ad751adf0cffc757c44d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 6 Jul 2026 18:41:43 +0200 Subject: [PATCH 5/6] Surface network activity in `pq profile info` and `pq thread info` Add a network-activity summary to the two entry-point commands so wall-clock waiting is as visible as CPU usage. The headline metric is the interval-union time in flight, plus peak concurrency, in-flight-at- end counts, and the slowest requests carrying marker handles for drill-down. Profile-wide numbers are deduped across processes since the parent process copies every request. The section renders after CPU activity in both commands. Extends network-summary.ts with the per-thread and profile-wide computation on top of the shared helpers, and updates the guide, JSON schemas, and README so the agent-facing prompt teaches the new signals (CPU-bound vs wait-bound, the in-flight metric, marker handles, the long-lived streaming/long-poll annotation, and the --sort flag). --- profiler-cli/guide.txt | 7 + profiler-cli/schemas.txt | 28 ++ profiler-cli/src/formatters.ts | 181 +++++++++ profiler-cli/src/protocol.ts | 4 + .../src/test/unit/network-formatting.test.ts | 117 +++++- src/profile-query/formatters/profile-info.ts | 10 + src/profile-query/formatters/thread-info.ts | 11 + src/profile-query/index.ts | 2 + src/profile-query/network-summary.ts | 384 +++++++++++++++++- src/profile-query/types.ts | 81 ++++ .../profile-query/network-summary.test.ts | 293 +++++++++++++ .../profile-query/profile-querier.test.ts | 67 +++ 12 files changed, 1183 insertions(+), 2 deletions(-) diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index ac676da6f3..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) @@ -361,6 +365,9 @@ 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 5aca6eb5f0..b91a64dc09 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", diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index cbc94ef686..bce0dd33ab 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) { diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index df92362611..e4f8adec54 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -35,6 +35,10 @@ export type { NetworkRequestEntry, NetworkRequestSort, NetworkPhaseTimings, + NetworkSummaryRequest, + ThreadNetworkSummary, + ProfileNetworkSummary, + ProfileNetworkThreadBreakdown, ThreadFunctionsResult, ThreadPageLoadResult, NavigationMilestone, diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index aada4a3885..9bbacc4c8e 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, @@ -335,4 +341,113 @@ describe('formatThreadNetworkResult', function () { const startResult = makeResult({ sort: 'start' }); expect(formatThreadNetworkResult(startResult)).toContain('chronological'); }); + +}); + +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/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 3c32a0b2d2..bbd1c28640 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -213,6 +213,7 @@ export class ProfileQuerier { this._store, this._timestampManager, this._threadMap, + this._markerMap, this._processIndexMap, showAll, search @@ -249,6 +250,7 @@ export class ProfileQuerier { this._store, this._timestampManager, this._threadMap, + this._markerMap, threadHandle ); return { ...result, context: this._getContext() }; diff --git a/src/profile-query/network-summary.ts b/src/profile-query/network-summary.ts index 2b73a01428..fa3664a0f4 100644 --- a/src/profile-query/network-summary.ts +++ b/src/profile-query/network-summary.ts @@ -2,6 +2,47 @@ * 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. + */ +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. */ @@ -20,9 +61,74 @@ export function classifyCache( 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. + */ +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 (callers clamp them to the range of interest first). + * [start, end] pairs already clamped to the range. */ export function intervalUnionMs(intervals: Array<[number, number]>): number { if (intervals.length === 0) { @@ -69,3 +175,279 @@ export function peakConcurrency(intervals: Array<[number, number]>): number { } return peak; } + +/** + * Clamp a record's interval to the range. + */ +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. + */ +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 285efb4451..5472f786a5 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 & { @@ -479,6 +481,84 @@ export type ThreadNetworkResult = { 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; @@ -819,4 +899,5 @@ export type ProfileInfoResult = { cpuMs: number; depthLevel: number; }> | null; + networkActivity: ProfileNetworkSummary | null; }; diff --git a/src/test/unit/profile-query/network-summary.test.ts b/src/test/unit/profile-query/network-summary.test.ts index 372be214a3..d21fc4c815 100644 --- a/src/test/unit/profile-query/network-summary.test.ts +++ b/src/test/unit/profile-query/network-summary.test.ts @@ -6,7 +6,71 @@ 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 () { @@ -100,3 +164,232 @@ describe('interval helpers', function () { }); }); }); + +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); + }); + }); }); From 1373a9d32ac93541be789e00a2bd1876fd2b3153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Naz=C4=B1m=20Can=20Alt=C4=B1nova?= Date: Mon, 13 Jul 2026 18:40:52 +0200 Subject: [PATCH 6/6] Share network record-gathering between `thread network` and the info summaries Route `collectThreadNetwork` through the `gatherNetworkRecords` / `clampInterval` / `clampedDurationMs` helpers that `profile info` and `thread info` already use, and export them from network-summary.ts. This drops the command's own inline copy of the gather / clamp / count / cache logic, so there is a single source of truth for "what are this thread's network requests." --- profiler-cli/schemas.txt | 3 +- profiler-cli/src/formatters.ts | 4 + .../src/test/unit/network-formatting.test.ts | 20 +++ src/profile-query/formatters/marker-info.ts | 129 ++++++++---------- src/profile-query/network-summary.ts | 8 +- src/profile-query/types.ts | 4 + .../unit/profile-query/marker-utils.test.ts | 64 +++++++++ 7 files changed, 155 insertions(+), 77 deletions(-) diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index b91a64dc09..c495ebd243 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -189,7 +189,8 @@ profiler-cli thread network --json }, requests: [{ markerHandle, url, httpStatus?, httpVersion?, cacheStatus?, - transferSizeKB?, startTime, duration, incomplete, startedBeforeRecording, + transferSizeKB?, startTime, duration, status, + incomplete, startedBeforeRecording, phases: { dns?, tcp?, tls?, ttfb?, download?, mainThread? } }], context: SessionContext diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index bce0dd33ab..13f259f638 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -1510,6 +1510,10 @@ export function formatThreadNetworkResult( 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 { diff --git a/profiler-cli/src/test/unit/network-formatting.test.ts b/profiler-cli/src/test/unit/network-formatting.test.ts index 9bbacc4c8e..346680439e 100644 --- a/profiler-cli/src/test/unit/network-formatting.test.ts +++ b/profiler-cli/src/test/unit/network-formatting.test.ts @@ -34,6 +34,7 @@ function makeRequest( url: 'https://example.com/resource', startTime: 0, duration: 100, + status: 'STATUS_STOP', incomplete: false, startedBeforeRecording: false, phases: {}, @@ -342,6 +343,25 @@ describe('formatThreadNetworkResult', function () { 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( diff --git a/src/profile-query/formatters/marker-info.ts b/src/profile-query/formatters/marker-info.ts index 75e4250572..530c1fb164 100644 --- a/src/profile-query/formatters/marker-info.ts +++ b/src/profile-query/formatters/marker-info.ts @@ -14,6 +14,9 @@ import { intervalUnionMs, peakConcurrency, classifyCache, + gatherNetworkRecords, + clampInterval, + clampedDurationMs, } from '../network-summary'; import { getThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { @@ -51,7 +54,6 @@ import type { ProfileLogsResult, } from '../types'; import { - isNetworkMarker, LOG_LEVEL_STRING_TO_LETTER, LOG_LETTER_TO_LEVEL, formatLogTimestamp, @@ -1164,70 +1166,53 @@ export function collectThreadNetwork( const threadSelectors = getThreadSelectors(threadIndexes); const friendlyThreadName = threadSelectors.getFriendlyThreadName(state); - const fullMarkerList = threadSelectors.getFullMarkerList(state); - const allMarkerIndexes = threadSelectors.getFullMarkerListIndexes(state); const range = getCommittedRange(state); const rangeDurationMs = range.end - range.start; - // The displayable request set is completed (STATUS_STOP) markers plus - // requests still in flight at the end (STATUS_START only, no stop event). - // "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 and must not be miscounted as in flight. - const requestIndexes = allMarkerIndexes.filter((i) => { - const marker = fullMarkerList[i]; - if (!isNetworkMarker(marker)) { - return false; - } - const status = (marker.data as NetworkPayload).status; - return status === 'STATUS_STOP' || status === 'STATUS_START'; - }); - const incompleteCount = requestIndexes.filter( - (i) => (fullMarkerList[i].data as NetworkPayload).status === 'STATUS_START' - ).length; - const totalRequestCount = requestIndexes.length - incompleteCount; - - // In-range duration: a request can start before the range or end after it - // (requests still in flight at the recording end). Clamping keeps the - // reported duration from exceeding what the profile can show. - const durationOf = (markerIndex: MarkerIndex): number => { - const marker = fullMarkerList[markerIndex]; - const start = Math.max(marker.start, range.start); - const end = Math.min(marker.end ?? marker.start, range.end); - return Math.max(0, end - 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 + ); - // Wall-clock: interval union and peak concurrency over all requests - // intersecting the range, independent of the display filters below. - const inFlightIntervals: Array<[number, number]> = []; - for (const i of requestIndexes) { - const marker = fullMarkerList[i]; - const start = marker.start; - const end = marker.end ?? marker.start; - if (end < range.start || start > range.end) { - continue; + // 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++; } - inFlightIntervals.push([ - Math.max(start, range.start), - Math.min(end, range.end), - ]); } + + // 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 = requestIndexes; + 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 duration = durationOf(i); + filteredRecords = filteredRecords.filter((record) => { + const duration = clampedDurationMs(record, range); if (minDuration !== undefined && duration < minDuration) { return false; } @@ -1238,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 = {}; @@ -1246,8 +1231,8 @@ export function collectThreadNetwork( let cacheMiss = 0; let cacheUnknown = 0; - for (const i of filteredIndexes) { - const data = fullMarkerList[i].data as NetworkPayload; + for (const record of filteredRecords) { + const data = record.data; switch (classifyCache(data.cache)) { case 'hit': cacheHit++; @@ -1283,42 +1268,42 @@ export function collectThreadNetwork( // Sort before applying the limit so a limited window shows the intended // requests (slowest by default; chronological with --sort start). - const sortedIndexes = filteredIndexes.slice(); + const sortedRecords = filteredRecords.slice(); if (sort === 'duration') { - sortedIndexes.sort((a, b) => durationOf(b) - durationOf(a)); - } else { - sortedIndexes.sort( - (a, b) => fullMarkerList[a].start - fullMarkerList[b].start + 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 - ? sortedIndexes.slice(0, limit) - : sortedIndexes; + ? sortedRecords.slice(0, limit) + : sortedRecords; // Build per-request entries - const requests: NetworkRequestEntry[] = limitedIndexes.map((i) => { - const marker = fullMarkerList[i]; - const data = marker.data as NetworkPayload; - const duration = durationOf(i); - const inFlight = data.status === 'STATUS_START'; + 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: marker.start, + startTime: record.start, duration, - incomplete: inFlight, - // Completed request whose START predates the recording: the reported - // duration is a lower bound (clamped to the range). - startedBeforeRecording: marker.incomplete === true && !inFlight, + status: data.status, + incomplete: record.incomplete, + startedBeforeRecording: record.startedBeforeRecording, phases: buildNetworkPhases(data), }; }); diff --git a/src/profile-query/network-summary.ts b/src/profile-query/network-summary.ts index fa3664a0f4..8c24aaef2e 100644 --- a/src/profile-query/network-summary.ts +++ b/src/profile-query/network-summary.ts @@ -29,7 +29,7 @@ type State = ReturnType; /** * One derived network marker, resolved to absolute times and a handle. */ -type NetworkRecord = { +export type NetworkRecord = { threadIndex: ThreadIndex; threadIndexes: Set; markerIndex: MarkerIndex; @@ -86,7 +86,7 @@ function threadHasRawNetworkMarker( * Gather the derived network markers for a thread set that intersect `range`. * Times are absolute (same scale as `range`); intervals are clamped by callers. */ -function gatherNetworkRecords( +export function gatherNetworkRecords( state: State, threadIndex: ThreadIndex, threadIndexes: Set, @@ -179,7 +179,7 @@ export function peakConcurrency(intervals: Array<[number, number]>): number { /** * Clamp a record's interval to the range. */ -function clampInterval( +export function clampInterval( record: NetworkRecord, range: StartEndRange ): [number, number] { @@ -191,7 +191,7 @@ function clampInterval( * after it; clamping keeps the reported duration from exceeding what the * profile can actually show. */ -function clampedDurationMs( +export function clampedDurationMs( record: NetworkRecord, range: StartEndRange ): number { diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5472f786a5..0eb5c4c736 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -440,6 +440,10 @@ 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. diff --git a/src/test/unit/profile-query/marker-utils.test.ts b/src/test/unit/profile-query/marker-utils.test.ts index 95f6372a3b..c1cbb2d51e 100644 --- a/src/test/unit/profile-query/marker-utils.test.ts +++ b/src/test/unit/profile-query/marker-utils.test.ts @@ -796,6 +796,70 @@ describe('collectThreadNetwork', function () { 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([ {