feat(stats): add bounded path and hash-width analytics (#159)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-23 20:18:41 -04:00
1 parent 6107578c25
commit 8a3fa7e6bf
25 files changed
+1205 -4

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
// getPathStats godoc
//
// @Summary Received path-entry and hash-width distributions
// @Description Counts stored receptions in [since, until), at most 30 days. Hash widths count only validated nonempty ordinary header paths (1/2/3 bytes). Empty paths do not vote for a width. TRACE header paths contain signal readings and are separate. Missing payload type or inconsistent/unsupported path metadata is unclassified. The four categories partition total receptions. Flood paths accumulate entries; direct paths contain remaining entries, so counts do not measure distance or a complete traversed route. Both boundaries round down to UTC hours and the response reports that effective window. Reads use materialized snapshots refreshed by background.view_refresh; the current partial hour is excluded and missing hours are omitted.
// @Tags Stats
// @Produce json
// @Param since query int true "Inclusive start, epoch milliseconds (0 through 253402300799999)"
// @Param until query int true "Exclusive end, epoch milliseconds; at most 30 days after since"
// @Param iatas query string false "Comma-separated reception IATA codes"
// @Param regionId query int false "Region ID, expands to member IATAs"
// @Param region query string false "Region slug, expands to member IATAs"
// @Success 200 {object} api.PathStats
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Failure 503 {object} handlers.APIError
// @Router /stats/paths [get]
func getPathStats(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
since, until, err := parseStatsWindow(r)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
iatas := parseIATAs(r)
if q.Get("regionId") != "" || q.Get("region") != "" {
regionIATAs, err := resolveRegionIATAs(ctx, q.Get("regionId"), q.Get("region"), reader)
if err != nil {
respondError(w, http.StatusBadRequest, "region not found")
return
}
iatas = append(iatas, regionIATAs...)
if len(iatas) == 0 {
iatas = []string{""}
}
}
stats, err := reader.GetPathStats(ctx, since, until, iatas)
switch {
case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded):
respondError(w, http.StatusServiceUnavailable, "path query timed out; try a shorter time period")
case err != nil:
if r.Context().Err() != nil {
return
}
slog.Error("path stats failed", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
default:
respond(w, http.StatusOK, stats)
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
type pathReader struct {
stubReader
read func(context.Context, time.Time, time.Time, []string) (*api.PathStats, error)
}
func (s pathReader) GetPathStats(ctx context.Context, since, until time.Time, iatas []string) (*api.PathStats, error) {
return s.read(ctx, since, until, iatas)
}
func TestPathsRejectInvalidWindow(t *testing.T) {
for _, query := range []string{"", "since=0", "since=x&until=1", "since=-1&until=1", "since=1&until=1", "since=2&until=1", "since=0&until=2592000001", "since=0&since=0&until=1", "since=0&until=1&until=2", "since=253402300799998&until=253402300800000"} {
w := httptest.NewRecorder()
StatsRouter(nil).ServeHTTP(w, httptest.NewRequest("GET", "/paths?"+query, nil))
if w.Code != 400 {
t.Fatalf("query %q: %d %s", query, w.Code, w.Body.String())
}
}
}
func TestPathsFiltersErrorsAndCancellation(t *testing.T) {
for _, tc := range []struct {
name, query string
iatas []string
err error
status int
}{
{"global", "", nil, nil, 200}, {"IATAs", "&iatas=yvr,yyj", []string{"YVR", "YYJ"}, nil, 200},
{"region", "&region=west", []string{"YVR"}, nil, 200}, {"empty region", "&region=empty", []string{""}, nil, 200},
{"missing region", "&region=missing", nil, nil, 400}, {"timeout", "", nil, context.DeadlineExceeded, 503},
{"database error", "", nil, errors.New("private detail"), 500},
} {
t.Run(tc.name, func(t *testing.T) {
reader := pathReader{stubReader: stubReader{getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
if slug == "empty" {
return &api.Region{}, nil
}
if slug == "missing" {
return nil, errors.New("not found")
}
return &api.Region{IATAs: []string{"YVR"}}, nil
}}, read: func(ctx context.Context, since, until time.Time, iatas []string) (*api.PathStats, error) {
if d, ok := ctx.Deadline(); !ok || time.Until(d) > 15*time.Second {
t.Fatal("query has no bounded deadline")
}
if since.UnixMilli() != 0 || until.UnixMilli() != 2592000000 || !reflect.DeepEqual(iatas, tc.iatas) {
t.Fatalf("unexpected arguments: %v %v %v", since, until, iatas)
}
return &api.PathStats{Until: until.UnixMilli(), Receptions: 4, Hashed: 1, Empty: 1, Trace: 1, Unclassified: 1}, tc.err
}}
w := httptest.NewRecorder()
StatsRouter(reader).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=2592000000"+tc.query, nil))
if w.Code != tc.status || strings.Contains(w.Body.String(), "private detail") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
if tc.status == 200 {
var got api.PathStats
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.Receptions != 4 || got.Trace != 1 {
t.Fatalf("response: %+v %v", got, err)
}
}
})
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
called := false
r := pathReader{read: func(ctx context.Context, _ time.Time, _ time.Time, _ []string) (*api.PathStats, error) {
called = true
return nil, ctx.Err()
}}
w := httptest.NewRecorder()
StatsRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/paths?since=0&until=1", nil).WithContext(ctx))
if !called || w.Body.Len() != 0 {
t.Fatal("cancellation did not reach reader")
}
}
+1
View File
@@ -31,6 +31,7 @@ func StatsRouter(reader api.Reader) http.Handler {
r.Get("/overview", getStatsOverview(reader))
r.Get("/observations", getStatsObservations(reader))
r.Get("/signal", getSignalStats(reader))
r.Get("/paths", getPathStats(reader))
r.Get("/observer-comparison", getObserverComparison(reader))
r.Get("/payload-breakdown", getStatsPayloadBreakdown(reader))
r.Get("/top-nodes", getStatsTopNodes(reader))
@@ -70,6 +70,10 @@ func (s stubReader) GetSignalStats(context.Context, time.Time, time.Time, []stri
return nil, nil
}
func (s stubReader) GetPathStats(context.Context, time.Time, time.Time, []string) (*api.PathStats, error) {
return nil, nil
}
func (s stubReader) GetObserverComparison(ctx context.Context, a, b uuid.UUID, since, until time.Time, iatas []string) (*api.ObserverComparison, error) {
if s.getObserverComparison != nil {
return s.getObserverComparison(ctx, a, b, since, until, iatas)
+46
View File
@@ -0,0 +1,46 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
// PathStats counts stored receptions in [Since, Until), not unique packets or nodes.
// Hashed + Empty + Trace + Unclassified partitions Receptions. Empty paths never
// vote for a hash width. TRACE header paths contain signal readings, not hashes.
// Since and Until are the effective UTC-hour boundaries of the materialized snapshot.
type PathStats struct {
Since int64 `json:"since"`
Until int64 `json:"until"`
Receptions int64 `json:"receptions"`
Hashed int64 `json:"hashed"`
Empty int64 `json:"empty"`
Trace int64 `json:"trace"`
Unclassified int64 `json:"unclassified"`
HashWidths []PathHashWidth `json:"hashWidths"`
PathLengths []PathLengthBin `json:"pathLengths"`
Hourly []PathHour `json:"hourly"`
}
type PathHashWidth struct {
Bytes int32 `json:"bytes"`
Receptions int64 `json:"receptions"`
}
// PathLengthBin counts entries in validated ordinary header paths, including empty.
// Flood paths accumulate entries; direct paths contain remaining route entries.
// These counts cannot establish distance or a complete end-to-end hop count.
type PathLengthBin struct {
Entries int32 `json:"entries"`
Receptions int64 `json:"receptions"`
}
// PathHour is a UTC hour clipped to the requested window. Missing hours are omitted.
type PathHour struct {
Hour int64 `json:"hour"`
Receptions int64 `json:"receptions"`
OneByte int64 `json:"oneByte"`
TwoByte int64 `json:"twoByte"`
ThreeByte int64 `json:"threeByte"`
Empty int64 `json:"empty"`
Trace int64 `json:"trace"`
Unclassified int64 `json:"unclassified"`
}
+3
View File
@@ -164,6 +164,9 @@ type Reader interface {
// GetSignalStats aggregates retained reception readings in [since, until).
GetSignalStats(ctx context.Context, since, until time.Time, iatas []string) (*SignalStats, error)
// GetPathStats counts received path entries and validated ordinary hash widths.
GetPathStats(ctx context.Context, since, until time.Time, iatas []string) (*PathStats, error)
// GetStatsPayloadBreakdown returns observation counts grouped by payload type.
// Pass nil for iatas to return stats across all IATAs.
// since defines the start of the window; pass zero time for default (last 24h).
+2
View File
@@ -23,6 +23,7 @@ type viewRefresher interface {
RefreshRadioPresets(context.Context) error
RefreshObserverActivity(context.Context) error
RefreshSignalStats(context.Context) error
RefreshPathStats(context.Context) error
}
// ViewRefreshTask returns a Task that refreshes all materialized views.
@@ -45,6 +46,7 @@ func ViewRefreshTask(store viewRefresher, interval time.Duration) Task {
{"radio presets", store.RefreshRadioPresets},
{"observer activity", store.RefreshObserverActivity},
{"signal stats", store.RefreshSignalStats},
{"path stats", store.RefreshPathStats},
} {
if err := view.refresh(ctx); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", view.name, err))
+7 -2
View File
@@ -123,6 +123,10 @@ func (s *refreshStub) RefreshSignalStats(ctx context.Context) error {
return s.refresh(ctx, "signal stats")
}
func (s *refreshStub) RefreshPathStats(ctx context.Context) error {
return s.refresh(ctx, "path stats")
}
func TestViewRefreshTask(t *testing.T) {
first, second := errors.New("first failure"), errors.New("second failure")
for _, tc := range []struct {
@@ -132,13 +136,14 @@ func TestViewRefreshTask(t *testing.T) {
{"success", nil},
{"partial failure", map[string]error{"hourly stats": first, "top talkers": second}},
{"signal failure", map[string]error{"signal stats": first}},
{"path failure", map[string]error{"path stats": second}},
} {
t.Run(tc.name, func(t *testing.T) {
store := &refreshStub{errs: tc.errs}
task := ViewRefreshTask(store, time.Minute)
err := task.Run(context.Background())
if len(store.calls) != 9 {
t.Fatalf("refreshed %d views, want 9", len(store.calls))
if len(store.calls) != 10 {
t.Fatalf("refreshed %d views, want 10", len(store.calls))
}
if len(tc.errs) == 0 && err != nil {
t.Fatal(err)
+4
View File
@@ -35,6 +35,10 @@ func (s *stubReader) GetSignalStats(context.Context, time.Time, time.Time, []str
return nil, s.err
}
func (s *stubReader) GetPathStats(context.Context, time.Time, time.Time, []string) (*api.PathStats, error) {
return nil, s.err
}
func (s *stubReader) ListIATAs(_ context.Context) ([]api.IATA, error) {
s.calls++
return s.iatas, s.err
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package cache
import (
"context"
"encoding/json"
"fmt"
"slices"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func (cr *CachedReader) GetPathStats(ctx context.Context, since, until time.Time, iatas []string) (*api.PathStats, error) {
sorted := append([]string{}, iatas...)
slices.Sort(sorted)
sorted = slices.Compact(sorted)
segment, _ := json.Marshal(sorted) // [] and [""] must remain distinct (global vs empty region).
key := fmt.Sprintf("%s%s:%d:%d", keyPathStatsPrefix, segment, since.UnixMilli(), until.UnixMilli())
return getOrSet(ctx, cr.c, key, cr.ttl.Stats, func() (*api.PathStats, error) {
return cr.inner.GetPathStats(ctx, since, until, iatas)
})
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package cache
import (
"context"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
type pathStatsReader struct {
api.Reader
calls int64
}
func (r *pathStatsReader) GetPathStats(context.Context, time.Time, time.Time, []string) (*api.PathStats, error) {
r.calls++
return &api.PathStats{Receptions: r.calls}, nil
}
func TestPathCacheSeparatesWindowAndRegion(t *testing.T) {
c, mr := newTestClient(t)
inner := &pathStatsReader{}
r := NewCachedReader(inner, c, CacheTTLs{Stats: time.Minute})
for _, tc := range []struct {
iatas []string
since, until, want int64
}{
{nil, 0, 10, 1}, {[]string{}, 0, 10, 1}, {[]string{""}, 0, 10, 2}, {[]string{"YVR"}, 0, 10, 3},
{[]string{"YVR", "YYJ"}, 0, 10, 4}, {[]string{"YYJ", "YVR", "YYJ"}, 0, 10, 4},
{nil, 1, 10, 5}, {nil, 0, 11, 6}, {nil, 0, 10, 1},
} {
before := slices.Clone(tc.iatas)
got, err := r.GetPathStats(context.Background(), time.UnixMilli(tc.since), time.UnixMilli(tc.until), tc.iatas)
if err != nil || got.Receptions != tc.want || !slices.Equal(before, tc.iatas) {
t.Fatalf("%+v: %+v %v", tc, got, err)
}
}
mr.FastForward(time.Minute)
got, err := r.GetPathStats(context.Background(), time.UnixMilli(0), time.UnixMilli(10), nil)
if err != nil || got.Receptions != 7 {
t.Fatalf("expiry: %+v %v", got, err)
}
}
+1
View File
@@ -30,6 +30,7 @@ const (
keyStatsOverviewPrefix = "beacon:stats:overview:"
keyStatsObservationsPrefix = "beacon:stats:observations:"
keySignalStatsPrefix = "beacon:stats:signal:"
keyPathStatsPrefix = "beacon:stats:paths:"
keyStatsBreakdownPrefix = "beacon:stats:breakdown:"
keyStatsTopNodesPrefix = "beacon:stats:top-nodes:"
keyStatsTopObsPrefix = "beacon:stats:top-observers:"