Skip to content

Commit 43617cc

Browse files
tym83kvapsclaude
authored
fix(overview): count entities over the period window instead of at an instant (#8)
* fix(overview): count entities active over the month, not at an instant The snapshot queries ran as instant PromQL at a single timestamp, so they only counted series that were non-stale at that moment (~5m staleness) — roughly a third of the active fleet. Wrap each selector in max_over_time(<selector>[window]) where window spans the month up to queryAt, so a snapshot counts every cluster, node, tenant and app that reported at least once during the month. This matches the Grafana telemetry-overview dashboard, whose stat panels run range queries over the selected period rather than a single instant, and fixes the ~3x undercount on cozystack.io/oss-health/telemetry/. Signed-off-by: Timur Tukaev <timur.tukaev@aenix.io> * fix(overview): query quarter/year over their own windows, not by averaging The month period is still served from the cached monthly snapshot, but quarter and year are now queried live over 3- and 12-calendar-month windows instead of averaging per-month snapshots. A cumulative count of distinct clusters over a multi-month window cannot be recovered from per-month aggregates: a cluster active in two months must be counted once, and averaging understates the period total. The longer periods reuse the month's app breakdown to avoid a one-off load-test burst inflating their max_over_time peak. Removes the now-unused aggregateSnapshots/filterSnapshotsByMonths helpers and documents the new behavior in the README. Signed-off-by: tym83 <6355522@gmail.com> * perf(overview): cache windowed quarter/year period stats The quarter and year periods ran windowed max_over_time queries (the year is a 365d scan over the whole fleet) on every /api/overview request, which amplifies a cheap public GET into heavy VictoriaMetrics load. Memoize the computed quarter/year stats per requested month: past months are final and cached indefinitely, the in-progress month for a short TTL. Concurrent callers are coalesced via singleflight, mirroring the snapshot path. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> --------- Signed-off-by: Timur Tukaev <timur.tukaev@aenix.io> Signed-off-by: tym83 <6355522@gmail.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io> Co-authored-by: Andrei Kvapil <andrei.kvapil@aenix.io> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f2efedd commit 43617cc

2 files changed

Lines changed: 172 additions & 98 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ The server exposes a `GET /api/overview?year=YYYY&month=MM` endpoint that return
1515
### How it works
1616

1717
1. The endpoint requires `year` and `month` query parameters (e.g. `/api/overview?year=2026&month=03`). Requests without them return 400.
18-
2. On first request for a given month, the server queries VictoriaMetrics at the end of that month, writes a snapshot to `--snapshot-dir`, and caches it in memory. Subsequent requests for the same month are served from cache.
18+
2. On first request for a given month, the server queries VictoriaMetrics over a window spanning that month, writes a snapshot to `--snapshot-dir`, and caches it in memory. Subsequent requests for the same month are served from cache. Counts use `max_over_time(<selector>[window])` rather than an instant query, so a snapshot reflects every cluster/node/tenant/app that reported at least once during the month — an instant query only sees series that are non-stale at a single timestamp (~5m) and undercounts the active fleet ~3x.
1919
3. Concurrent requests for the same uncached month are coalesced into a single VictoriaMetrics query (per-month singleflight).
2020
4. The app list is fetched from [cozystack/cozystack packages/apps](https://github.com/cozystack/cozystack/tree/main/packages/apps) so newly added applications are picked up automatically; a built-in fallback list is used if GitHub is unreachable.
21-
5. The response aggregates snapshots into three time periods relative to the requested month: **that month**, **last quarter** (3 months), and **last 12 months**.
21+
5. The response reports three time periods relative to the requested month: **that month**, **last quarter** (3 calendar months) and **last 12 months**. The quarter and year are queried live over their own windows — a cumulative count of distinct clusters over several months cannot be derived by averaging per-month snapshots (a cluster active in two months must be counted once). Their app breakdown reuses the month's, to avoid one-off load-test spikes inflating the longer-window peak. The quarter/year results are memoized per requested month (past months indefinitely, the current month for a short TTL) and concurrent callers are coalesced via singleflight, so repeated requests do not re-run the heavy windowed queries.
2222

2323
The snapshot directory is backed by an `emptyDir` volume — cache is per-pod and is rebuilt on restart from VictoriaMetrics on demand.
2424

@@ -43,8 +43,8 @@ The snapshot directory is backed by an `emptyDir` volume — cache is per-pod an
4343
"kubernetes": 30
4444
}
4545
},
46-
"quarter": { "..." : "averaged over 3 months" },
47-
"year": { "..." : "averaged over 12 months" }
46+
"quarter": { "..." : "distinct over the last 3 calendar months" },
47+
"year": { "..." : "distinct over the last 12 calendar months" }
4848
}
4949
}
5050
```

overview.go

Lines changed: 168 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ type githubContent struct {
6767
// maxVMResponseSize caps the VictoriaMetrics response body to prevent OOM on malformed responses.
6868
const maxVMResponseSize = 10 * 1024 * 1024 // 10 MB
6969

70+
// currentMonthPeriodTTL is how long quarter/year period stats for the current
71+
// month are reused before being recomputed. Past months are final and cached
72+
// indefinitely; only the in-progress month needs periodic refreshing.
73+
const currentMonthPeriodTTL = 5 * time.Minute
74+
75+
// periodCacheEntry holds the computed quarter/year stats for a requested month.
76+
type periodCacheEntry struct {
77+
quarter PeriodStats
78+
year PeriodStats
79+
expiresAt time.Time
80+
}
81+
7082
// OverviewManager handles snapshot collection, storage, and serving.
7183
type OverviewManager struct {
7284
vmSelectURL string
@@ -80,15 +92,25 @@ type OverviewManager struct {
8092
// wait for it to finish instead of firing duplicate VM queries.
8193
inflightMu sync.Mutex
8294
inflight map[string]*sync.WaitGroup
95+
96+
// periodCache memoizes the expensive windowed quarter/year queries per
97+
// requested month; periodInflight coalesces concurrent callers the same
98+
// way inflight does for snapshots. See periodsFor for the freshness rules.
99+
periodMu sync.RWMutex
100+
periodCache map[string]periodCacheEntry
101+
periodInflightMu sync.Mutex
102+
periodInflight map[string]*sync.WaitGroup
83103
}
84104

85105
// NewOverviewManager creates a new OverviewManager and loads any cached snapshots.
86106
func NewOverviewManager(vmSelectURL, snapshotDir string) *OverviewManager {
87107
m := &OverviewManager{
88-
vmSelectURL: vmSelectURL,
89-
snapshotDir: snapshotDir,
90-
httpClient: &http.Client{Timeout: 30 * time.Second},
91-
inflight: make(map[string]*sync.WaitGroup),
108+
vmSelectURL: vmSelectURL,
109+
snapshotDir: snapshotDir,
110+
httpClient: &http.Client{Timeout: 30 * time.Second},
111+
inflight: make(map[string]*sync.WaitGroup),
112+
periodCache: make(map[string]periodCacheEntry),
113+
periodInflight: make(map[string]*sync.WaitGroup),
92114
}
93115
if err := os.MkdirAll(snapshotDir, 0755); err != nil {
94116
log.Printf("Warning: cannot create snapshot dir %s: %v", snapshotDir, err)
@@ -229,30 +251,44 @@ func (m *OverviewManager) collectSnapshot(monthLabel string) {
229251
queryAt = time.Now().UTC()
230252
}
231253

254+
// Lookback window covering the month from its first day up to queryAt. An
255+
// instant query alone only sees series that are non-stale at queryAt (~5m
256+
// staleness), so it counts only the clusters that happened to report in the
257+
// last few minutes — roughly a third of the fleet. Telemetry clients report
258+
// periodically, so we wrap each selector in max_over_time(<selector>[window])
259+
// to count every series that reported at least once during the month. This
260+
// matches the Grafana telemetry-overview dashboard, whose stat panels run
261+
// range queries over the period instead of a single instant.
262+
windowSec := int(queryAt.Sub(t).Seconds())
263+
if windowSec < 300 {
264+
windowSec = 300
265+
}
266+
window := fmt.Sprintf("%ds", windowSec)
267+
232268
snapshot := Snapshot{
233269
Month: monthLabel,
234270
CollectedAt: time.Now().UTC(),
235271
Apps: make(map[string]int),
236272
}
237273

238-
// Query cluster count
239-
clusters, err := m.queryScalar(`count(count by (cluster_id) (cozy_cluster_info))`, queryAt)
274+
// Query cluster count: clusters that reported at least once during the month.
275+
clusters, err := m.queryScalar(fmt.Sprintf(`count(count by (cluster_id) (max_over_time(cozy_cluster_info[%s])))`, window), queryAt)
240276
if err != nil {
241277
log.Printf("Error querying cluster count: %v", err)
242278
} else {
243279
snapshot.Clusters = int(clusters)
244280
}
245281

246-
// Query total nodes
247-
nodes, err := m.queryScalar(`sum(cozy_nodes_count)`, queryAt)
282+
// Query total nodes: peak node count per cluster over the month, summed.
283+
nodes, err := m.queryScalar(fmt.Sprintf(`sum(max_over_time(cozy_nodes_count[%s]))`, window), queryAt)
248284
if err != nil {
249285
log.Printf("Error querying total nodes: %v", err)
250286
} else {
251287
snapshot.TotalNodes = int(nodes)
252288
}
253289

254290
// Query total tenants (Tenant is an application kind)
255-
tenants, err := m.queryScalar(`sum(cozy_application_count{kind="Tenant"})`, queryAt)
291+
tenants, err := m.queryScalar(fmt.Sprintf(`sum(max_over_time(cozy_application_count{kind="Tenant"}[%s]))`, window), queryAt)
256292
if err != nil {
257293
log.Printf("Error querying total tenants: %v", err)
258294
} else {
@@ -263,7 +299,7 @@ func (m *OverviewManager) collectSnapshot(monthLabel string) {
263299
appList := m.fetchAppList()
264300

265301
// Query application counts by kind
266-
appCounts, err := m.queryVector(`sum by (kind) (cozy_application_count)`, queryAt)
302+
appCounts, err := m.queryVector(fmt.Sprintf(`sum by (kind) (max_over_time(cozy_application_count[%s]))`, window), queryAt)
267303
if err != nil {
268304
log.Printf("Error querying application counts: %v", err)
269305
}
@@ -602,120 +638,158 @@ func (m *OverviewManager) HandleOverview(w http.ResponseWriter, r *http.Request)
602638
}
603639
}
604640

605-
// buildOverview constructs the overview response from stored snapshots.
641+
// buildOverview constructs the overview response.
642+
//
643+
// The month period is taken from the cached/just-collected monthly snapshot.
644+
// The quarter and year periods are queried live over their own windows (3 and
645+
// 12 calendar months ending at the same instant), because a cumulative count
646+
// of distinct clusters over a multi-month window cannot be derived by averaging
647+
// per-month snapshots: a cluster active in two months must be counted once, not
648+
// twice, and averaging would understate the period total. The app breakdown for
649+
// the longer periods reuses the month's, to avoid transient load-test spikes
650+
// inflating the quarter/year peak (max_over_time captures one-off bursts).
651+
//
606652
// snapshots must be sorted descending by month; index 0 is the most recent.
607653
func (m *OverviewManager) buildOverview(snapshots []Snapshot) OverviewResponse {
654+
latest := snapshots[0]
608655
resp := OverviewResponse{
609-
GeneratedAt: snapshots[0].CollectedAt.Format(time.RFC3339),
656+
GeneratedAt: latest.CollectedAt.Format(time.RFC3339),
610657
Periods: make(map[string]PeriodStats),
611658
}
612659

613-
// Month: latest snapshot
614-
resp.Periods["month"] = aggregateSnapshots(snapshots[:1], false)
660+
monthStart := parseMonth(latest.Month)
661+
// Same instant the snapshot was queried at: end of its month, clamped to now.
662+
queryAt := monthStart.AddDate(0, 1, 0).Add(-time.Second)
663+
if now := time.Now().UTC(); queryAt.After(now) {
664+
queryAt = now
665+
}
615666

616-
// Quarter: last 3 calendar months
617-
resp.Periods["quarter"] = aggregateSnapshots(filterSnapshotsByMonths(snapshots, 3), true)
667+
// Month: distinct/peak over the calendar month, straight from the snapshot.
668+
month := PeriodStats{
669+
Label: monthStart.Format("January 2006"),
670+
Start: monthStart.Format("2006-01-02"),
671+
End: monthStart.AddDate(0, 1, -1).Format("2006-01-02"),
672+
Clusters: latest.Clusters,
673+
TotalNodes: latest.TotalNodes,
674+
TotalTenants: latest.TotalTenants,
675+
Apps: latest.Apps,
676+
}
677+
if latest.Clusters > 0 {
678+
month.AvgNodesPerCluster = roundTo(float64(latest.TotalNodes)/float64(latest.Clusters), 1)
679+
month.AvgTenantsPerCluster = roundTo(float64(latest.TotalTenants)/float64(latest.Clusters), 1)
680+
}
681+
resp.Periods["month"] = month
618682

619-
// Year: last 12 calendar months
620-
resp.Periods["year"] = aggregateSnapshots(filterSnapshotsByMonths(snapshots, 12), true)
683+
quarter, year := m.periodsFor(latest.Month, monthStart, queryAt, latest.Apps)
684+
resp.Periods["quarter"] = quarter
685+
resp.Periods["year"] = year
621686

622687
return resp
623688
}
624689

625-
// filterSnapshotsByMonths returns snapshots within the last N calendar months
626-
// relative to the latest snapshot. This ensures correct ranges even with gaps.
627-
func filterSnapshotsByMonths(snapshots []Snapshot, months int) []Snapshot {
628-
if len(snapshots) == 0 {
629-
return nil
630-
}
631-
632-
latest := parseMonth(snapshots[0].Month)
633-
cutoff := latest.AddDate(0, -(months - 1), 0)
690+
// periodsFor returns the quarter and year PeriodStats for the requested month,
691+
// reusing a memoized result when one is available. The windowed max_over_time
692+
// queries that back these periods are expensive (the year alone is a 365d scan
693+
// over the whole fleet), so running them on every request would amplify a cheap
694+
// public GET into a heavy VictoriaMetrics load. A past month is final and cached
695+
// indefinitely; the in-progress month is cached for a short TTL. Concurrent
696+
// callers for the same month are coalesced via singleflight, mirroring how
697+
// snapshot collection is coalesced.
698+
func (m *OverviewManager) periodsFor(monthLabel string, monthStart, queryAt time.Time, apps map[string]int) (PeriodStats, PeriodStats) {
699+
for {
700+
m.periodMu.RLock()
701+
entry, ok := m.periodCache[monthLabel]
702+
m.periodMu.RUnlock()
703+
if ok && time.Now().UTC().Before(entry.expiresAt) {
704+
return entry.quarter, entry.year
705+
}
634706

635-
var filtered []Snapshot
636-
for _, s := range snapshots {
637-
t := parseMonth(s.Month)
638-
if !t.Before(cutoff) {
639-
filtered = append(filtered, s)
707+
// Singleflight: only one goroutine computes a given month at a time.
708+
m.periodInflightMu.Lock()
709+
if wg, inflight := m.periodInflight[monthLabel]; inflight {
710+
m.periodInflightMu.Unlock()
711+
wg.Wait()
712+
continue // winner populated the cache; re-check it
640713
}
714+
wg := &sync.WaitGroup{}
715+
wg.Add(1)
716+
m.periodInflight[monthLabel] = wg
717+
m.periodInflightMu.Unlock()
718+
719+
return m.computeAndCachePeriods(monthLabel, monthStart, queryAt, apps, wg)
641720
}
642-
return filtered
643721
}
644722

645-
// aggregateSnapshots computes stats from a list of snapshots.
646-
// If avg is true, it computes averages; otherwise uses the single snapshot values.
647-
func aggregateSnapshots(snapshots []Snapshot, avg bool) PeriodStats {
648-
if len(snapshots) == 0 {
649-
return PeriodStats{}
650-
}
723+
// computeAndCachePeriods runs the windowed quarter/year queries, stores the
724+
// result in periodCache and releases the singleflight slot. It is only ever
725+
// called by the goroutine that won the singleflight in periodsFor.
726+
func (m *OverviewManager) computeAndCachePeriods(monthLabel string, monthStart, queryAt time.Time, apps map[string]int, wg *sync.WaitGroup) (PeriodStats, PeriodStats) {
727+
defer func() {
728+
m.periodInflightMu.Lock()
729+
delete(m.periodInflight, monthLabel)
730+
m.periodInflightMu.Unlock()
731+
wg.Done()
732+
}()
651733

652-
// Snapshots are sorted descending by month. The latest is first.
653-
latest := snapshots[0]
654-
oldest := snapshots[len(snapshots)-1]
734+
quarterStart := monthStart.AddDate(0, -2, 0)
735+
quarter := m.periodStats(
736+
fmt.Sprintf("%s \u2014 %s", quarterStart.Format("January 2006"), monthStart.Format("January 2006")),
737+
quarterStart, queryAt, apps)
655738

656-
stats := PeriodStats{
657-
Apps: make(map[string]int),
739+
yearStart := monthStart.AddDate(0, -11, 0)
740+
year := m.periodStats(
741+
fmt.Sprintf("%s \u2014 %s", yearStart.Format("January 2006"), monthStart.Format("January 2006")),
742+
yearStart, queryAt, apps)
743+
744+
expiresAt := time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC)
745+
if m.isCurrentOrFutureMonth(monthLabel) {
746+
expiresAt = time.Now().UTC().Add(currentMonthPeriodTTL)
658747
}
748+
m.periodMu.Lock()
749+
m.periodCache[monthLabel] = periodCacheEntry{quarter: quarter, year: year, expiresAt: expiresAt}
750+
m.periodMu.Unlock()
659751

660-
// Build label and date range
661-
latestDate := parseMonth(latest.Month)
662-
oldestDate := parseMonth(oldest.Month)
752+
return quarter, year
753+
}
663754

664-
if len(snapshots) == 1 {
665-
stats.Label = latestDate.Format("January 2006")
666-
stats.Start = latestDate.Format("2006-01-02")
667-
endOfMonth := latestDate.AddDate(0, 1, -1)
668-
stats.End = endOfMonth.Format("2006-01-02")
669-
} else {
670-
stats.Label = fmt.Sprintf("%s \u2014 %s",
671-
oldestDate.Format("January 2006"),
672-
latestDate.Format("January 2006"))
673-
stats.Start = oldestDate.Format("2006-01-02")
674-
endOfMonth := latestDate.AddDate(0, 1, -1)
675-
stats.End = endOfMonth.Format("2006-01-02")
676-
}
677-
678-
if !avg || len(snapshots) == 1 {
679-
// Use the latest snapshot directly
680-
stats.Clusters = latest.Clusters
681-
stats.TotalNodes = latest.TotalNodes
682-
stats.TotalTenants = latest.TotalTenants
683-
if latest.Clusters > 0 {
684-
stats.AvgNodesPerCluster = roundTo(float64(latest.TotalNodes)/float64(latest.Clusters), 1)
685-
stats.AvgTenantsPerCluster = roundTo(float64(latest.TotalTenants)/float64(latest.Clusters), 1)
686-
}
687-
for k, v := range latest.Apps {
688-
stats.Apps[k] = v
689-
}
690-
return stats
755+
// periodStats runs windowed VictoriaMetrics queries over [start, end] and
756+
// returns aggregated stats for the period. Clusters/nodes/tenants are counted
757+
// over the whole window via max_over_time, so every entity active at any point
758+
// during the period is included (see collectSnapshot for why an instant query
759+
// undercounts). The app breakdown is supplied by the caller.
760+
func (m *OverviewManager) periodStats(label string, start, end time.Time, apps map[string]int) PeriodStats {
761+
windowSec := int(end.Sub(start).Seconds())
762+
if windowSec < 300 {
763+
windowSec = 300
691764
}
765+
window := fmt.Sprintf("%ds", windowSec)
692766

693-
// Average across snapshots
694-
n := float64(len(snapshots))
695-
var totalClusters, totalNodes, totalTenants float64
696-
appTotals := make(map[string]float64)
697-
698-
for _, s := range snapshots {
699-
totalClusters += float64(s.Clusters)
700-
totalNodes += float64(s.TotalNodes)
701-
totalTenants += float64(s.TotalTenants)
702-
for k, v := range s.Apps {
703-
appTotals[k] += float64(v)
704-
}
767+
stats := PeriodStats{
768+
Label: label,
769+
Start: start.Format("2006-01-02"),
770+
End: end.Format("2006-01-02"),
771+
Apps: apps,
705772
}
706773

707-
stats.Clusters = int(math.Round(totalClusters / n))
708-
stats.TotalNodes = int(math.Round(totalNodes / n))
709-
stats.TotalTenants = int(math.Round(totalTenants / n))
774+
if clusters, err := m.queryScalar(fmt.Sprintf(`count(count by (cluster_id) (max_over_time(cozy_cluster_info[%s])))`, window), end); err != nil {
775+
log.Printf("Error querying %s cluster count: %v", label, err)
776+
} else {
777+
stats.Clusters = int(clusters)
778+
}
779+
if nodes, err := m.queryScalar(fmt.Sprintf(`sum(max_over_time(cozy_nodes_count[%s]))`, window), end); err != nil {
780+
log.Printf("Error querying %s node count: %v", label, err)
781+
} else {
782+
stats.TotalNodes = int(nodes)
783+
}
784+
if tenants, err := m.queryScalar(fmt.Sprintf(`sum(max_over_time(cozy_application_count{kind="Tenant"}[%s]))`, window), end); err != nil {
785+
log.Printf("Error querying %s tenant count: %v", label, err)
786+
} else {
787+
stats.TotalTenants = int(tenants)
788+
}
710789
if stats.Clusters > 0 {
711790
stats.AvgNodesPerCluster = roundTo(float64(stats.TotalNodes)/float64(stats.Clusters), 1)
712791
stats.AvgTenantsPerCluster = roundTo(float64(stats.TotalTenants)/float64(stats.Clusters), 1)
713792
}
714-
715-
for k, v := range appTotals {
716-
stats.Apps[k] = int(math.Round(v / n))
717-
}
718-
719793
return stats
720794
}
721795

0 commit comments

Comments
 (0)