Skip to content

Commit 85ef684

Browse files
committed
Show counter values over time in pq counter info
Add an "over time" section that splits the current view into time buckets, so a counter's trajectory is visible rather than only an aggregate. Each slice shows the graph-type-appropriate value (level and delta for accumulated counters, the amount for rate counters), its share of the range, and a CO2e estimate where the schema requests one. Values reuse the tooltip formatters and ts-N time names, matching the timeline. A fixed-width sparkline of the trajectory is drawn alongside, in both `counter info` and `counter list`. Closes #6112
1 parent 03b9fc2 commit 85ef684

8 files changed

Lines changed: 555 additions & 12 deletions

File tree

profiler-cli/guide.txt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ COUNTERS
224224
network bandwidth, process CPU, power, and similar. Each counter has a handle
225225
(c-0, c-1, ...) and carries its own display metadata (label, unit, graph type).
226226

227-
profiler-cli counter list List all counters with one-line summaries
227+
profiler-cli counter list List all counters, each with a sparkline
228228
profiler-cli counter info c-0 Detailed info and stats for one counter
229229

230230
Counters also appear in "profile info", listed under their owning process
@@ -235,6 +235,14 @@ COUNTERS
235235
memory range for Memory, data transferred for Bandwidth, or energy used (with a
236236
CO2e estimate) for Power.
237237

238+
"counter info" also prints an "over time" section that splits the current view
239+
into time buckets, so you can see when a counter's value changed - for any
240+
counter, in its own unit. What each slice shows follows the counter's graph
241+
type: accumulated counters (e.g. Memory) show the level and its change, rate
242+
counters (e.g. Bandwidth, Power) show the amount in the slice. Each slice also
243+
shows its share of the range, and a sparkline draws the trajectory above them.
244+
"counter list" shows a sparkline next to each counter as well.
245+
238246
All counter stats respect the current zoom: with no zoom they cover the whole
239247
profile; after "zoom push" they cover the committed range. Combine with zoom to
240248
see, for example, how much memory a specific time window allocated:

profiler-cli/schemas.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ CounterSummary:
3232
unit, graphType,
3333
color, pid, mainThreadIndex, mainThreadHandle, mainThreadName,
3434
rangeSampleCount,
35-
stats: [{ source, label, value, formattedValue, carbon? }]
35+
stats: [{ source, label, value, formattedValue, carbon? }],
36+
graph: [number]
3637
}
3738

3839
profiler-cli counter list --json
@@ -49,6 +50,10 @@ profiler-cli counter info --json
4950
description,
5051
sampleCount,
5152
rangeStart, rangeEnd,
53+
overTime: [{ startTime, startTimeName, startTimeStr,
54+
endTime, endTimeName, endTimeStr,
55+
value, formattedValue, delta?, formattedDelta?,
56+
percentage?, formattedPercentage?, carbon? }],
5257
context: SessionContext
5358
}
5459

profiler-cli/src/formatters.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -490,8 +490,42 @@ export function formatCounterListResult(
490490
if (result.counters.length === 0) {
491491
return `${contextHeader}\n\nNo counters in this profile.`;
492492
}
493-
const lines = result.counters.map(formatCounterSummaryLine);
494-
return `${contextHeader}\n\nCounters (${result.counters.length}):\n${lines.join('\n')}`;
493+
const blocks = result.counters.map((counter) => {
494+
const block = [formatCounterSummaryLine(counter)];
495+
if (counter.graph.length > 0) {
496+
block.push(` ${renderSparkline(counter.graph)}`);
497+
}
498+
return block.join('\n');
499+
});
500+
// Trailing blank line so the last counter's sparkline is separated from the
501+
// prompt, matching the blank lines between counters.
502+
return `${contextHeader}\n\nCounters (${result.counters.length}):\n${blocks.join('\n\n')}\n`;
503+
}
504+
505+
const SPARKLINE_CHARS = '▁▂▃▄▅▆▇█';
506+
507+
/**
508+
* Render a compact sparkline of the given values using block characters.
509+
* Heights are normalized across the series (min..max); a flat series renders
510+
* at a mid-height rather than the floor so it doesn't read as zero.
511+
*/
512+
function renderSparkline(values: number[]): string {
513+
if (values.length === 0) {
514+
return '';
515+
}
516+
const min = Math.min(...values);
517+
const max = Math.max(...values);
518+
const range = max - min;
519+
const lastIndex = SPARKLINE_CHARS.length - 1;
520+
if (range === 0) {
521+
return SPARKLINE_CHARS[Math.floor(lastIndex / 2)].repeat(values.length);
522+
}
523+
return values
524+
.map((value) => {
525+
const index = Math.round(((value - min) / range) * lastIndex);
526+
return SPARKLINE_CHARS[index];
527+
})
528+
.join('');
495529
}
496530

497531
/**
@@ -534,6 +568,36 @@ export function formatCounterInfoResult(
534568
lines.push(` ${stat.label}: ${value}`);
535569
}
536570
}
571+
if (result.overTime.length > 0) {
572+
lines.push(` ${result.label} over time:`);
573+
if (result.graph.length > 0) {
574+
lines.push(` ${renderSparkline(result.graph)}`);
575+
lines.push('');
576+
}
577+
// Build the columns first, then pad each to its widest cell so the values
578+
// line up in a column.
579+
const rows = result.overTime.map((bucket) => {
580+
const extras = [
581+
bucket.formattedDelta,
582+
bucket.formattedPercentage,
583+
bucket.carbon,
584+
].filter((part) => part !== undefined);
585+
return {
586+
handles: `[${bucket.startTimeName}${bucket.endTimeName}]`,
587+
times: `(${bucket.startTimeStr} - ${bucket.endTimeStr})`,
588+
value: bucket.formattedValue,
589+
extras: extras.length > 0 ? `(${extras.join(', ')})` : '',
590+
};
591+
});
592+
const handlesWidth = Math.max(...rows.map((row) => row.handles.length));
593+
const timesWidth = Math.max(...rows.map((row) => row.times.length));
594+
const valueWidth = Math.max(...rows.map((row) => row.value.length));
595+
for (const row of rows) {
596+
lines.push(
597+
` ${row.handles.padEnd(handlesWidth)} ${row.times.padEnd(timesWidth)} ${row.value.padEnd(valueWidth)} ${row.extras}`.trimEnd()
598+
);
599+
}
600+
}
537601
return lines.join('\n');
538602
}
539603

profiler-cli/src/test/unit/counter-formatting.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function makeCounter(overrides: Partial<CounterSummary> = {}): CounterSummary {
4646
formattedValue: '27B',
4747
},
4848
],
49+
graph: [],
4950
...overrides,
5051
};
5152
}
@@ -95,6 +96,19 @@ describe('formatCounterListResult', function () {
9596
'No counters in this profile.'
9697
);
9798
});
99+
100+
it('renders a sparkline next to each counter', function () {
101+
const result: WithContext<CounterListResult> = {
102+
context: createContext(),
103+
type: 'counter-list',
104+
counters: [makeCounter({ graph: [1, 5, 9] })],
105+
};
106+
107+
const output = formatCounterListResult(result);
108+
expect(output).toContain('c-0: Memory (Memory)');
109+
expect(output).toContain('▁'); // lowest graph value
110+
expect(output).toContain('█'); // highest graph value
111+
});
98112
});
99113

100114
describe('formatCounterInfoResult', function () {
@@ -109,6 +123,7 @@ describe('formatCounterInfoResult', function () {
109123
sampleCount: 7,
110124
rangeStart: 0,
111125
rangeEnd: 10,
126+
overTime: [],
112127
...overrides,
113128
};
114129
}
@@ -147,4 +162,81 @@ describe('formatCounterInfoResult', function () {
147162
'Energy used in the visible range: 5 Wh (2 g CO₂e)'
148163
);
149164
});
165+
166+
it('renders the over-time section with level and delta', function () {
167+
const output = formatCounterInfoResult(
168+
makeInfo({
169+
overTime: [
170+
{
171+
startTime: 0,
172+
startTimeName: 'ts-0',
173+
startTimeStr: '0s',
174+
endTime: 5,
175+
endTimeName: 'ts-K',
176+
endTimeStr: '5ms',
177+
value: 2_100_000,
178+
formattedValue: '2.1 MB',
179+
delta: 2_100_000,
180+
formattedDelta: '+2.1 MB',
181+
percentage: 0.25,
182+
formattedPercentage: '25%',
183+
},
184+
{
185+
startTime: 5,
186+
startTimeName: 'ts-K',
187+
startTimeStr: '5ms',
188+
endTime: 10,
189+
endTimeName: 'ts-Z',
190+
endTimeStr: '10ms',
191+
value: 8_400_000,
192+
formattedValue: '8.4 MB',
193+
delta: 6_300_000,
194+
formattedDelta: '+6.3 MB',
195+
percentage: 1,
196+
formattedPercentage: '100%',
197+
},
198+
],
199+
})
200+
);
201+
expect(output).toContain('Memory over time:');
202+
// Columns are padded for alignment, so allow variable whitespace between them.
203+
expect(output).toMatch(
204+
/\[ts-0 ts-K\]\s+\(0s - 5ms\)\s+2\.1 MB\s+\(\+2\.1 MB, 25%\)/
205+
);
206+
expect(output).toMatch(
207+
/\[ts-K ts-Z\]\s+\(5ms - 10ms\)\s+8\.4 MB\s+\(\+6\.3 MB, 100%\)/
208+
);
209+
});
210+
211+
function makeBucket(
212+
value: number,
213+
index: number
214+
): CounterInfoResult['overTime'][number] {
215+
return {
216+
startTime: index,
217+
startTimeName: `ts-${index}`,
218+
startTimeStr: `${index}ms`,
219+
endTime: index + 1,
220+
endTimeName: `ts-${index + 1}`,
221+
endTimeStr: `${index + 1}ms`,
222+
value,
223+
formattedValue: `${value}B`,
224+
};
225+
}
226+
227+
it('renders a sparkline from the graph values', function () {
228+
const output = formatCounterInfoResult(
229+
makeInfo({ overTime: [makeBucket(1, 0)], graph: [1, 5, 9] })
230+
);
231+
expect(output).toContain('Memory over time:');
232+
expect(output).toContain('▁'); // lowest graph value
233+
expect(output).toContain('█'); // highest graph value
234+
});
235+
236+
it('omits the sparkline when the graph is empty', function () {
237+
const output = formatCounterInfoResult(
238+
makeInfo({ overTime: [makeBucket(1, 0)], graph: [] })
239+
);
240+
expect(output).not.toMatch(/[]/);
241+
});
150242
});

0 commit comments

Comments
 (0)