fix(metrics): even out chart density on long ranges

Sentinel keeps raw samples for the last 24-48 hours and one-minute
averages before that. LTTB buckets by point count, so 7-day and 30-day
charts gave most points to the raw part. Average those ranges per
minute before LTTB.

Closes #12281

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-23 22:26:24 +02:00
co-authored by Claude Opus 5.5
parent 6699b436e8
commit f72041c513
2 changed files with 53 additions and 0 deletions
+31
View File
@@ -172,6 +172,11 @@ trait HasMetrics
/**
* Map raw rows to [timestampMs, value] pairs, downsampling long ranges.
*
* Sentinel keeps raw samples for the last 24 to 48 hours and one-minute averages
* before that. LTTB buckets by point count, so on ranges that reach past the raw
* window the raw part got most of the chart points. Those ranges are averaged per
* minute first, the same as Sentinel does, so the density is even over time.
*
* @param array<int, array<string, mixed>>|null $rows
*/
private function mapRows(?array $rows, string $valueField, int $mins): ?array
@@ -184,6 +189,10 @@ trait HasMetrics
return [(int) $metric['time'], (float) ($metric[$valueField] ?? 0.0)];
})->toArray();
if ($mins > 24 * 60) {
$metrics = $this->averagePerMinute($metrics);
}
if ($mins > 60 && count($metrics) > 1000) {
$metrics = downsampleLTTB($metrics, 1000);
}
@@ -191,6 +200,28 @@ trait HasMetrics
return $metrics;
}
/**
* Average [timestampMs, value] pairs into one-minute buckets, timestamped at the
* bucket start like Sentinel's own downsampling. Samples that are a minute or more
* apart keep their own value.
*
* @param array<int, array{0: int, 1: float}> $metrics
* @return array<int, array{0: int, 1: float}>
*/
private function averagePerMinute(array $metrics): array
{
$sums = [];
$counts = [];
foreach ($metrics as [$time, $value]) {
$bucket = intdiv($time, 60_000) * 60_000;
$sums[$bucket] = ($sums[$bucket] ?? 0.0) + $value;
$counts[$bucket] = ($counts[$bucket] ?? 0) + 1;
}
ksort($sums);
return array_map(fn (int $bucket) => [$bucket, $sums[$bucket] / $counts[$bucket]], array_keys($sums));
}
private function metricsToken(Server $server): string
{
$previousToken = null;
@@ -85,3 +85,25 @@ it('runs the batched metrics script with sudo for a non-root user', function ()
->and($commands[0])->not->toContain('sudo curl')
->and($commands[0])->not->toContain('sudo printf');
});
it('keeps long-range charts evenly dense across mixed-resolution Sentinel history', function () {
// Sentinel keeps raw samples (here every 10 s) for the last one to two days and
// one-minute averages before that. A 7-day chart must not crowd the raw days.
$end = 1_800_000_000_000;
$rawStart = $end - 2 * 86_400_000;
$rows = [];
for ($time = $end - 7 * 86_400_000; $time < $rawStart; $time += 60_000) {
$rows[] = ['time' => $time, 'percent' => 10];
}
for ($time = $rawStart; $time < $end; $time += 10_000) {
$rows[] = ['time' => $time, 'percent' => ($time / 10_000) % 2 === 0 ? 0 : 20];
}
Process::fake(fn () => Process::result(json_encode($rows)));
$metrics = $this->server->getCpuMetrics(10080);
$recent = array_filter($metrics, fn (array $point) => $point[0] >= $rawStart);
expect(count($metrics))->toBeLessThanOrEqual(1000)
->and(count($recent) / count($metrics))->toBeLessThan(0.35)
->and(array_values(array_unique(array_column($recent, 1))))->toBe([10.0]);
});