diff --git a/profiler-cli/guide.txt b/profiler-cli/guide.txt index 65cef7e2eb..65884b75a1 100644 --- a/profiler-cli/guide.txt +++ b/profiler-cli/guide.txt @@ -224,16 +224,16 @@ COUNTERS 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 list List all counters, each with a sparkline 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. + "counter info" also prints an "over time" section: the current view split into + time buckets. Accumulated counters (e.g. Memory) show each bucket's level and its + change; rate counters (e.g. Bandwidth, Power) show the amount per bucket and its + share of the range. 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 diff --git a/profiler-cli/schemas.txt b/profiler-cli/schemas.txt index 602560a352..b3a5de298f 100644 --- a/profiler-cli/schemas.txt +++ b/profiler-cli/schemas.txt @@ -32,7 +32,8 @@ CounterSummary: unit, graphType, color, pid, mainThreadIndex, mainThreadHandle, mainThreadName, rangeSampleCount, - stats: [{ source, label, value, formattedValue, carbon? }] + stats: [{ source, label, value, formattedValue, carbon? }], + graph: [number] } profiler-cli counter list --json @@ -49,6 +50,10 @@ profiler-cli counter info --json description, sampleCount, rangeStart, rangeEnd, + overTime: [{ startTime, startTimeName, startTimeStr, + endTime, endTimeName, endTimeStr, + value, formattedValue, delta?, formattedDelta?, + percentage?, formattedPercentage?, carbon? }], context: SessionContext } diff --git a/profiler-cli/src/formatters.ts b/profiler-cli/src/formatters.ts index 6dee2303bc..9b07369de2 100644 --- a/profiler-cli/src/formatters.ts +++ b/profiler-cli/src/formatters.ts @@ -490,8 +490,61 @@ export function formatCounterListResult( 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')}`; + const blocks = result.counters.map((counter) => { + const block = [formatCounterSummaryLine(counter)]; + if (counter.graph.length > 0) { + block.push(` ${counterSparkline(counter)}`); + } + return block.join('\n'); + }); + // Trailing blank line so the last counter's sparkline is separated from the + // prompt, matching the blank lines between counters. + return `${contextHeader}\n\nCounters (${result.counters.length}):\n${blocks.join('\n\n')}\n`; +} + +const SPARKLINE_CHARS = '▁▂▃▄▅▆▇█'; + +/** + * Render a compact sparkline of the values using block characters, scaled + * between `min` (floor) and `max` (top). Values are clamped to that range; a + * zero-width range renders at mid-height so it doesn't read as the floor. + */ +function renderSparkline(values: number[], min: number, max: number): string { + if (values.length === 0) { + return ''; + } + const range = max - min; + const lastIndex = SPARKLINE_CHARS.length - 1; + if (range <= 0) { + return SPARKLINE_CHARS[Math.floor(lastIndex / 2)].repeat(values.length); + } + return values + .map((value) => { + const clamped = Math.min(max, Math.max(min, value)); + const index = Math.round(((clamped - min) / range) * lastIndex); + return SPARKLINE_CHARS[index]; + }) + .join(''); +} + +/** + * Render a counter's sparkline, choosing the baseline per graph type so the + * heights are meaningful: accumulated counters (e.g. Memory) are relative, so + * they scale between their own min and max; rate counters are absolute and + * anchored at zero, with percent counters (e.g. Process CPU) pinned to 0-100%. + */ +function counterSparkline(counter: CounterSummary): string { + const { graph } = counter; + if (graph.length === 0) { + return ''; + } + if (counter.graphType === 'line-accumulated') { + return renderSparkline(graph, Math.min(...graph), Math.max(...graph)); + } + if (counter.unit === 'percent') { + return renderSparkline(graph, 0, 1); + } + return renderSparkline(graph, 0, Math.max(...graph)); } /** @@ -534,6 +587,36 @@ export function formatCounterInfoResult( lines.push(` ${stat.label}: ${value}`); } } + if (result.overTime.length > 0) { + lines.push(` ${result.label} over time:`); + if (result.graph.length > 0) { + lines.push(` ${counterSparkline(result)}`); + lines.push(''); + } + // Build the columns first, then pad each to its widest cell so the values + // line up in a column. + const rows = result.overTime.map((bucket) => { + const extras = [ + bucket.formattedDelta, + bucket.formattedPercentage, + bucket.carbon, + ].filter((part) => part !== undefined); + return { + handles: `[${bucket.startTimeName} → ${bucket.endTimeName}]`, + times: `(${bucket.startTimeStr} - ${bucket.endTimeStr})`, + value: bucket.formattedValue, + extras: extras.length > 0 ? `(${extras.join(', ')})` : '', + }; + }); + const handlesWidth = Math.max(...rows.map((row) => row.handles.length)); + const timesWidth = Math.max(...rows.map((row) => row.times.length)); + const valueWidth = Math.max(...rows.map((row) => row.value.length)); + for (const row of rows) { + lines.push( + ` ${row.handles.padEnd(handlesWidth)} ${row.times.padEnd(timesWidth)} ${row.value.padEnd(valueWidth)} ${row.extras}`.trimEnd() + ); + } + } return lines.join('\n'); } diff --git a/profiler-cli/src/test/unit/counter-formatting.test.ts b/profiler-cli/src/test/unit/counter-formatting.test.ts index a9b7ca0958..7377d0720c 100644 --- a/profiler-cli/src/test/unit/counter-formatting.test.ts +++ b/profiler-cli/src/test/unit/counter-formatting.test.ts @@ -46,6 +46,7 @@ function makeCounter(overrides: Partial = {}): CounterSummary { formattedValue: '27B', }, ], + graph: [], ...overrides, }; } @@ -95,6 +96,64 @@ describe('formatCounterListResult', function () { 'No counters in this profile.' ); }); + + it('renders a sparkline next to each counter', function () { + const result: WithContext = { + context: createContext(), + type: 'counter-list', + counters: [makeCounter({ graph: [1, 5, 9] })], + }; + + const output = formatCounterListResult(result); + expect(output).toContain('c-0: Memory (Memory)'); + // Memory is relative: it scales between its own min and max. + expect(output).toContain('▁'); // lowest graph value + expect(output).toContain('█'); // highest graph value + }); + + function listOf(counter: CounterSummary): WithContext { + return { + context: createContext(), + type: 'counter-list', + counters: [counter], + }; + } + + it('scales a percent (CPU) sparkline absolutely, 0 to 100%', function () { + const output = formatCounterListResult( + listOf( + makeCounter({ + label: 'Process CPU', + category: 'CPU', + graphType: 'line-rate', + unit: 'percent', + graph: [0.5, 0.55, 0.6], + }) + ) + ); + // ~50-60% on a 0-100% scale is mid-height: not the floor (the reviewer's + // "50% treated as 0" bug) and not the top (60% is not 100%). + expect(output).not.toContain('▁'); + expect(output).not.toContain('█'); + expect(output).toContain('▅'); + }); + + it('anchors a rate (bytes) sparkline at zero, not the series min', function () { + const output = formatCounterListResult( + listOf( + makeCounter({ + label: 'Bandwidth', + category: 'Bandwidth', + graphType: 'line-rate', + unit: 'bytes', + graph: [10, 20, 30], + }) + ) + ); + // The smallest slice (10) sits above the zero baseline, so it isn't the floor. + expect(output).not.toContain('▁'); + expect(output).toContain('█'); // the peak slice + }); }); describe('formatCounterInfoResult', function () { @@ -109,6 +168,7 @@ describe('formatCounterInfoResult', function () { sampleCount: 7, rangeStart: 0, rangeEnd: 10, + overTime: [], ...overrides, }; } @@ -147,4 +207,81 @@ describe('formatCounterInfoResult', function () { 'Energy used in the visible range: 5 Wh (2 g CO₂e)' ); }); + + it('renders the over-time section with level and delta', function () { + const output = formatCounterInfoResult( + makeInfo({ + overTime: [ + { + startTime: 0, + startTimeName: 'ts-0', + startTimeStr: '0s', + endTime: 5, + endTimeName: 'ts-K', + endTimeStr: '5ms', + value: 2_100_000, + formattedValue: '2.1 MB', + delta: 2_100_000, + formattedDelta: '+2.1 MB', + percentage: 0.25, + formattedPercentage: '25%', + }, + { + startTime: 5, + startTimeName: 'ts-K', + startTimeStr: '5ms', + endTime: 10, + endTimeName: 'ts-Z', + endTimeStr: '10ms', + value: 8_400_000, + formattedValue: '8.4 MB', + delta: 6_300_000, + formattedDelta: '+6.3 MB', + percentage: 1, + formattedPercentage: '100%', + }, + ], + }) + ); + expect(output).toContain('Memory over time:'); + // Columns are padded for alignment, so allow variable whitespace between them. + expect(output).toMatch( + /\[ts-0 → ts-K\]\s+\(0s - 5ms\)\s+2\.1 MB\s+\(\+2\.1 MB, 25%\)/ + ); + expect(output).toMatch( + /\[ts-K → ts-Z\]\s+\(5ms - 10ms\)\s+8\.4 MB\s+\(\+6\.3 MB, 100%\)/ + ); + }); + + function makeBucket( + value: number, + index: number + ): CounterInfoResult['overTime'][number] { + return { + startTime: index, + startTimeName: `ts-${index}`, + startTimeStr: `${index}ms`, + endTime: index + 1, + endTimeName: `ts-${index + 1}`, + endTimeStr: `${index + 1}ms`, + value, + formattedValue: `${value}B`, + }; + } + + it('renders a sparkline from the graph values', function () { + const output = formatCounterInfoResult( + makeInfo({ overTime: [makeBucket(1, 0)], graph: [1, 5, 9] }) + ); + expect(output).toContain('Memory over time:'); + expect(output).toContain('▁'); // lowest graph value + expect(output).toContain('█'); // highest graph value + }); + + it('omits the sparkline when the graph is empty', function () { + const output = formatCounterInfoResult( + makeInfo({ overTime: [makeBucket(1, 0)], graph: [] }) + ); + expect(output).not.toMatch(/[▁▂▃▄▅▆▇█]/); + }); }); diff --git a/src/profile-query/formatters/counter-info.ts b/src/profile-query/formatters/counter-info.ts index 57ad58f0bd..6a296af860 100644 --- a/src/profile-query/formatters/counter-info.ts +++ b/src/profile-query/formatters/counter-info.ts @@ -4,9 +4,10 @@ import { getProfile, - getProfileRootRange, getCounters, getCounterSelectors, + getCommittedRange, + getProfileInterval, getMeta, } from 'firefox-profiler/selectors/profile'; import { @@ -22,22 +23,33 @@ import { ENERGY_LADDER, pickTier, } from 'firefox-profiler/components/timeline/TrackCounterTooltipFormat'; +import { getSampleIndexRangeForSelection } from 'firefox-profiler/profile-logic/profile-data'; import { getCounterHandle, parseCounterHandle } from '../counter-map'; import type { CounterIndex, + CounterDisplayConfig, CounterTooltipDataSource, CounterTooltipFormat, + CounterTooltipRow, ProfileMeta, } from 'firefox-profiler/types'; import type { Store } from '../../types/store'; import type { ThreadMap } from '../thread-map'; +import type { TimestampManager } from '../timestamps'; import type { CounterStat, CounterSummary, + CounterTimeBucket, CounterListResult, CounterInfoResult, } from '../types'; +// Number of buckets in the detailed "over time" table. +const OVER_TIME_BUCKET_COUNT = 10; + +// Width (in characters/points) of the higher-resolution sparkline series. +const GRAPH_BUCKET_COUNT = 50; + // 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 @@ -180,8 +192,10 @@ export function collectCounterSummary( const counter = selectors.getCounter(state); const { display } = counter; - const [rangeStartIndex, rangeEndIndex] = - selectors.getCommittedRangeCounterSampleRange(state); + const [rangeStartIndex, rangeEndIndex] = getInRangeSampleIndexes( + store, + counterIndex + ); const mainThreadName = profile.threads[counter.mainThreadIndex]?.name ?? ''; @@ -200,6 +214,7 @@ export function collectCounterSummary( mainThreadName, rangeSampleCount: Math.max(0, rangeEndIndex - rangeStartIndex), stats: collectCounterStats(store, counterIndex), + graph: collectCounterGraph(store, counterIndex), }; } @@ -219,12 +234,272 @@ export function collectCounterList( }; } +/** + * Decide how to aggregate a counter's values per time bucket, and which tooltip + * format to render them with. Accumulated counters report the running level; + * rate counters report the amount summed over the bucket (using their + * range-aggregate row's format), except process CPU, which averages its ratio. + */ +function getOverTimeMode(display: CounterDisplayConfig): { + kind: 'level' | 'sum' | 'avg-ratio'; + format: CounterTooltipFormat; +} { + type ValueRow = Extract; + const rowFor = (source: CounterTooltipDataSource): ValueRow | undefined => { + for (const row of display.tooltipRows) { + if (row.type === 'value' && row.source === source) { + return row; + } + } + return undefined; + }; + const fallback: CounterTooltipFormat = { + unit: display.unit === 'bytes' ? 'bytes' : 'number', + }; + + if (display.graphType === 'line-accumulated') { + return { kind: 'level', format: rowFor('accumulated')?.format ?? fallback }; + } + + const rangeRow = rowFor('count-range') ?? rowFor('committed-range-total'); + if (rangeRow) { + return { kind: 'sum', format: rangeRow.format }; + } + const cpuRow = rowFor('cpu-ratio'); + if (cpuRow) { + return { kind: 'avg-ratio', format: cpuRow.format }; + } + return { kind: 'sum', format: rowFor('count')?.format ?? fallback }; +} + +/** + * The sample indexes strictly inside the committed range. The counter + * selectors' committed range is padded by one sample on each side (for graph + * continuity); those boundary samples are outside the current view, so counts, + * sums, and time spans should exclude them. + */ +function getInRangeSampleIndexes( + store: Store, + counterIndex: CounterIndex +): [number, number] { + const state = store.getState(); + const { samples } = getCounterSelectors(counterIndex).getCounter(state); + const range = getCommittedRange(state); + return getSampleIndexRangeForSelection(samples, range.start, range.end); +} + +type CounterBucket = { + startTime: number; + endTime: number; + value: number; + delta?: number; // accumulated counters only +}; + +type CounterBuckets = { + kind: 'level' | 'sum' | 'avg-ratio'; + format: CounterTooltipFormat; + countRange: number; // for the accumulated (level) share + totalSum: number; // for the rate (sum) share + buckets: CounterBucket[]; +}; + +/** + * Split the current committed range into `requestedBucketCount` equal-width time + * buckets and compute a raw value per bucket. Sample times are absolute. Shared + * by the detailed "over time" table and the sparkline. + * + * `capToSampleCount` keeps the table from showing more rows than there are + * samples; the sparkline leaves it off for a consistent width, filling + * sample-less buckets by carrying the level forward (accumulated) or with zero. + */ +function getCounterBuckets( + store: Store, + counterIndex: CounterIndex, + requestedBucketCount: number, + capToSampleCount: boolean +): CounterBuckets | null { + const state = store.getState(); + const selectors = getCounterSelectors(counterIndex); + const counter = selectors.getCounter(state); + const { samples, display } = counter; + const range = getCommittedRange(state); + const [startIndex, endIndex] = getInRangeSampleIndexes(store, counterIndex); + const { minCount, countRange, accumulatedCounts } = + selectors.getAccumulateCounterSamples(state); + + const span = range.end - range.start; + if (endIndex <= startIndex || span <= 0) { + return null; + } + + const { kind, format } = getOverTimeMode(display); + const interval = getProfileInterval(state); + // Use the same range-aware max as the timeline's cpu-ratio tooltip + // (getMaxRangeCounterSampleCountPerMs), so the CLI and UI agree. + const maxCountPerMs = + kind === 'avg-ratio' + ? selectors.getMaxRangeCounterSampleCountPerMs(state) + : 0; + + const bucketCount = Math.max( + 1, + capToSampleCount + ? Math.min(requestedBucketCount, endIndex - startIndex) + : requestedBucketCount + ); + const width = span / bucketCount; + + type Acc = { + sum: number; + ratioSum: number; + ratioN: number; + level: number | null; + }; + const accs: Acc[] = Array.from({ length: bucketCount }, () => ({ + sum: 0, + ratioSum: 0, + ratioN: 0, + level: null, + })); + + for (let i = startIndex; i < endIndex; i++) { + const bucketIndex = Math.min( + bucketCount - 1, + Math.max(0, Math.floor((samples.time[i] - range.start) / width)) + ); + const acc = accs[bucketIndex]; + if (kind === 'level') { + acc.level = accumulatedCounts[i] - minCount; + } else if (kind === 'sum') { + acc.sum += samples.count[i]; + } else { + const dt = i === 0 ? interval : samples.time[i] - samples.time[i - 1]; + if (dt > 0 && maxCountPerMs > 0) { + acc.ratioSum += samples.count[i] / dt / maxCountPerMs; + acc.ratioN += 1; + } + } + } + + const totalSum = accs.reduce((sum, acc) => sum + acc.sum, 0); + + let carriedLevel = 0; + const buckets: CounterBucket[] = accs.map((acc, bucketIndex) => { + const startTime = range.start + bucketIndex * width; + const endTime = + bucketIndex === bucketCount - 1 + ? range.end + : range.start + (bucketIndex + 1) * width; + + let value: number; + let delta: number | undefined; + if (kind === 'level') { + const level = acc.level ?? carriedLevel; + delta = level - carriedLevel; + carriedLevel = level; + value = level; + } else if (kind === 'avg-ratio') { + value = acc.ratioN > 0 ? acc.ratioSum / acc.ratioN : 0; + } else { + value = acc.sum; + } + return { startTime, endTime, value, delta }; + }); + + return { kind, format, countRange, totalSum, buckets }; +} + +/** + * The detailed "over time" table: a small number of buckets with formatted + * values, deltas, and per-slice shares. Rate buckets are shown as a share of the + * range total; accumulated buckets as a share of the range peak (countRange). + */ +function collectCounterOverTime( + store: Store, + counterIndex: CounterIndex, + timestampManager: TimestampManager +): CounterTimeBucket[] { + const counterBuckets = getCounterBuckets( + store, + counterIndex, + OVER_TIME_BUCKET_COUNT, + true + ); + if (counterBuckets === null) { + return []; + } + const meta = getMeta(store.getState()); + const { kind, format, countRange, totalSum, buckets } = counterBuckets; + + return buckets.map((bucket): CounterTimeBucket => { + const { formattedValue, carbon } = formatCounterRowValue( + bucket.value, + format, + meta + ); + + let formattedDelta: string | undefined; + if (bucket.delta !== undefined) { + const sign = bucket.delta < 0 ? '-' : '+'; + formattedDelta = + sign + + formatCounterRowValue(Math.abs(bucket.delta), format, meta) + .formattedValue; + } + + let percentage: number | undefined; + if (kind === 'level' && countRange > 0) { + percentage = bucket.value / countRange; + } else if (kind === 'sum' && totalSum > 0) { + percentage = bucket.value / totalSum; + } + const formattedPercentage = + percentage !== undefined ? formatPercent(percentage) : undefined; + + return { + startTime: bucket.startTime, + startTimeName: timestampManager.nameForTimestamp(bucket.startTime), + startTimeStr: timestampManager.timestampString(bucket.startTime), + endTime: bucket.endTime, + endTimeName: timestampManager.nameForTimestamp(bucket.endTime), + endTimeStr: timestampManager.timestampString(bucket.endTime), + value: bucket.value, + formattedValue, + delta: bucket.delta, + formattedDelta, + percentage, + formattedPercentage, + carbon, + }; + }); +} + +/** + * A higher-resolution series of raw per-bucket values for the sparkline, + * independent of the detailed "over time" table's coarser buckets. + */ +function collectCounterGraph( + store: Store, + counterIndex: CounterIndex +): number[] { + const counterBuckets = getCounterBuckets( + store, + counterIndex, + GRAPH_BUCKET_COUNT, + false + ); + return counterBuckets === null + ? [] + : counterBuckets.buckets.map((bucket) => bucket.value); +} + /** * Build detailed information about a single counter, resolved by handle. */ export function collectCounterInfo( store: Store, threadMap: ThreadMap, + timestampManager: TimestampManager, counterHandle: string ): CounterInfoResult { const state = store.getState(); @@ -234,17 +509,16 @@ export function collectCounterInfo( const summary = collectCounterSummary(store, threadMap, counterIndex); const selectors = getCounterSelectors(counterIndex); const counter = selectors.getCounter(state); - const [rangeStartIndex, rangeEndIndex] = - selectors.getCommittedRangeCounterSampleRange(state); + const [rangeStartIndex, rangeEndIndex] = getInRangeSampleIndexes( + store, + counterIndex + ); - const zeroAt = getProfileRootRange(state).start; + // Sample times are already in absolute profile-time space (the same space as + // the committed range), so they need no zeroAt offset here. const hasRange = rangeEndIndex > rangeStartIndex; - const rangeStart = hasRange - ? counter.samples.time[rangeStartIndex] + zeroAt - : null; - const rangeEnd = hasRange - ? counter.samples.time[rangeEndIndex - 1] + zeroAt - : null; + const rangeStart = hasRange ? counter.samples.time[rangeStartIndex] : null; + const rangeEnd = hasRange ? counter.samples.time[rangeEndIndex - 1] : null; return { ...summary, @@ -253,5 +527,6 @@ export function collectCounterInfo( sampleCount: counter.samples.length, rangeStart, rangeEnd, + overTime: collectCounterOverTime(store, counterIndex, timestampManager), }; } diff --git a/src/profile-query/index.ts b/src/profile-query/index.ts index 73d9959263..369725a12e 100644 --- a/src/profile-query/index.ts +++ b/src/profile-query/index.ts @@ -230,6 +230,7 @@ export class ProfileQuerier { const result = collectCounterInfo( this._store, this._threadMap, + this._timestampManager, counterHandle ); return { ...result, context: this._getContext() }; diff --git a/src/profile-query/types.ts b/src/profile-query/types.ts index 65e1135b3a..d3bc43c43b 100644 --- a/src/profile-query/types.ts +++ b/src/profile-query/types.ts @@ -704,6 +704,9 @@ export type CounterSummary = { mainThreadName: string; rangeSampleCount: number; // samples within the current range stats: CounterStat[]; // range-aggregate stats from the tooltip schema + // Raw values for a sparkline of the counter's trajectory over the current + // view. Empty when the counter has no in-range samples. + graph: number[]; }; export type CounterListResult = { @@ -711,12 +714,37 @@ export type CounterListResult = { counters: CounterSummary[]; }; +/** + * One time bucket of a counter's "over time" breakdown. The current view is + * split into equal-width buckets; each carries the bucket's value formatted via + * the tooltip schema. `delta` is present only for accumulated counters. + */ +export type CounterTimeBucket = { + startTime: number; // absolute time of the bucket start + startTimeName: string; // e.g. "ts-0" + startTimeStr: string; // human-readable, relative to profile start, e.g. "1.4s" + endTime: number; + endTimeName: string; + endTimeStr: string; + value: number; + formattedValue: string; + delta?: number; + formattedDelta?: string; // signed, e.g. "+6.3 MB" + // Ratio (0..1) of the bucket's value: share of the range total for rate + // counters, share of the range peak for accumulated counters. Omitted for + // process CPU, whose value is already a percentage. + percentage?: number; + formattedPercentage?: string; // e.g. "60%" + carbon?: string; // when the row's format requests a CO2e estimate +}; + 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 + overTime: CounterTimeBucket[]; // per-bucket values across the current view }; // ===== Profile Commands ===== diff --git a/src/test/unit/profile-query/profile-querier.test.ts b/src/test/unit/profile-query/profile-querier.test.ts index eec3b04b6e..10d52d2540 100644 --- a/src/test/unit/profile-query/profile-querier.test.ts +++ b/src/test/unit/profile-query/profile-querier.test.ts @@ -20,6 +20,7 @@ import { ProfileQuerier } from 'firefox-profiler/profile-query'; import { getProfileFromTextSamples, getCounterForThread, + getCounterForThreadWithSamples, } from '../../fixtures/profiles/processed-profile'; import { getProfileRootRange } from 'firefox-profiler/selectors/profile'; import { storeWithProfile } from '../../fixtures/stores'; @@ -450,6 +451,131 @@ describe('ProfileQuerier', function () { 'Unknown counter c-0' ); }); + + it('reports counter values over time', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 + A A A A A A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { + count: [0, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000], + length: 10, + }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.overTime.length).toBeGreaterThan(0); + // Accumulated counters report a per-bucket level, delta, and share of peak. + expect(info.overTime.every((b) => b.delta !== undefined)).toBe(true); + expect(info.overTime.every((b) => b.percentage !== undefined)).toBe(true); + expect(info.overTime[0].startTimeName).toMatch(/^ts-/); + // Memory only grows here, so the last level is at least the first. + const first = info.overTime[0].value; + const last = info.overTime[info.overTime.length - 1].value; + expect(last).toBeGreaterThanOrEqual(first); + // The sparkline is of fixed width. + expect(info.graph.length).toBe(50); + }); + + it('gives the graph a fixed width, wider than the over-time buckets', async function () { + const columns = Array.from({ length: 60 }, (_, i) => i); + const { profile } = getProfileFromTextSamples( + `${columns.join(' ')}\n${columns.map(() => 'A').join(' ')}` + ); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { count: columns.map(() => 1000), length: 60 }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.overTime.length).toBe(10); + expect(info.graph.length).toBe(50); + }); + + it('reports a per-bucket amount (no delta) for rate counters', async function () { + const { profile } = getProfileFromTextSamples(` + 0 1 2 3 4 5 6 7 8 9 + A A A A A A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { count: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90], length: 10 }, + 'eth0', + 'Bandwidth' + ); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.graphType).toBe('line-rate'); + expect(info.overTime.length).toBeGreaterThan(0); + // Rate counters report an amount per bucket (no level/delta) and a share + // of the range total. + expect(info.overTime.every((b) => b.delta === undefined)).toBe(true); + expect(info.overTime.every((b) => b.percentage !== undefined)).toBe(true); + expect(info.graph.length).toBeGreaterThan(0); + }); + + it('reports range times relative to the profile start, not double-offset', async function () { + // Profile that does not start at zero. + const { profile } = getProfileFromTextSamples(` + 1000 1010 1020 + A A A + `); + const counter = getCounterForThread(profile.threads[0], 0, { + name: 'malloc', + category: 'Memory', + }); + profile.counters = [counter]; + + const info = await querierFor(profile).counterInfo('c-0'); + + expect(info.rangeStart).not.toBeNull(); + expect(info.rangeStart! - info.context.rootRange.start).toBe(0); + }); + + it('excludes the padded boundary samples when zoomed', async function () { + const { profile } = getProfileFromTextSamples(` + 0 10 20 30 40 + A A A A A + `); + const counter = getCounterForThreadWithSamples( + profile.threads[0], + 0, + { + time: [0, 10, 20, 30, 40], + count: [0, 100, 100, 100, 100], + length: 5, + }, + 'malloc', + 'Memory' + ); + profile.counters = [counter]; + + const querier = querierFor(profile); + const startName = querier._timestampManager.nameForTimestamp(15); + const endName = querier._timestampManager.nameForTimestamp(35); + await querier.pushViewRange(`${startName},${endName}`); + + const info = await querier.counterInfo('c-0'); + + // Only the samples at 20 and 30 are inside [15, 35]; the boundary samples + // at 10 and 40 (which the counter selectors pad the range with) are not. + expect(info.rangeSampleCount).toBe(2); + }); }); describe('threadSamples', function () {