Files
MrAlders0n 14354b03dc fix(telemetry): stop partial and bogus stats inflating bucketed counters
Bucketed airtime and receive errors used MAX - MIN per bucket, so one zeroed,
replayed or garbage row counted the whole running total (e.g. 47,737 s RX in a
6 h bucket). Buckets are now summed from per-row increments with dips, stale
replays and physically impossible jumps rejected. Ingest skips uptime-only
stats and a migration deletes the rows already stored.
2026-09-30 16:32:11 -04:00

200 lines
6.2 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
import (
"testing"
"time"
)
var telemetryBase = time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC)
type row struct {
hour int
tx, rx float32
errs int32
noise float32
uptime int64
}
func telemetryPoints(rows ...row) []ObserverTelemetryPoint {
pts := make([]ObserverTelemetryPoint, 0, len(rows))
for _, r := range rows {
tx, rx, errs, noise, uptime := r.tx, r.rx, r.errs, r.noise, r.uptime
pts = append(pts, ObserverTelemetryPoint{
T: telemetryBase.Add(time.Duration(r.hour) * time.Hour).UnixMilli(),
AirtimeTxSecs: &tx,
AirtimeRxSecs: &rx,
ReceiveErrors: &errs,
NoiseFloorDB: &noise,
UptimeSeconds: &uptime,
})
}
return pts
}
func bucketAt(t *testing.T, buckets []ObserverTelemetryPoint, hour int) ObserverTelemetryPoint {
t.Helper()
want := telemetryBase.Add(time.Duration(hour) * time.Hour).UnixMilli()
for _, b := range buckets {
if b.T == want {
return b
}
}
t.Fatalf("no bucket at hour %d in %+v", hour, buckets)
return ObserverTelemetryPoint{}
}
func checkCounters(t *testing.T, b ObserverTelemetryPoint, tx, rx float32, errs int32) {
t.Helper()
if *b.AirtimeTxSecs != tx || *b.AirtimeRxSecs != rx || *b.ReceiveErrors != errs {
t.Errorf("counters = tx %v rx %v errs %v, want tx %v rx %v errs %v",
*b.AirtimeTxSecs, *b.AirtimeRxSecs, *b.ReceiveErrors, tx, rx, errs)
}
}
func TestBucketTelemetry_SumsIncrements(t *testing.T) {
pts := telemetryPoints(
row{0, 100, 1000, 50, -90, 10000},
row{1, 110, 1060, 55, -92, 13600},
row{2, 125, 1100, 60, -94, 17200},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
if len(b) != 1 {
t.Fatalf("got %d buckets, want 1", len(b))
}
checkCounters(t, b[0], 25, 100, 10)
if *b[0].NoiseFloorDB != -92 || *b[0].UptimeSeconds != 17200 {
t.Errorf("noise %v uptime %v, want -92 17200", *b[0].NoiseFloorDB, *b[0].UptimeSeconds)
}
}
// Observer e61a62a7 on 2026-09-30: an uptime-only status stored zeros mid-run.
func TestBucketTelemetry_ZeroedRowIsNotARestart(t *testing.T) {
pts := telemetryPoints(
row{5, 7935, 47484, 93689, -96, 1093083},
row{6, 0, 0, 0, 0, 1096701},
row{7, 7951, 47586, 93884, -97, 1100319},
)
b := BucketTelemetry(pts, telemetryBase.Add(5*time.Hour), 6*time.Hour)
checkCounters(t, bucketAt(t, b, 6), 16, 102, 195)
}
func TestBucketTelemetry_SingleCounterDip(t *testing.T) {
pts := telemetryPoints(
row{0, 3108, 19536, 38083, -108, 473828},
row{1, 3160, 19837, 0, -92, 477447},
row{2, 3208, 20145, 39211, -94, 481065},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 100, 609, 1128)
}
func TestBucketTelemetry_RebootCountsSinceBoot(t *testing.T) {
pts := telemetryPoints(
row{0, 50, 500, 10, -90, 10000},
row{1, 60, 560, 12, -90, 13600},
row{2, 5, 40, 3, -90, 1200},
row{3, 9, 70, 4, -90, 4800},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 10+5+4, 60+40+30, 2+3+1)
}
// Observer 50350675 replays an old snapshot between live rows.
func TestBucketTelemetry_StaleReplaySkipped(t *testing.T) {
pts := telemetryPoints(
row{0, 121, 4788, 0, -120, 225259},
row{1, 123, 4849, 0, -120, 228860},
row{2, 88, 3879, 0, -50, 184130},
row{3, 126, 4972, 0, -120, 236061},
row{4, 88, 3879, 0, -50, 184130},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 5, 184, 0)
if *b[0].NoiseFloorDB != -120 || *b[0].UptimeSeconds != 236061 {
t.Errorf("stale row leaked into averages: noise %v uptime %v", *b[0].NoiseFloorDB, *b[0].UptimeSeconds)
}
}
func TestBucketTelemetry_LookbackSeedsBaseline(t *testing.T) {
pts := telemetryPoints(
row{5, 100, 1000, 50, -90, 10000},
row{6, 110, 1030, 52, -90, 13600},
row{12, 130, 1090, 58, -90, 35200},
)
b := BucketTelemetry(pts, telemetryBase.Add(6*time.Hour), 6*time.Hour)
if len(b) != 2 {
t.Fatalf("got %d buckets, want 2 (lookback row must not get its own)", len(b))
}
checkCounters(t, bucketAt(t, b, 6), 10, 30, 2)
checkCounters(t, bucketAt(t, b, 12), 20, 60, 6)
}
// Some firmware keeps counting airtime across a reboot (ed5a20b6).
func TestBucketTelemetry_RebootWithPersistentCounters(t *testing.T) {
pts := telemetryPoints(
row{0, 900, 178000, 10, -90, 500000},
row{1, 910, 178400, 12, -90, 503600},
row{2, 915, 178485, 1, -90, 2250},
row{3, 920, 178589, 3, -90, 5851},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 20, 589, 2+1+2)
}
// Observer 408159b8 reported one-off garbage between sane readings.
func TestBucketTelemetry_GarbageSpikeRejected(t *testing.T) {
pts := telemetryPoints(
row{0, 96, 164, 144, -89, 145911},
row{1, 101, 172, 146, -89, 149536},
row{2, 111, 201329470, 2080374784, -90, 156847},
row{3, 114, 197, 160, -89, 160464},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 18, 33, 16)
}
func TestBucketTelemetry_GarbageBaselineRecovers(t *testing.T) {
pts := telemetryPoints(
row{0, 100, 2080374784, 50, -90, 10000},
row{1, 110, 200, 55, -90, 13600},
row{2, 120, 210, 60, -90, 17200},
row{3, 130, 220, 65, -90, 20800},
row{4, 140, 235, 70, -90, 24400},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, b[0], 40, 15, 20)
}
func TestBucketTelemetry_GapLongerThanBucketDropped(t *testing.T) {
pts := telemetryPoints(
row{0, 100, 22018, 50, -90, 500000},
row{1, 110, 22100, 55, -90, 503600},
row{20, 400, 39295, 900, -90, 572000},
row{21, 410, 39384, 905, -90, 575600},
)
b := BucketTelemetry(pts, telemetryBase, 6*time.Hour)
checkCounters(t, bucketAt(t, b, 18), 10, 89, 5)
}
func TestBucketTelemetry_AveragesAndNils(t *testing.T) {
b1, b2 := int32(3700), int32(3705)
q := int32(2)
pts := []ObserverTelemetryPoint{
{T: telemetryBase.UnixMilli(), BatteryMV: &b1, QueueLength: &q},
{T: telemetryBase.Add(time.Hour).UnixMilli(), BatteryMV: &b2},
}
b := BucketTelemetry(pts, telemetryBase, 24*time.Hour)
if len(b) != 1 {
t.Fatalf("got %d buckets, want 1", len(b))
}
if *b[0].BatteryMV != 3703 || *b[0].QueueLength != 2 {
t.Errorf("battery %v queue %v, want 3703 2", *b[0].BatteryMV, *b[0].QueueLength)
}
if b[0].AirtimeTxSecs != nil || b[0].NoiseFloorDB != nil || b[0].UptimeSeconds != nil {
t.Errorf("fields with no data should stay nil: %+v", b[0])
}
}