Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger. MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon. Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
7.7 KiB
Historical stats
The rule
Historical stats read hourly rollup tables filled once per closed hour by one background task. Raw rows are read for per-entity drill-downs (a packet, a trace, a route), observer comparison, sub-hour observer activity, and bounded observer-directory slices that cannot use a completed whole-hour rollup.
Rollup contract
- Hours.
houris the UTCdate_trunc('hour', heard_at)and leads every rollup table's primary key. Rollup tables have no foreign keys: history outlives deleted observers, nodes and IATAs. - Eligibility. Hour
[H, H+1h)can be rolled oncenow() >= H + 95 min. Ingest clampsheard_atto ±30 minutes of server time, so nothing can still land in it. - Registration. Every eligible hour since the earliest retained observation gets a row
in
analytics_rollup_hours, including hours with no traffic. Status ismissing,completeorpartial. - Rolling. One process holds the advisory lock (
analytics_rolluptask, every 5 minutes, plus a catch-up at boot). Each hour is rolled in one REPEATABLE READ transaction: copy its observations (joined to packet dimensions) into a temp table, delete and reinsert every family, hash the result, mark the hour complete. A serialization failure skips the hour until the next pass. - Revision.
analytics_state.revisionincreases when an hour's content hash changes or an hour turns partial;analytics_raw_state.coverageincreases when retention deletes rollup hours. Cache keys for rollup-backed endpoints include their sum (each counter only grows), read through a 10 s memo. - Raw deletion. Cleanup deletes packets older than
packets.retention, but holds the cutoff 35 minutes behind the oldestmissinghour, for at most 24 hours (it logs a warning past 6). Each batch advancesanalytics_raw_state.raw_deleted_beforeto the newest deletedlast_heard_at. Cleanup and the rollup never write the same row. - Partial hours. A not-yet-complete hour starting at or before
raw_deleted_before + 30 minlost raw rows before it was rolled. It is markedpartialand never rolled; readers report it without values, except the observer directory, which counts any retained raw observations in the requested slice. - Re-rolls. Hours are not re-rolled periodically. A message stored by channel backfill (at
startup, and when keys are imported at runtime) queues every hour its packet was heard in
analytics_dirty_hours, in the same statement as the message insert. Each pass re-rolls a few complete ones while their raw rows remain. A queued hour still waiting for its first roll keeps its entry unless that roll's snapshot already included it. - Retention. Rollups are kept for
analytics.rollup_retention(default 90 days), which is also the longest window/stats/seriesaccepts.
Combining hours
| Metric | Across hours and IATAs |
|---|---|
| Observations, receptions, payload counts | Sum |
| Unique packets, adverts, messages, scoped packets | Sum of hourly IATA-set counts: each is counted once per hour it was heard, so one heard across an hour boundary counts twice (measured +1.1%) |
| Active observers, IATAs, scopes | Distinct across the window |
| Active scope observers and nodes (per scope, per hour) | Distinct within the hour, across the requested IATAs |
| Max path entries | Max |
| SNR / RSSI averages | Σ sum / Σ samples, never an average of averages |
Historical stats windows snap to UTC hours on the server, and responses report the
effective window. The observer directory preserves exact millisecond bounds instead.
/stats/overview spans the 24 newest eligible hours; its windowHours counts the complete
ones its totals cover.
Observer directory
GET /api/v1/observers/directory defaults to [request time - 7 days, request time).
Explicit since and until are epoch milliseconds and remain exact; windows must be
positive and at most 31 days. If only until is supplied, since defaults to seven days
before it. If only since is supplied, until defaults to request time.
Counts combine complete rollups for whole UTC hours fully inside the window with retained raw observations for every other hour and the exact boundary slices. These sources never overlap. Completed whole-hour history survives raw retention; a sliced boundary whose raw rows have expired cannot be reconstructed from its whole-hour total.
Every observer receives a numeric observationCount, including zero. maxObservationCount
is the maximum across all matching observers, independent of pagination, and is zero when
none match. Incomplete history never suppresses these counts or changes effectiveSort:
traffic sorting uses available counts, while explicit name sorting remains supported.
Both sorts break name ties by observer ID.
coverage is conservative and informational. expectedHours counts intersecting UTC
hours, including boundary slices. completeHours counts the whole hours read from complete
rollups. Other hours are partialHours when there is a partial/boundary rollup or matching
retained raw traffic; otherwise they are missingHours. Raw-only traffic does not establish
complete history. Status is complete only when all intersecting hours are complete,
partial when any complete or partial hour exists, and unavailable otherwise. Zero counts
with incomplete coverage mean no available observations, not proof that no traffic occurred.
The response uses Cache-Control: no-store. For continuation requests, send nextCursor,
the same filters and sort, and the exact returned windowStart/windowEnd as since/until.
These bounds fix the time window, not a database snapshot; late arrivals and metadata changes
can still move results. Refresh without bounds to obtain a new rolling window and include
new traffic.
IATA sets
A packet heard in YVR and YYZ appears in both IATAs' per-IATA rows, so summing per-IATA
distinct counts overcounts (2.26× on production data). The *_sets tables instead group
each distinct packet, advert or message under the exact sorted set of IATAs that heard it
that hour (iatas char(3)[], kept canonical by a CHECK). For any requested set S, the rows
where iatas && S count every item heard in S exactly once. This is exact for one IATA, a
region or all IATAs.
Raw or current, by design
- Packet, trace and route detail, the packet list, observer comparison, and observer
activity below one hour. Hourly observer activity reads raw rows for the hours after the
newest complete one (at most the last 24 hours, the raw holdback), so the unrolled tail isn't
shown as zero.
rolledUntilandrawFromin the response mark any hours still uncovered. - Current memberships: scope observer and node counts, node types, clock drift, radio presets.
/tracesreadstrace_tags, a per-tag summary kept at ingest, not a rollup. A new packet adds its count, type and payload in the statement that stores it; hearings set the times.
Adding a metric
- Add a family table in a new numbered migration (
001_baseline.sqlis frozen from 2.0.0):hourfirst in the primary key, no foreign keys, and a set table withCHECK (iata_set_is_canonical(iatas))plus a GIN index if it needs distinct counts. - Add a delete in
DeleteRollupHour, aRoll*insert overrollup_obsindb/queries/rollup.sql, a line inRollupContentHashandDeleteOldRollups, and the call inrollHour(db/rollup.go). - Read it with an hour-bounded query, using the UNION-ALL IATA guard (see
series.sql) for per-IATA tables oriatas && @iatasfor set tables. - Cache it with
cachedRollupso its key carries the revision. - Add a raw-SQL oracle case to
db/rollup_integration_test.go. - Run
sqlc generate, regenerate mocks and swagger.