Files
meshcore-analyzer/cmd/server/adaptive_offline.go
T
efitenandClaude Opus 5.5 2bb000f2aa feat(notify): adaptive offline threshold and certain node analytics (#2260) (#2262)
Part 2 of #2260. **Builds on #2261** (certain liveness): review/merge
that first; until then this diff also shows its commit.

## Adaptive offline threshold

`userManagement.notifications.adaptiveOffline` — **off by default**:

```json
"notifications": {
  "enabled": true,
  "adaptiveOffline": { "enabled": true, "floorMinutes": 30, "minObservers": 3 }
}
```

The fixed `infraSilentHours` (72 h) treats every repeater the same. With
this on, a watched node's `node.offline` window is learned from its own
**certain** traffic (the #2261 rule):

1. Certain packets per hour over the week **before its last certain
packet**. Ending there, not at now, keeps an outage from dragging the
rate down until the fixed window returns and the node reads "back
online".
2. Empty runs are classified: ≤ 6 h and recurring on another day at the
same hours = normal silence (kept; the window is never shorter than the
longest + 1 h); any other run = outage, left out of the learning so it
neither stretches the window nor blocks learning.
3. λ = 10th percentile of the remaining hourly counts, so quiet nights
don't alert.
4. Window = 14/λ hours: silence at rate λ has a < 10⁻⁶ chance (Poisson,
ln 10⁶ ≈ 13.8).
5. Clamped to [`floorMinutes`, the role's silent window].

Kept on the fixed window: λ < 1/hour, under 24 h of history in memory,
or a 10th-percentile hour heard by fewer than `minObservers` observers
(one observer dropping out must not read as the node going offline).
Silence is measured up to the newest packet in the store, so a stalling
feed stops every node's clock. A node stored offline comes back only on
evidence from the moment it went offline on. The notifier caches each
learned window for 10 minutes and drops unwatched nodes.

For the #2260 repeater (~840 packets/day) the mail would have gone out
about 30 minutes into the outage, instead of never (or 72 h with #2261
alone).

## Certain node analytics

- Each `activityTimeline` bucket carries `certain`: the part that can
only be this node (`certainFor`, the packet-level form of the
`certainHeard` rule). `TimeBucket.Certain` is `omitempty`, so other
endpoints are unchanged.
- Availability and longest silence use certain buckets only; the longest
silence now includes one **still running** (an outage in progress is the
silence worth seeing).
- The chart stacks certain (`--accent`) and uncertain (`--text-muted`) —
existing theme variables, so no new customizer entry. Descriptions
updated.

## Performance

`CertainActivity` walks a week of packets per watched node, at most once
per node per 10 minutes, with the read lock taken **per node**:

```
BenchmarkCertainActivity-16   12.1 ms/op   (50 watched repeaters × 6,000 packets; ~0.25 ms per lock hold)
BenchmarkNoteCertainRelays-16  121 ns/op   0 allocs
```

Node analytics adds one `certainFor` per packet of the requested node (a
prefix-map lookup, or the cached decoded JSON for its own packets).

## Tests

- `adaptiveOfflineAfter`: busy repeater hits the floor; 5/hour; quiet
nights set the rate; > 10 % empty hours, short history, too few
observers → fixed.
- `CertainActivity` ends at the last certain packet and counts only
certain packets — verified red by mutating the window to end at now.
- Notifier end to end: learned window passed → bad; inside → good; too
few observers / setting off → fixed 72 h; cache refresh and prune.
- `certainFor`, `longestSilence` (incl. running silence), config
defaults, `/api/nodes/{pk}/analytics` splits a shared-prefix bucket.
- Frontend: `NodeAnalyticsTimeline.series` (new
`tests/unit/test-node-analytics-certain-timeline.js`).
- `cmd/server` `go test ./...` and `test-all.sh` pass locally, also on a
trial merge into a downstream fork.

## Validation

Server on the freshened e2e fixture: analytics buckets carry `certain`
(e.g. 89 of 90 packets certain for one repeater). **No browser
screenshot**: Playwright isn't installed on this machine.

## Later (rule 8)

`floorMinutes` / `minObservers` are server config, not theme settings;
an admin UI for notification settings would be the place, not the
customizer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 21:20:42 +02:00

265 lines
8.9 KiB
Go

package main
import (
"math"
"sort"
"strings"
"time"
)
// Adaptive offline threshold (#2260, part 2). The fixed infraSilentHours
// (72 h by default) treats every repeater the same: one heard every minute
// that goes quiet for an hour is suspicious, a node that sends a few adverts
// a day is not. For node.offline, a watched node's threshold is learned from
// its own certain traffic (certainFor), so an outage is mailed within the
// hour instead of three days later.
//
// Learning: certain packets per hour over the week before the node's last
// certain packet. Ending there, not at now, keeps a silence in progress out
// of the rate: otherwise the outage itself drags the rate down until the
// fixed threshold returns and the node reads "back online".
//
// Threshold: arrivals treated as Poisson, P(no packet in t) = e^(-λt); for
// that below one in a million t = ln(10⁶)/λ ≈ 14/λ hours, with λ the 10th
// percentile of the hourly counts so quiet nights do not alert.
//
// Empty runs of hours are classified first. A run of at most
// adaptiveMaxNormalRun hours that recurs (another empty run on a different
// day covers one of the same hours of the day) is the node's normal
// silence: it stays in the percentile, and the threshold is never shorter
// than the longest such run plus an hour, so it does not mail offline and
// online every night. Any other run is an outage: it is left out of the
// learning entirely, so one outage neither stretches the threshold for a
// week nor makes the node unlearnable.
//
// Clamped to [floor, the role's silent window]. Nodes with too little
// certain traffic (λ < 1/hour, under a day of history) keep the fixed
// window, and so do nodes whose 10th-percentile hour was heard by fewer
// than minObservers observers: when one observer carries the node, that
// observer dropping out must not read as the node going offline.
const (
adaptiveLearnWindow = 7 * 24 * time.Hour
adaptiveMinHistory = 24 // hours of history before a rate is trusted
adaptiveMinRate = 1.0 // certain packets per hour at the 10th percentile
adaptiveSurpriseHours = 13.815510557964 // ln(10⁶): P(silence) < 10⁻⁶
adaptiveMaxNormalRun = 6 // longest empty run in hours that may be normal silence
adaptiveRefreshEvery = 10 * time.Minute
)
// certainActivity is a node's certain traffic in its learning window.
type certainActivity struct {
From time.Time // start of Hourly[0], UTC, on the hour
Hourly []int // certain packets per hour, oldest first, zero hours included
HourObservers []int // distinct observers per hour of those packets
}
// CertainActivity returns, per pubkey with a certain last heard, its certain
// packets per hour over adaptiveLearnWindow up to that last heard (capped at
// the store's covered window) and how many observers heard each hour.
// O(packets of the given nodes), with the read lock taken per node so ingest
// waits for one node's walk at most, not all of them.
func (s *PacketStore) CertainActivity(pubkeys []string, now time.Time) map[string]certainActivity {
now = now.UTC() // FirstSeen is UTC; a local offset breaks the string bounds
_, pm := s.getCachedNodesAndPM()
s.mu.RLock()
covered, _ := s.coverageStart(now)
s.mu.RUnlock()
covered = covered.UTC()
out := make(map[string]certainActivity, len(pubkeys))
for _, pk := range pubkeys {
if act, ok := s.certainActivityOf(pk, covered, pm); ok {
out[pk] = act
}
}
return out
}
// certainActivityOf is CertainActivity for one node, under its own read lock.
func (s *PacketStore) certainActivityOf(pk string, covered time.Time, pm *prefixMap) (certainActivity, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
last, ok := parseRelayTS(s.certainHeard[pk])
if !ok {
return certainActivity{}, false
}
end := last.UTC().Truncate(time.Hour).Add(time.Hour)
from := end.Add(-adaptiveLearnWindow)
if covered.After(from) {
from = covered.Truncate(time.Hour).Add(time.Hour) // first whole hour held
}
hours := int(end.Sub(from) / time.Hour)
if hours <= 0 {
return certainActivity{}, false
}
act := certainActivity{From: from, Hourly: make([]int, hours), HourObservers: make([]int, hours)}
observers := make([]map[string]struct{}, hours)
// Pre-filter on the date only: legacy FirstSeen values use a space
// instead of T and would sort below a same-day RFC3339 bound. The parsed
// time below decides.
fromDate := from.Format("2006-01-02")
for _, tx := range s.byNode[pk] {
if tx == nil || len(tx.FirstSeen) < 10 || tx.FirstSeen[:10] < fromDate || !certainFor(tx, pk, pm) {
continue
}
t, ok := parseStoreTime(tx.FirstSeen)
if !ok || t.Before(from) || !t.Before(end) {
continue
}
h := int(t.Sub(from) / time.Hour)
act.Hourly[h]++
for _, o := range tx.Observations {
if o != nil && o.ObserverID != "" {
if observers[h] == nil {
observers[h] = map[string]struct{}{}
}
observers[h][o.ObserverID] = struct{}{}
}
}
}
for h, set := range observers {
act.HourObservers[h] = len(set)
}
return act, true
}
// adaptiveOfflineAfter is the learned silence after which a node with this
// activity counts as offline, floored at floor; ok is false when the
// activity is too thin to learn from (see the file comment). The caller
// caps it at the role's silent window.
func adaptiveOfflineAfter(act certainActivity, floor time.Duration, minObservers int) (time.Duration, bool) {
outage, normalRun := classifySilences(act)
var counts, observers []int
for h, c := range act.Hourly {
if outage[h] {
continue
}
counts = append(counts, c)
if c > 0 && h < len(act.HourObservers) {
observers = append(observers, act.HourObservers[h])
}
}
if len(counts) < adaptiveMinHistory || len(observers) == 0 {
return 0, false
}
sort.Ints(counts)
rate := float64(counts[len(counts)/10])
if rate < adaptiveMinRate {
return 0, false
}
sort.Ints(observers)
if observers[len(observers)/10] < minObservers {
return 0, false
}
after := time.Duration(math.Ceil(adaptiveSurpriseHours / rate * float64(time.Hour)))
if normalRun > 0 {
// k empty buckets in a row can hide up to k+1 hours of silence.
if seen := time.Duration(normalRun+1) * time.Hour; after < seen {
after = seen
}
}
if after < floor {
after = floor
}
return after, true
}
// classifySilences marks the hours of outage runs (empty runs that are not
// normal silence, see the file comment) and returns the longest normal run.
func classifySilences(act certainActivity) (outage []bool, longestNormal int) {
type run struct{ start, n int }
var runs []run
for h := 0; h < len(act.Hourly); h++ {
if act.Hourly[h] != 0 {
continue
}
r := run{start: h}
for h < len(act.Hourly) && act.Hourly[h] == 0 {
r.n++
h++
}
runs = append(runs, r)
}
day := func(h int) int { return int(act.From.Add(time.Duration(h)*time.Hour).Unix() / 86400) }
hod := func(h int) int { return act.From.Add(time.Duration(h) * time.Hour).Hour() }
recurs := func(i int) bool {
a := runs[i]
for j, b := range runs {
if j == i || day(b.start) == day(a.start) {
continue
}
for x := a.start; x < a.start+a.n; x++ {
for y := b.start; y < b.start+b.n; y++ {
if hod(x) == hod(y) {
return true
}
}
}
}
return false
}
outage = make([]bool, len(act.Hourly))
for i, r := range runs {
if r.n <= adaptiveMaxNormalRun && recurs(i) {
if r.n > longestNormal {
longestNormal = r.n
}
continue
}
for h := r.start; h < r.start+r.n; h++ {
outage[h] = true
}
}
return outage, longestNormal
}
// adaptiveEntry is one cached threshold; ok false caches "not learnable".
type adaptiveEntry struct {
after time.Duration
ok bool
at time.Time
}
// offlineWindows returns the learned offline window per watched pubkey that
// has one, recomputing entries older than adaptiveRefreshEvery: the rate
// moves slowly and the walk over a week of packets need not run every tick.
// Entries for nodes no longer watched are dropped. Loop goroutine only.
func (n *notifier) offlineWindows(pubkeys []string, now time.Time) map[string]time.Duration {
set := n.a.set.notify.adaptive
if !set.enabled || len(pubkeys) == 0 {
n.adaptive = nil
return nil
}
if n.adaptive == nil {
n.adaptive = map[string]adaptiveEntry{}
}
watched := make(map[string]bool, len(pubkeys))
var stale []string
for _, pk := range pubkeys {
pk = strings.ToLower(pk)
watched[pk] = true
if e, ok := n.adaptive[pk]; !ok || now.Sub(e.at) >= adaptiveRefreshEvery {
stale = append(stale, pk)
}
}
for pk := range n.adaptive {
if !watched[pk] {
delete(n.adaptive, pk)
}
}
if len(stale) > 0 {
acts := n.src.certainActivity(stale, now)
for _, pk := range stale {
after, ok := adaptiveOfflineAfter(acts[pk], set.floor, set.minObservers)
n.adaptive[pk] = adaptiveEntry{after: after, ok: ok, at: now}
}
}
out := make(map[string]time.Duration, len(n.adaptive))
for pk, e := range n.adaptive {
if e.ok {
out[pk] = e.after
}
}
return out
}