Files
meshcore-analyzer/cmd/server/observer_analytics.go
T
d60188e481 refactor(1828): split handleObserverAnalytics into 5 helpers + byTxID fast-path (#1839)
## Summary

Phase A of #1828: extract the 5 aggregate builders in
`handleObserverAnalytics` into pure helpers in a new
`cmd/server/observer_analytics.go`. Handler becomes a snapshot + filter
+ 5 composed calls.

Also adopts the `byTxID` direct-read in `buildPacketTypes` (issue body's
core observation): the payload-type histogram no longer allocates a full
`enrichObs` map + interface-boxed fields just to read `tx.PayloadType`.
That's the ~90% perf win the triage called out.

Scope is exactly Phase A per the second triage comment. Phase B
(sub-endpoints, caching, SQL migration) is deferred to a follow-up.

## Byte-identical output

- Timeline / NodesTimeline: same key set, same sort, same labels.
- PacketTypes: same keys/counts. Both legacy
(`enriched["payload_type"].(int)`) and new (`tx.PayloadType == nil`
guard) skip obs whose tx is missing or `PayloadType` is `nil`.
- SnrDistribution: same 2-unit floor bucketing (negative-side rounding
preserved), same ascending sort.
- RecentPackets: still the first 20 enriched observations (`enrichObs`
kept only here, where the extra fields are actually needed).

## TDD

- Red commit: `9dc62f43` — 7 unit tests fail on assertions (not build
errors) against stubs.
- Green commit: `8d41011d` — implementations + handler rewire. All new
tests + existing `TestObserverAnalytics*` handler tests pass.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all 8 hard gates + 3 warnings pass).

## Non-goals

- No new endpoints.
- No SQL migration.
- No public API signature change.
- Snapshot count unchanged (still one under RLock, per #1481 P0-2).

Fixes #1828.

---------

Co-authored-by: fix-1828-bot <bot@corescope.local>
Co-authored-by: clawbot <bot@corescope>
2026-07-09 19:14:44 -07:00

208 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package main — observer analytics helpers.
//
// #1828 Phase A: extracted from cmd/server/routes.go handleObserverAnalytics
// (routes.go:2819-2953). Split the 5 aggregate builders into standalone
// functions for readability + isolated tests. No behavior change relative to
// the pre-#1828 handler; JSON output is byte-identical.
//
// The `filtered` argument is the time-window-filtered, timestamp-desc sorted
// slice of *StoreObs produced by the handler after snapshotting under RLock
// (see #1481 P0-2). Helpers do NOT touch store.mu — the handler owns lock
// scoping.
//
// Concurrency note (#1839 MINOR): the RLock snapshot only covers the
// *StoreObs pointer slice; reads of store.byTxID inside buildPacketTypes /
// buildNodesTimeline are unsynchronized concurrent-map reads (pre-existing
// behavior — enrichObs did the same). Not introduced by this refactor.
//
// Perf note (#1839 MINOR): dropping enrichObs on the histogram / nodes-
// timeline paths also eliminates N fetchResolvedPathForObs SQL calls per
// /analytics request (store.go:fetchResolvedPathForObs), not just the alloc/
// boxing win — /analytics SQL-load drops materially.
package main
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"time"
)
// observerAnalyticsBucketDur returns the timeline bucket size for a given day
// range. Mirrors the constant table in the legacy handler.
func observerAnalyticsBucketDur(days int) time.Duration {
bucketDur := 24 * time.Hour
if days <= 1 {
bucketDur = time.Hour
} else if days <= 7 {
bucketDur = 4 * time.Hour
}
return bucketDur
}
// observerAnalyticsFormatLabel returns a closure that formats a bucket time
// as a human-readable label appropriate for the day range.
func observerAnalyticsFormatLabel(days int) func(time.Time) string {
return func(t time.Time) string {
if days <= 1 {
return t.UTC().Format("15:04")
}
if days <= 7 {
return t.UTC().Format("Mon 15:04")
}
return t.UTC().Format("Jan 02")
}
}
// buildTimelineBuckets is the shared kernel used by both buildTimeline and
// buildNodesTimeline. Sorts the count map by bucket-key and returns
// TimeBucket entries with labels formatted per days.
func buildTimelineBuckets(counts map[int64]int, days int) []TimeBucket {
formatLabel := observerAnalyticsFormatLabel(days)
keys := make([]int64, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
out := make([]TimeBucket, 0, len(keys))
for _, k := range keys {
lbl := formatLabel(time.Unix(k, 0))
out = append(out, TimeBucket{Label: &lbl, Count: counts[k]})
}
return out
}
// buildTimeline builds the packet-timeline aggregate for /analytics.
func buildTimeline(filtered []*StoreObs, days int) []TimeBucket {
bucketDur := observerAnalyticsBucketDur(days)
counts := map[int64]int{}
for _, obs := range filtered {
ts, ok := obs.ParsedTime()
if !ok {
continue
}
bucketStart := ts.UTC().Truncate(bucketDur).Unix()
counts[bucketStart]++
}
return buildTimelineBuckets(counts, days)
}
// buildPacketTypes builds the payload-type histogram. Uses a direct
// store.byTxID read (see #1828 triage) rather than enrichObs — the loop only
// needs payload_type, which is a single indirection off the transmission.
// This avoids one map allocation + several interface-boxing conversions per
// observation (routes.go:2886 hot path pre-#1828).
func buildPacketTypes(store *PacketStore, filtered []*StoreObs) map[string]int {
out := map[string]int{}
for _, obs := range filtered {
tx := store.byTxID[obs.TransmissionID]
if tx == nil || tx.PayloadType == nil {
continue
}
out[strconv.Itoa(*tx.PayloadType)]++
}
return out
}
// buildNodesTimeline builds the distinct-node-per-bucket timeline aggregate.
// Nodes = path-json hops decoded_json pubKey/srcHash/destHash.
func buildNodesTimeline(store *PacketStore, filtered []*StoreObs, days int) []TimeBucket {
bucketDur := observerAnalyticsBucketDur(days)
nodeBucketSets := map[int64]map[string]struct{}{}
for _, obs := range filtered {
ts, ok := obs.ParsedTime()
if !ok {
continue
}
bucketStart := ts.UTC().Truncate(bucketDur).Unix()
if nodeBucketSets[bucketStart] == nil {
nodeBucketSets[bucketStart] = map[string]struct{}{}
}
// Legacy handler read decoded_json via enrichObs (which pulls it off
// tx.DecodedJSON). Read tx directly for parity + savings.
if tx := store.byTxID[obs.TransmissionID]; tx != nil && tx.DecodedJSON != "" {
var decoded map[string]interface{}
if json.Unmarshal([]byte(tx.DecodedJSON), &decoded) == nil {
for _, k := range []string{"pubKey", "srcHash", "destHash"} {
if v, ok := decoded[k].(string); ok && v != "" {
nodeBucketSets[bucketStart][v] = struct{}{}
}
}
}
}
for _, hop := range parsePathJSON(obs.PathJSON) {
if hop != "" {
nodeBucketSets[bucketStart][hop] = struct{}{}
}
}
}
nodeCounts := make(map[int64]int, len(nodeBucketSets))
for k, nodes := range nodeBucketSets {
nodeCounts[k] = len(nodes)
}
return buildTimelineBuckets(nodeCounts, days)
}
// buildSnrDistribution builds the SNR histogram (2-unit buckets, floor).
func buildSnrDistribution(filtered []*StoreObs) []SnrDistributionEntry {
snrBuckets := map[int]*SnrDistributionEntry{}
for _, obs := range filtered {
if obs.SNR == nil {
continue
}
bucket := int(*obs.SNR) / 2 * 2
if *obs.SNR < 0 && int(*obs.SNR) != bucket {
bucket -= 2
}
if snrBuckets[bucket] == nil {
snrBuckets[bucket] = &SnrDistributionEntry{Range: fmt.Sprintf("%d to %d", bucket, bucket+2)}
}
snrBuckets[bucket].Count++
}
keys := make([]int, 0, len(snrBuckets))
for k := range snrBuckets {
keys = append(keys, k)
}
sort.Ints(keys)
out := make([]SnrDistributionEntry, 0, len(keys))
for _, k := range keys {
out = append(out, *snrBuckets[k])
}
return out
}
// buildRecentPackets builds the "first N enriched observations" list. This is
// the only aggregate that needs the full enrichObs map — recentPackets is a
// UI-facing payload and the extra fields matter here.
//
// Legacy parity (#1839): the pre-#1828 routes.go loop was
//
// for i, obs := range filtered {
// if _, ok := obs.ParsedTime(); !ok { continue }
// ...
// if i < limit { recentPackets = append(..., enriched) }
// }
//
// The `i` in the gate is the RAW slice index — a bad-ts obs at position k<limit
// consumed its slot (via `continue`) and could not be replaced by a later
// good-ts obs. Result can be <limit when bad-ts obs sit in the head of
// `filtered`. We reproduce that exact semantic here to keep output byte-
// identical.
func buildRecentPackets(store *PacketStore, filtered []*StoreObs, limit int) []map[string]interface{} {
if limit <= 0 {
return []map[string]interface{}{}
}
out := make([]map[string]interface{}, 0, limit)
for i, obs := range filtered {
if i >= limit {
break
}
if _, ok := obs.ParsedTime(); !ok {
continue
}
out = append(out, store.enrichObs(obs))
}
return out
}