fix(observers): separate packet metrics from presence updates

This commit is contained in:
n30nex authored and MrAlders0n committed 2026-09-30 07:19:02 -04:00
1 parent fd7c6b3eb7
commit 5fbba24a4a
31 files changed
+844 -93

No files matched your search

+12 -1
View File
@@ -256,6 +256,7 @@ var activityIntervals = map[string]time.Duration{
// @Param observerId path string true "Observer UUID"
// @Param range query string false "Trailing window as a Go duration, max 720h (default 24h); max 48h when interval is under 1h"
// @Param interval query string false "Bucket size: 5m, 15m, 1h, 6h or 24h (default 15m)"
// @Param until query int false "Optional exclusive end in epoch milliseconds; aligned down to a complete bucket, at most 30 days old"
// @Success 200 {object} api.ObserverActivity
// @Failure 400 {object} handlers.APIError
// @Failure 404 {object} handlers.APIError
@@ -295,7 +296,17 @@ func getObserverActivity(reader api.Reader) http.HandlerFunc {
respondError(w, http.StatusBadRequest, "range/interval exceeds 1000 buckets")
return
}
activity, err := reader.GetObserverActivity(r.Context(), observerID, window, interval)
until := time.Time{}
if values, ok := r.URL.Query()["until"]; ok {
n, err := strconv.ParseInt(values[0], 10, 64)
now := time.Now()
if len(values) != 1 || err != nil || n < 0 || n > now.UnixMilli() || n < now.Add(-720*time.Hour).UnixMilli() {
respondError(w, http.StatusBadRequest, "until must be one past epoch-millisecond timestamp within 30 days")
return
}
until = time.UnixMilli(n)
}
activity, err := reader.GetObserverActivity(r.Context(), observerID, window, interval, until)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
respondError(w, http.StatusNotFound, "observer not found")
+12 -8
View File
@@ -200,7 +200,7 @@ func TestGetObserverActivity_Defaults(t *testing.T) {
var gotWindow, gotInterval time.Duration
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
gotWindow, gotInterval = window, interval
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
},
@@ -232,7 +232,7 @@ func TestGetObserverActivity_CustomRangeAndInterval(t *testing.T) {
var gotWindow, gotInterval time.Duration
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
gotWindow, gotInterval = window, interval
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
},
@@ -264,7 +264,7 @@ func TestGetObserverActivity_SubHourRangeLimit(t *testing.T) {
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
t.Fatal("reader should not be called")
return nil, nil
},
@@ -286,7 +286,7 @@ func TestGetObserverActivity_SubHourRangeLimit(t *testing.T) {
var gotWindow, gotInterval time.Duration
ok := chi.NewRouter()
ok.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
gotWindow, gotInterval = window, interval
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
},
@@ -309,6 +309,10 @@ func TestGetObserverActivity_BadRequests(t *testing.T) {
query string
}{
{"invalid uuid", "not-a-uuid", ""},
{"bad end", "00000000-0000-0000-0000-000000000001", "?until=banana"},
{"negative end", "00000000-0000-0000-0000-000000000001", "?until=-1"},
{"duplicate end", "00000000-0000-0000-0000-000000000001", "?until=1&until=2"},
{"future end", "00000000-0000-0000-0000-000000000001", "?until=253402300799999"},
{"unparseable range", "00000000-0000-0000-0000-000000000001", "?range=banana"},
{"range too long", "00000000-0000-0000-0000-000000000001", "?range=721h"},
{"zero range", "00000000-0000-0000-0000-000000000001", "?range=0"},
@@ -319,7 +323,7 @@ func TestGetObserverActivity_BadRequests(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
t.Fatal("reader should not be called")
return nil, nil
},
@@ -338,7 +342,7 @@ func TestGetObserverActivity_NotFound(t *testing.T) {
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
return nil, pgx.ErrNoRows
},
}))
@@ -364,7 +368,7 @@ func TestGetObserverActivity_ReaderError(t *testing.T) {
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
return nil, errors.New("boom")
},
}))
@@ -381,7 +385,7 @@ func TestGetObserverActivity_NullsSerialise(t *testing.T) {
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
r := chi.NewRouter()
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
return &api.ObserverActivity{
PayloadTypes: []api.PayloadBreakdownItem{},
Points: []api.ObserverActivityPoint{{T: 1757376000000, Observations: 3}},
+3 -3
View File
@@ -32,7 +32,7 @@ type stubReader struct {
getObserver func(ctx context.Context, observerID uuid.UUID) (*api.Observer, error)
getObserverTelemetry func(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error)
getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error)
getObserverActivity func(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error)
getObserverActivity func(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error)
getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error)
listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error)
listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error)
@@ -186,9 +186,9 @@ func (s stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID
return nil, nil
}
func (s stubReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
func (s stubReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error) {
if s.getObserverActivity != nil {
return s.getObserverActivity(ctx, observerID, window, interval)
return s.getObserverActivity(ctx, observerID, window, interval, until)
}
return nil, nil
}
+17 -2
View File
@@ -44,8 +44,8 @@ type Observer struct {
LastStatusAt *int64 `json:"lastStatusAt,omitempty"` // epoch ms
FirstSeen int64 `json:"firstSeen"` // epoch ms
LastSeen int64 `json:"lastSeen"` // epoch ms
ObservationCount int64 `json:"observationCount"`
Brokers []ObserverBroker `json:"brokers"` // broker names this observer has been seen on
ObservationCount int64 `json:"observationCount"` // legacy cumulative presence counter; includes non-packet events
Brokers []ObserverBroker `json:"brokers"` // broker names this observer has been seen on
}
// ObserverTelemetryPoint is a single telemetry snapshot for an observer.
@@ -89,7 +89,22 @@ type ObserverActivityPoint struct {
}
// ObserverActivity is the per-observer heard-activity response.
// ObserverActivitySummary describes stored packet records, never MQTT presence events.
type ObserverActivitySummary struct {
RecordedPackets int64 `json:"recordedPackets"`
LastCompleteHour int64 `json:"lastCompleteHour"`
LastCompleteHourStart int64 `json:"lastCompleteHourStart"`
LastCompleteHourEnd int64 `json:"lastCompleteHourEnd"`
LatestRecordedAt *int64 `json:"latestRecordedAt"`
}
type ObserverActivity struct {
WindowStart int64 `json:"windowStart"` // inclusive complete-bucket start, epoch ms
WindowEnd int64 `json:"windowEnd"` // exclusive complete-bucket end, epoch ms
GeneratedAt int64 `json:"generatedAt"` // response computation time, not proof of continuous coverage
Source string `json:"source"` // raw or hourly; missing records do not prove an outage
Summary *ObserverActivitySummary `json:"summary,omitempty"`
Range string `json:"range"`
Interval string `json:"interval"`
Radio *ObserverActivityRadio `json:"radio"`
+1 -1
View File
@@ -100,7 +100,7 @@ type Reader interface {
// GetObserverActivity returns bucketed heard-activity for an observer over the trailing window.
// interval >= 1h is served from the hourly rollup. Returns pgx.ErrNoRows for an unknown observer.
// Range and Interval on the result are left empty for the handler to fill.
GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*ObserverActivity, error)
GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*ObserverActivity, error)
// GetObserverScopes returns the names of all transport scopes an observer has
// been seen forwarding packets for, ordered alphabetically.