diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index bc1cca6bfb..65cef7e2eb 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -117,6 +117,7 @@ HANDLE SYSTEM t-0, t-1 Thread handles (from "profile info") m-1234 Marker handles (from "thread markers") f-12 Function handles (from "thread samples", "thread functions") + c-0, c-1 Counter handles (from "counter list" or "profile info") ts-6 Timestamp handles (named points in time, usable with "zoom push") Handle lifetime and stability: @@ -127,6 +128,8 @@ HANDLE SYSTEM m-N thread markers No -- rebuilt each time the daemon starts f-N thread samples, Yes -- direct index into the profile's function thread functions table; same profile always yields the same f-N + c-N counter list Yes -- direct index into the profile's counter + array; same profile always yields the same c-N ts-N thread markers No -- position-based, session-scoped ────────────────────────────────────────────────────────────────────────── @@ -215,6 +218,31 @@ FILTERS profiler-cli filter push --during-marker --search Paint +COUNTERS + + Counters are time series the profiler records alongside samples: memory usage, + network bandwidth, process CPU, power, and similar. Each counter has a handle + (c-0, c-1, ...) and carries its own display metadata (label, unit, graph type). + + profiler-cli counter list List all counters with one-line summaries + profiler-cli counter info c-0 Detailed info and stats for one counter + + Counters also appear in "profile info", listed under their owning process + next to that process's threads (much like the timeline track list). + + The stats shown come from the counter's own tooltip schema, so they match the + timeline tooltips. Each counter reports its whole-range aggregates, e.g. the + memory range for Memory, data transferred for Bandwidth, or energy used (with a + CO2e estimate) for Power. + + All counter stats respect the current zoom: with no zoom they cover the whole + profile; after "zoom push" they cover the committed range. Combine with zoom to + see, for example, how much memory a specific time window allocated: + + profiler-cli zoom push 2.7,3.1 + profiler-cli counter info c-0 + + JSON OUTPUT Add --json to any command to get structured JSON output, suitable for piping to jq diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 2a13b1389f..bb0afab549 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -19,12 +19,39 @@ profiler-cli profile info --json processes: [{ pid, name, cpuMs, threads: [{ threadHandle, threadIndex, name, tid, cpuMs }], - remainingThreads?: { count, combinedCpuMs, maxCpuMs } + remainingThreads?: { count, combinedCpuMs, maxCpuMs }, + counters?: [CounterSummary] }], remainingProcesses?: { count, combinedCpuMs, maxCpuMs }, context: SessionContext } +CounterSummary: + { + counterHandle, counterIndex, name, label, category, + unit, graphType, + color, pid, mainThreadIndex, mainThreadHandle, mainThreadName, + rangeSampleCount, + stats: [{ source, label, labelKey?, value, formattedValue, carbon? }] + } + +profiler-cli counter list --json + { + type: "counter-list", + counters: [CounterSummary], + context: SessionContext + } + +profiler-cli counter info --json + { + type: "counter-info", + ...CounterSummary, + description, + sampleCount, + rangeStart, rangeEnd, + context: SessionContext + } + profiler-cli thread samples --json { type: "thread-samples", diff --git a/profiler-cli/src/commands/counter.ts b/profiler-cli/src/commands/counter.ts new file mode 100644 index 0000000000..15a8e5d9a5 --- /dev/null +++ b/profiler-cli/src/commands/counter.ts @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * `profiler-cli counter` command. + */ + +import type { Command } from 'commander'; +import { sendCommand } from '../client'; +import { formatOutput } from '../output'; +import { addGlobalOptions } from './shared'; + +export function registerCounterCommand( + program: Command, + sessionDir: string +): void { + const counter = program + .command('counter') + .description('Counter-level commands'); + + addGlobalOptions( + counter + .command('list') + .description('List all counters with one-line summaries') + ).action(async (opts) => { + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'list' }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); + + addGlobalOptions( + counter + .command('info [handle]') + .description('Show detailed information about a counter (e.g. c-0)') + .option('--counter ', 'Counter handle') + ).action(async (handleArg: string | undefined, opts) => { + const counterHandle = handleArg ?? opts.counter; + const result = await sendCommand( + sessionDir, + { command: 'counter', subcommand: 'info', counter: counterHandle }, + opts.session + ); + console.log(formatOutput(result, opts.json ?? false)); + }); +} diff --git a/profiler-cli/src/daemon.ts b/profiler-cli/src/daemon.ts index 992b827f62..4d4b3012fc 100644 --- a/profiler-cli/src/daemon.ts +++ b/profiler-cli/src/daemon.ts @@ -360,6 +360,18 @@ export class Daemon { default: throw assertExhaustiveCheck(command); } + case 'counter': + switch (command.subcommand) { + case 'list': + return this.querier!.counterList(); + case 'info': + if (!command.counter) { + throw new Error('counter handle required for counter info'); + } + return this.querier!.counterInfo(command.counter); + default: + throw assertExhaustiveCheck(command); + } case 'sample': switch (command.subcommand) { case 'info': diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index f62da72cd5..6dee2303bc 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -35,6 +35,9 @@ import type { SampleFilterSpec, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from './protocol'; import { truncateFunctionName } from '../../src/profile-query/function-list'; import { describeSpec } from '../../src/profile-query/filter-stack'; @@ -428,6 +431,10 @@ Name: ${result.name}\n`; if (process.remainingThreads) { output += ` + ${process.remainingThreads.count} more threads with combined CPU time ${process.remainingThreads.combinedCpuMs.toFixed(3)}ms and max CPU time ${process.remainingThreads.maxCpuMs.toFixed(3)}ms (use --all to see all)\n`; } + + for (const counter of process.counters ?? []) { + output += ` ${counter.counterHandle}: ${counter.label}${formatCounterStats(counter)}\n`; + } } if (result.remainingProcesses) { @@ -451,6 +458,85 @@ Name: ${result.name}\n`; return output; } +function formatCounterStatInline( + stat: CounterSummary['stats'][number] +): string { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + return `${stat.label}: ${value}`; +} + +/** The ` - stat; stat [N samples]` trailer shared by counter list and profile info. */ +function formatCounterStats(counter: CounterSummary): string { + const stats = + counter.stats.length > 0 + ? ` - ${counter.stats.map(formatCounterStatInline).join('; ')}` + : ''; + return `${stats} [${counter.rangeSampleCount} samples]`; +} + +function formatCounterSummaryLine(counter: CounterSummary): string { + return ` ${counter.counterHandle}: ${counter.label} (${counter.category})${formatCounterStats(counter)}`; +} + +/** + * Format a CounterListResult as plain text. + */ +export function formatCounterListResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + if (result.counters.length === 0) { + return `${contextHeader}\n\nNo counters in this profile.`; + } + const lines = result.counters.map(formatCounterSummaryLine); + return `${contextHeader}\n\nCounters (${result.counters.length}):\n${lines.join('\n')}`; +} + +/** + * Format a CounterInfoResult as plain text. + */ +export function formatCounterInfoResult( + result: WithContext +): string { + const contextHeader = formatContextHeader(result.context); + const lines = [ + contextHeader, + '', + `Counter ${result.counterHandle}: ${result.label}`, + ` Name: ${result.name}`, + ` Category: ${result.category}`, + ]; + if (result.description) { + lines.push(` Description: ${result.description}`); + } + lines.push(` Unit: ${result.unit || '(none)'}`); + lines.push(` Graph type: ${result.graphType}`); + lines.push( + ` Main thread: ${result.mainThreadHandle} (${result.mainThreadName})` + ); + lines.push( + ` Samples: ${result.sampleCount} total, ${result.rangeSampleCount} in current range` + ); + if (result.rangeStart !== null && result.rangeEnd !== null) { + const zeroAt = result.context.rootRange.start; + lines.push( + ` Time span: ${formatDuration(result.rangeStart - zeroAt)} → ${formatDuration(result.rangeEnd - zeroAt)}` + ); + } + if (result.stats.length > 0) { + lines.push(' Stats (current range):'); + for (const stat of result.stats) { + const value = stat.carbon + ? `${stat.formattedValue} (${stat.carbon})` + : stat.formattedValue; + lines.push(` ${stat.label}: ${value}`); + } + } + return lines.join('\n'); +} + /** * Helper function to format a call tree node recursively. * diff --git a/profiler-cli/src/index.ts b/profiler-cli/src/index.ts index 05b7a640a7..c637516b9b 100644 --- a/profiler-cli/src/index.ts +++ b/profiler-cli/src/index.ts @@ -37,6 +37,7 @@ import { registerProfileCommand } from './commands/profile'; import { registerThreadCommand } from './commands/thread'; import { registerMarkerCommand } from './commands/marker'; import { registerFunctionCommand } from './commands/function'; +import { registerCounterCommand } from './commands/counter'; import { registerZoomCommand } from './commands/zoom'; import { registerFilterCommand } from './commands/filter'; import { registerSessionCommand } from './commands/session'; @@ -85,6 +86,8 @@ Examples: profiler-cli thread samples profiler-cli thread functions --search GC --min-self 1 profiler-cli thread markers --search DOMEvent --category Graphics + profiler-cli counter list + profiler-cli counter info c-0 profiler-cli zoom push 2.7,3.1 profiler-cli filter push --excludes-function f-184 profiler-cli status @@ -180,6 +183,7 @@ Examples: registerThreadCommand(program, SESSION_DIR); registerMarkerCommand(program, SESSION_DIR); registerFunctionCommand(program, SESSION_DIR); + registerCounterCommand(program, SESSION_DIR); registerZoomCommand(program, SESSION_DIR); registerFilterCommand(program, SESSION_DIR); registerSessionCommand(program, SESSION_DIR); diff --git a/profiler-cli/src/output.ts b/profiler-cli/src/output.ts index 36d8fc6345..a983f136cb 100644 --- a/profiler-cli/src/output.ts +++ b/profiler-cli/src/output.ts @@ -28,6 +28,8 @@ import { formatProfileLogsResult, formatThreadPageLoadResult, formatThreadSelectResult, + formatCounterListResult, + formatCounterInfoResult, } from './formatters'; /** @@ -88,6 +90,10 @@ export function formatOutput( return formatThreadPageLoadResult(result); case 'thread-select': return formatThreadSelectResult(result); + case 'counter-list': + return formatCounterListResult(result); + case 'counter-info': + return formatCounterInfoResult(result); default: throw assertExhaustiveCheck(result); } diff --git a/profiler-cli/src/protocol.ts b/profiler-cli/src/protocol.ts index 330dfe479f..3e817f9b43 100644 --- a/profiler-cli/src/protocol.ts +++ b/profiler-cli/src/protocol.ts @@ -50,6 +50,9 @@ export type { ProfileInfoResult, ProfileLogsResult, ThreadSelectResult, + CounterSummary, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; export type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -79,6 +82,8 @@ import type { FilterStackResult, ProfileLogsResult, ThreadSelectResult, + CounterListResult, + CounterInfoResult, } from '../../src/profile-query/types'; import type { CallTreeCollectionOptions } from '../../src/profile-query/formatters/call-tree'; @@ -141,6 +146,11 @@ export type ClientCommand = subcommand: 'info' | 'select' | 'stack'; marker?: string; } + | { + command: 'counter'; + subcommand: 'list' | 'info'; + counter?: string; + } | { command: 'sample'; subcommand: 'info' | 'select'; sample?: string } | { command: 'function'; @@ -195,7 +205,9 @@ export type CommandResult = | WithContext | WithContext | WithContext - | WithContext; + | WithContext + | WithContext + | WithContext; export interface SessionMetadata { id: string; diff --git a/profiler-cli/src/test/integration/counter.test.ts b/profiler-cli/src/test/integration/counter.test.ts new file mode 100644 index 0000000000..a44a48b68a --- /dev/null +++ b/profiler-cli/src/test/integration/counter.test.ts @@ -0,0 +1,138 @@ +/* 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/. */ + +/** + * CLI counter command tests. + */ + +import { + createTestContext, + cleanupTestContext, + cli, + cliFail, + type CliTestContext, +} from './utils'; + +import type { + CounterListResult, + CounterInfoResult, + ProfileInfoResult, + WithContext, +} from '../../protocol'; + +// processed-3.json has a single Memory counter (name "malloc", category "Memory"). +const PROFILE_WITH_COUNTER = 'src/test/fixtures/upgrades/processed-3.json'; +// processed-1.json has no counters. +const PROFILE_WITHOUT_COUNTER = 'src/test/fixtures/upgrades/processed-1.json'; + +describe('profiler-cli counter commands', () => { + let ctx: CliTestContext; + + beforeEach(async () => { + ctx = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(ctx); + }); + + it('counter list shows the memory counter', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['counter', 'list']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Counters (1)'); + expect(result.stdout).toContain('c-0'); + expect(result.stdout).toContain('Memory'); + }); + + it('counter list --json returns structured data', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['counter', 'list', '--json']); + const parsed = JSON.parse(result.stdout) as WithContext; + + expect(parsed.type).toBe('counter-list'); + expect(parsed.counters).toHaveLength(1); + const counter = parsed.counters[0]; + expect(counter.counterHandle).toBe('c-0'); + expect(counter.label).toBe('Memory'); + expect(counter.category).toBe('Memory'); + expect(counter.unit).toBe('bytes'); + expect(counter.graphType).toBe('line-accumulated'); + expect(counter.mainThreadHandle).toMatch(/^t-\d+$/); + + // The Memory tooltip schema exposes a single range-aggregate row, + // "memory range in graph" (source count-range), formatted in bytes. + const rangeStat = counter.stats.find((s) => s.source === 'count-range'); + expect(rangeStat).toBeDefined(); + expect(rangeStat!.label).toContain('memory range'); + expect(rangeStat!.formattedValue).toMatch(/B$|KB$|MB$|GB$/); + }); + + it('counter info shows details for a counter', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['counter', 'info', 'c-0']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Counter c-0: Memory'); + expect(result.stdout).toContain('Category: Memory'); + expect(result.stdout).toContain('Unit: bytes'); + expect(result.stdout).toContain('Amount of allocated memory'); + }); + + it('counter info --json includes detail fields', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['counter', 'info', 'c-0', '--json']); + const parsed = JSON.parse(result.stdout) as WithContext; + + expect(parsed.type).toBe('counter-info'); + expect(parsed.counterHandle).toBe('c-0'); + expect(parsed.description).toBe('Amount of allocated memory'); + expect(parsed.sampleCount).toBeGreaterThan(0); + expect(parsed.stats.some((s) => s.source === 'count-range')).toBe(true); + }); + + it('profile info lists counters under their process', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['profile', 'info']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('c-0'); + expect(result.stdout).toContain('Memory'); + }); + + it('profile info --json nests counters in their owning process', async () => { + await cli(ctx, ['load', PROFILE_WITH_COUNTER]); + + const result = await cli(ctx, ['profile', 'info', '--json']); + const parsed = JSON.parse(result.stdout) as WithContext; + + const counters = parsed.processes.flatMap((p) => p.counters ?? []); + expect(counters).toHaveLength(1); + expect(counters[0].counterHandle).toBe('c-0'); + }); + + it('counter list reports when a profile has no counters', async () => { + await cli(ctx, ['load', PROFILE_WITHOUT_COUNTER]); + + const result = await cli(ctx, ['counter', 'list']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No counters in this profile.'); + }); + + it('counter info fails cleanly for an unknown handle', async () => { + await cli(ctx, ['load', PROFILE_WITHOUT_COUNTER]); + + const result = await cliFail(ctx, ['counter', 'info', 'c-0']); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('Unknown counter c-0'); + }); +}); diff --git a/src/profile-query/counter-map.ts b/src/profile-query/counter-map.ts new file mode 100644 index 0000000000..8e6ae8a5f1 --- /dev/null +++ b/src/profile-query/counter-map.ts @@ -0,0 +1,37 @@ +/* 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 type { CounterIndex } from 'firefox-profiler/types'; + +/** + * A handle like "c-2" always refers to counter index 2 for this profile, + * making handles stable across sessions for the same processed profile data. + */ +export function getCounterHandle(counterIndex: CounterIndex): `c-${number}` { + return `c-${counterIndex}`; +} + +/** + * Parse a counter handle and validate it against the number of counters. + */ +export function parseCounterHandle( + counterHandle: string, + counterCount: number +): CounterIndex { + const match = /^c-(\d+)$/.exec(counterHandle); + if (match === null) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + const counterIndex = Number(match[1]); + if ( + !Number.isInteger(counterIndex) || + counterIndex < 0 || + counterIndex >= counterCount + ) { + throw new Error(`Unknown counter ${counterHandle}`); + } + + return counterIndex; +} diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts new file mode 100644 index 0000000000..36834b653d --- /dev/null +++ b/src/profile-query/formatters/counter-info.ts @@ -0,0 +1,261 @@ +/* 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 { + getProfile, + getProfileRootRange, + getCounters, + getCounterSelectors, + getCommittedRange, + getMeta, +} from 'firefox-profiler/selectors/profile'; +import { getSampleIndexRangeForSelection } from 'firefox-profiler/profile-logic/profile-data'; +import { + formatBytes, + formatNumber, + formatPercent, +} from 'firefox-profiler/utils/format-numbers'; +import { + pwhToWh, + carbonForBytes, + carbonForWattHours, + POWER_LADDER, + ENERGY_LADDER, + pickTier, +} from 'firefox-profiler/components/timeline/TrackCounterTooltipFormat'; +import { getCounterHandle, parseCounterHandle } from '../counter-map'; +import type { + CounterIndex, + CounterTooltipDataSource, + CounterTooltipFormat, + CounterSamplesTable, + ProfileMeta, +} from 'firefox-profiler/types'; +import type { Store } from '../../types/store'; +import type { ThreadMap } from '../thread-map'; +import type { + CounterStat, + CounterSummary, + CounterListResult, + CounterInfoResult, +} from '../types'; + +// The tooltip schema describes many per-sample and preview-selection rows that +// only make sense at a hover point. These are the two sources that aggregate +// over the whole committed range, so they are the only ones a CLI summary can +// resolve without a cursor. +const RANGE_AGGREGATE_SOURCES: Set = new Set([ + 'count-range', + 'committed-range-total', +]); + +function sumCountOverRange( + samples: CounterSamplesTable, + start: number, + end: number +): number { + const [begin, finish] = getSampleIndexRangeForSelection(samples, start, end); + let sum = 0; + for (let i = begin; i < finish; i++) { + sum += samples.count[i]; + } + return sum; +} + +/** + * Format a resolved counter value exactly as the timeline tooltip does, minus + * the React/localization wrapping. Only the range-aggregate sources reach this, + * so the power-`scale` ladder normalization (which needs a per-sample dt) never + * applies; energy values are pWh sums converted to watt-hours. + */ +function formatCounterRowValue( + value: number, + format: CounterTooltipFormat, + meta: ProfileMeta +): { formattedValue: string; carbon?: string } { + if (format.scale) { + const valueForLadder = format.scale === 'energy' ? pwhToWh(value) : value; + const ladder = format.scale === 'power' ? POWER_LADDER : ENERGY_LADDER; + const tier = pickTier(valueForLadder, ladder); + const formattedValue = `${formatNumber(valueForLadder * tier.multiplier, tier.valueSignificantDigits)} ${tier.unitText}`; + let carbon: string | undefined; + if (format.co2 === 'per-watthour' && format.scale === 'energy') { + const grams = carbonForWattHours(valueForLadder, meta); + carbon = `${formatNumber(grams * tier.carbonMultiplier, tier.carbonSignificantDigits)} ${tier.carbonUnitText}`; + } + return { formattedValue, carbon }; + } + + let formattedValue: string; + switch (format.unit) { + case 'bytes': + formattedValue = formatBytes(value); + break; + case 'bytes-per-second': + formattedValue = `${formatBytes(value * 1000)} per second`; + break; + case 'percent': + formattedValue = formatPercent(value); + break; + case 'number': + formattedValue = formatNumber(value, 2, 0); + break; + default: + formattedValue = formatNumber(value); + break; + } + + let carbon: string | undefined; + if (format.co2 === 'per-byte') { + const bytesForCarbon = + format.unit === 'bytes-per-second' ? value * 1000 : value; + carbon = `${formatNumber(carbonForBytes(bytesForCarbon))} g CO₂e`; + } + return { formattedValue, carbon }; +} + +/** + * Resolve the counter's range-aggregate tooltip rows into formatted stats. + */ +function collectCounterStats( + store: Store, + counterIndex: CounterIndex +): CounterStat[] { + const state = store.getState(); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const accumulated = selectors.getAccumulateCounterSamples(state); + const committedRange = getCommittedRange(state); + const meta = getMeta(state); + + const stats: CounterStat[] = []; + for (const row of counter.display.tooltipRows) { + if (row.type !== 'value') { + continue; + } + if (row.requiresPreviewSelection) { + continue; + } + if (!RANGE_AGGREGATE_SOURCES.has(row.source)) { + continue; + } + + const value = + row.source === 'count-range' + ? accumulated.countRange + : sumCountOverRange( + counter.samples, + committedRange.start, + committedRange.end + ); + + const { formattedValue, carbon } = formatCounterRowValue( + value, + row.format, + meta + ); + stats.push({ + source: row.source, + label: row.label, + labelKey: row.labelKey, + value, + formattedValue, + carbon, + }); + } + return stats; +} + +/** + * Build the shared summary for a single counter. The stats cover the current + * committed (zoom) range, since the underlying counter selectors are + * range-aware. + */ +export function collectCounterSummary( + store: Store, + threadMap: ThreadMap, + counterIndex: CounterIndex +): CounterSummary { + const state = store.getState(); + const profile = getProfile(state); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const { display } = counter; + + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const mainThreadName = profile.threads[counter.mainThreadIndex]?.name ?? ''; + + return { + counterHandle: getCounterHandle(counterIndex), + counterIndex, + name: counter.name, + label: display.label || counter.name, + category: counter.category, + unit: display.unit, + graphType: display.graphType, + color: display.color, + pid: counter.pid, + mainThreadIndex: counter.mainThreadIndex, + mainThreadHandle: threadMap.handleForThreadIndex(counter.mainThreadIndex), + mainThreadName, + rangeSampleCount: Math.max(0, rangeEndIndex - rangeStartIndex), + stats: collectCounterStats(store, counterIndex), + }; +} + +/** + * Build summaries for every counter in the profile. Returns an empty list when + * the profile has no counters. + */ +export function collectCounterList( + store: Store, + threadMap: ThreadMap +): CounterListResult { + const counters = getCounters(store.getState()) ?? []; + return { + type: 'counter-list', + counters: counters.map((_, index) => + collectCounterSummary(store, threadMap, index) + ), + }; +} + +/** + * Build detailed information about a single counter, resolved by handle. + */ +export function collectCounterInfo( + store: Store, + threadMap: ThreadMap, + counterHandle: string +): CounterInfoResult { + const state = store.getState(); + const counters = getCounters(state) ?? []; + const counterIndex = parseCounterHandle(counterHandle, counters.length); + + const summary = collectCounterSummary(store, threadMap, counterIndex); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const [rangeStartIndex, rangeEndIndex] = + selectors.getCommittedRangeCounterSampleRange(state); + + const zeroAt = getProfileRootRange(state).start; + const hasRange = rangeEndIndex > rangeStartIndex; + const rangeStart = hasRange + ? counter.samples.time[rangeStartIndex] + zeroAt + : null; + const rangeEnd = hasRange + ? counter.samples.time[rangeEndIndex - 1] + zeroAt + : null; + + return { + ...summary, + type: 'counter-info', + description: counter.description, + sampleCount: counter.samples.length, + rangeStart, + rangeEnd, + }; +} diff --git a/src/profile-query/formatters/profile-info.ts b/src/profile-query/formatters/profile-info.ts index 1cce4668d4..3695a8c106 100644 --- a/src/profile-query/formatters/profile-info.ts +++ b/src/profile-query/formatters/profile-info.ts @@ -6,15 +6,17 @@ import { getProfile, getThreadCPUTimeMs, getRangeFilteredCombinedThreadActivitySlices, + getCounters, } from 'firefox-profiler/selectors/profile'; import { getProfileNameWithDefault } from 'firefox-profiler/selectors/url-state'; import { buildProcessThreadList } from '../process-thread-list'; import { collectSliceTree } from '../cpu-activity'; +import { collectCounterSummary } from './counter-info'; 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 { ProfileInfoResult } from '../types'; +import type { ProfileInfoResult, CounterSummary } from '../types'; /** * Filter a list of processes by a search string. @@ -108,6 +110,15 @@ export function collectProfileInfo( ? applySearchFilter(result.processes, search) : result.processes; + const countersByPid = new Map(); + (getCounters(state) ?? []).forEach((_, index) => { + const counter = collectCounterSummary(store, threadMap, index); + const pid = String(profile.threads[counter.mainThreadIndex].pid); + const list = countersByPid.get(pid) ?? []; + list.push(counter); + countersByPid.set(pid, list); + }); + const processesData: ProfileInfoResult['processes'] = processesToShow.map( (processItem) => { let startTimeName: string | undefined; @@ -141,6 +152,7 @@ export function collectProfileInfo( cpuMs: thread.cpuMs, })), remainingThreads: processItem.remainingThreads, + counters: countersByPid.get(String(processItem.pid)), }; } ); diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 5ff640589d..73d9959263 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -62,6 +62,10 @@ import { collectProfileLogs, } from './formatters/marker-info'; import { collectThreadPageLoad } from './formatters/page-load'; +import { + collectCounterList, + collectCounterInfo, +} from './formatters/counter-info'; import { parseTimeValue } from './time-range-parser'; import { describeTransformGroup, pushSpecTransforms } from './filter-stack'; import { functionAnnotate as computeFunctionAnnotate } from './function-annotate'; @@ -92,6 +96,8 @@ import type { ThreadFunctionsResult, ThreadPageLoadResult, ProfileLogsResult, + CounterListResult, + CounterInfoResult, MarkerFilterOptions, FunctionFilterOptions, SampleFilterSpec, @@ -202,7 +208,7 @@ export class ProfileQuerier { showAll: boolean = false, search?: string ): Promise> { - const result = await collectProfileInfo( + const result = collectProfileInfo( this._store, this._timestampManager, this._threadMap, @@ -213,6 +219,22 @@ export class ProfileQuerier { return { ...result, context: this._getContext() }; } + async counterList(): Promise> { + const result = collectCounterList(this._store, this._threadMap); + return { ...result, context: this._getContext() }; + } + + async counterInfo( + counterHandle: string + ): Promise> { + const result = collectCounterInfo( + this._store, + this._threadMap, + counterHandle + ); + return { ...result, context: this._getContext() }; + } + async threadInfo( threadHandle?: string ): Promise> { diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 5cc2983a99..52c9a88874 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -7,7 +7,11 @@ * These types are used by both profile-query (the library) and profiler-cli. */ -import type { Transform } from 'firefox-profiler/types'; +import type { + Transform, + CounterGraphType, + CounterTooltipDataSource, +} from 'firefox-profiler/types'; // ===== Utility types ===== @@ -662,6 +666,60 @@ export type ThreadPageLoadResult = { jankPeriods: JankPeriod[]; // limited by jankLimit }; +// ===== Counter Commands ===== + +/** + * A single range-aggregate stat for a counter, derived from one of the + * counter's own `display.tooltipRows`. Only the rows whose data source is a + * whole-range aggregate (`count-range`, `committed-range-total`) are surfaced; + * per-sample and preview-selection rows have no CLI equivalent and are skipped. + * `label`, `value`, and `formattedValue` come straight from the tooltip schema, + * so the CLI and the timeline tooltips stay in lockstep. + */ +export type CounterStat = { + source: CounterTooltipDataSource; + label: string; + labelKey?: string; + value: number; + formattedValue: string; + carbon?: string; +}; + +/** + * One-line summary of a counter, shared by `counter list`, `counter info`, and + * the `profile info --counters` section. The `stats` cover the current + * committed (zoom) range, or the whole profile when not zoomed. + */ +export type CounterSummary = { + counterHandle: string; // e.g. "c-0" + counterIndex: number; + name: string; // raw counter name, e.g. "malloc" + label: string; // display.label || name, e.g. "Memory" + category: string; // e.g. "Memory" + unit: string; // display.unit, e.g. "bytes" + graphType: CounterGraphType; + color: string; + pid: string; + mainThreadIndex: number; + mainThreadHandle: string; // e.g. "t-0" + mainThreadName: string; + rangeSampleCount: number; // samples within the current range + stats: CounterStat[]; // range-aggregate stats from the tooltip schema +}; + +export type CounterListResult = { + type: 'counter-list'; + counters: CounterSummary[]; +}; + +export type CounterInfoResult = CounterSummary & { + type: 'counter-info'; + description: string; + sampleCount: number; // total samples in the counter (whole profile) + rangeStart: number | null; // absolute time of first in-range sample + rangeEnd: number | null; // absolute time of last in-range sample +}; + // ===== Profile Commands ===== export type ProfileInfoResult = { @@ -694,6 +752,7 @@ export type ProfileInfoResult = { combinedCpuMs: number; maxCpuMs: number; }; + counters?: CounterSummary[]; }>; remainingProcesses?: { count: number;