From dcf2e8aa3d44c080d9cfb15ed47f8bde204f453b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 26 Oct 2026 22:51:45 +0000 Subject: [PATCH] Show route churn as a whole count per minute (closes #160) The status page showed route churn per second as a one-minute moving average, so a quiet feed gave a fraction such as 0.1. The average was correct. The metrics tracker now also counts prefix announcements and withdrawals, from all peers, in each clock minute. /api/v1/stats reports the count for the last full minute as route_churn_per_min in stream, in place of route_churn_per_sec. The status page shows it as "Route Churn/min". The status and home pages round messages and updates per second to whole numbers. Model: opus-5-5 --- README.md | 4 +- TODO.md | 5 ++ internal/metrics/metrics.go | 15 ++-- internal/metrics/minutecounter.go | 59 +++++++++++++ .../metrics/minutecounter_internal_test.go | 86 +++++++++++++++++++ internal/server/handlers.go | 10 +-- internal/server/handlers_internal_test.go | 45 ++++++++++ internal/templates/index.html | 2 +- internal/templates/status.html | 14 +-- 9 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 internal/metrics/minutecounter.go create mode 100644 internal/metrics/minutecounter_internal_test.go diff --git a/README.md b/README.md index 7b5d2ec..b459378 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ Every route answers `HEAD` as it answers `GET`, without the body. ### API v1 - `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage - collector, stream and handler queue statistics + collector, stream and handler queue statistics. `stream.route_churn_per_min` + is the number of prefix announcements and withdrawals, from all peers, in the + last full clock minute. - `GET /api/v1/ip/{ip}` - Looks up each address: its most specific live route (`netblock`), that route's origin AS with its WHOIS details, and the address's PTR names. `{ip}` is an IP address, a host name, or a comma-separated list of diff --git a/TODO.md b/TODO.md index fa26f64..df0f160 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,11 @@ tested. # Completed Steps +- 2026-10-26: the status page shows "Route Churn/min", the number of prefix + announcements and withdrawals, from all peers, in the last full clock minute. + `/api/v1/stats` reports it as `route_churn_per_min` in `stream`, which + replaces the moving average `route_churn_per_sec`. The messages and updates + per second on `/` and `/status` are whole numbers (closes #160) - 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait 1.5 times as long, up to one minute instead of five. Its queries to RIPE, APNIC and AFRINIC leave out person and role objects, which RIPE counts against diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 657d042..e188bcf 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -34,7 +34,7 @@ type Tracker struct { // Announcement/withdrawal metrics announcementCounter metrics.Counter withdrawalCounter metrics.Counter - churnRate metrics.Meter // combined announcements + withdrawals per second + churnPerMinute minuteCounter // prefix announcements and withdrawals // BGP peer tracking bgpPeerCount atomic.Int32 @@ -56,7 +56,7 @@ func New() *Tracker { ipv6UpdateRate: metrics.NewMeter(), announcementCounter: metrics.NewCounter(), withdrawalCounter: metrics.NewCounter(), - churnRate: metrics.NewMeter(), + churnPerMinute: minuteCounter{now: time.Now}, } } @@ -156,13 +156,13 @@ func (t *Tracker) RecordIPv6Update() { // RecordAnnouncement records a route announcement func (t *Tracker) RecordAnnouncement() { t.announcementCounter.Inc(1) - t.churnRate.Mark(1) + t.churnPerMinute.add() } // RecordWithdrawal records a route withdrawal func (t *Tracker) RecordWithdrawal() { t.withdrawalCounter.Inc(1) - t.churnRate.Mark(1) + t.churnPerMinute.add() } // SetBGPPeerCount updates the current BGP peer count @@ -198,9 +198,10 @@ func (t *Tracker) GetWithdrawalCount() uint64 { return uint64(count) } -// GetChurnRate returns the route churn rate per second -func (t *Tracker) GetChurnRate() float64 { - return t.churnRate.Rate1() +// GetChurnPerMinute returns the number of prefix announcements and +// withdrawals, from all peers, in the last full clock minute. +func (t *Tracker) GetChurnPerMinute() int { + return t.churnPerMinute.lastFullMinute() } // GetRouteMetrics returns current route update metrics diff --git a/internal/metrics/minutecounter.go b/internal/metrics/minutecounter.go new file mode 100644 index 0000000..88aeb7b --- /dev/null +++ b/internal/metrics/minutecounter.go @@ -0,0 +1,59 @@ +package metrics + +import ( + "sync" + "time" +) + +// minuteCounter counts events in each clock minute and reports the count of +// the last full minute. It is safe for concurrent use. +type minuteCounter struct { + mu sync.Mutex + now func() time.Time + + // minute is the start of the minute that count belongs to. + minute time.Time + count int + // lastMinuteCount is the count of the minute just before minute. + lastMinuteCount int +} + +// add counts one event in the current minute. +func (c *minuteCounter) add() { + c.mu.Lock() + defer c.mu.Unlock() + + c.moveToCurrentMinute() + c.count++ +} + +// lastFullMinute returns the number of events in the minute that ended most +// recently. +func (c *minuteCounter) lastFullMinute() int { + c.mu.Lock() + defer c.mu.Unlock() + + c.moveToCurrentMinute() + + return c.lastMinuteCount +} + +// moveToCurrentMinute starts a new count when the clock has moved into +// another minute. The count it ends becomes the last full minute's count when +// its minute is the one just before the current minute. Otherwise the last +// full minute had no events. The caller holds mu. +func (c *minuteCounter) moveToCurrentMinute() { + minute := c.now().Truncate(time.Minute) + if minute.Equal(c.minute) { + return + } + + if minute.Sub(c.minute) == time.Minute { + c.lastMinuteCount = c.count + } else { + c.lastMinuteCount = 0 + } + + c.minute = minute + c.count = 0 +} diff --git a/internal/metrics/minutecounter_internal_test.go b/internal/metrics/minutecounter_internal_test.go new file mode 100644 index 0000000..02f0686 --- /dev/null +++ b/internal/metrics/minutecounter_internal_test.go @@ -0,0 +1,86 @@ +package metrics + +import ( + "sync" + "testing" + "time" +) + +// TestChurnPerMinuteCountsLastFullMinute announces and withdraws prefixes on +// an injected clock and expects each minute's whole count once that minute +// has ended, and 0 for a minute with none. +func TestChurnPerMinuteCountsLastFullMinute(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 10, 26, 12, 0, 30, 0, time.UTC) + tracker := New() + tracker.churnPerMinute.now = func() time.Time { return now } + + expectChurn := func(want int, why string) { + t.Helper() + + got := tracker.GetChurnPerMinute() + if got != want { + t.Fatalf("at %s churn = %d, want %d: %s", + now.Format(time.TimeOnly), got, want, why) + } + } + + const firstMinuteChurn = 2 + + tracker.RecordAnnouncement() + tracker.RecordWithdrawal() + expectChurn(0, "12:00 has not ended") + + now = time.Date(2026, 10, 26, 12, 1, 59, 0, time.UTC) + + tracker.RecordAnnouncement() + expectChurn(firstMinuteChurn, "12:00 had one announcement and one withdrawal") + + now = time.Date(2026, 10, 26, 12, 2, 0, 0, time.UTC) + + expectChurn(1, "12:01 had one announcement") + + now = time.Date(2026, 10, 26, 12, 4, 0, 0, time.UTC) + + expectChurn(0, "12:03 had none") + + tracker.RecordWithdrawal() + + now = time.Date(2026, 10, 26, 12, 6, 0, 0, time.UTC) + + expectChurn(0, "12:05 had none, though 12:04 had one withdrawal") +} + +// TestChurnPerMinuteCountsConcurrentRecords announces prefixes from several +// goroutines at once and expects every one counted. +func TestChurnPerMinuteCountsConcurrentRecords(t *testing.T) { + t.Parallel() + + const ( + goroutines = 8 + recordsPerGoroutine = 1000 + ) + + now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC) + tracker := New() + tracker.churnPerMinute.now = func() time.Time { return now } + + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + for range recordsPerGoroutine { + tracker.RecordAnnouncement() + } + }) + } + + wg.Wait() + + now = now.Add(time.Minute) + + want := goroutines * recordsPerGoroutine + if got := tracker.GetChurnPerMinute(); got != want { + t.Fatalf("churn = %d, want %d", got, want) + } +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index c33963d..156a47a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -441,10 +441,10 @@ type GCStats struct { // //nolint:tagliatelle // field names are the existing JSON API type StreamStats struct { - Announcements uint64 `json:"announcements"` - Withdrawals uint64 `json:"withdrawals"` - RouteChurnPerSec float64 `json:"route_churn_per_sec"` - BGPPeerCount int `json:"bgp_peer_count"` + Announcements uint64 `json:"announcements"` + Withdrawals uint64 `json:"withdrawals"` + RouteChurnPerMin int `json:"route_churn_per_min"` + BGPPeerCount int `json:"bgp_peer_count"` } // StatsResponse represents the API statistics response @@ -545,7 +545,7 @@ func (s *Server) streamStats() StreamStats { return StreamStats{ Announcements: metricsTracker.GetAnnouncementCount(), Withdrawals: metricsTracker.GetWithdrawalCount(), - RouteChurnPerSec: metricsTracker.GetChurnRate(), + RouteChurnPerMin: metricsTracker.GetChurnPerMinute(), BGPPeerCount: metricsTracker.GetBGPPeerCount(), } } diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index 9736468..f4a87f3 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -195,6 +195,51 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) { } } +// TestStatsAnswerCarriesChurnPerMinute checks that the stream object of the +// /api/v1/stats answer holds route_churn_per_min as a whole number, and no +// longer route_churn_per_sec, the moving average it replaces +// (https://git.eeqj.de/sneak/routewatch/issues/160). +func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) { + t.Parallel() + + s := newServerWithTestRoutes(t) + + rec := httptest.NewRecorder() + s.handleStats()(rec, + httptest.NewRequestWithContext(t.Context(), http.MethodGet, statsPath, nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body) + } + + var body struct { + Data struct { + Stream map[string]json.RawMessage `json:"stream"` + } `json:"data"` + } + + err := json.Unmarshal(rec.Body.Bytes(), &body) + if err != nil { + t.Fatalf("decoding the answer: %v", err) + } + + churn, ok := body.Data.Stream["route_churn_per_min"] + if !ok { + t.Fatalf("stream has no route_churn_per_min: %s", rec.Body) + } + + var count int + + err = json.Unmarshal(churn, &count) + if err != nil { + t.Errorf("route_churn_per_min = %s, want a whole number: %v", churn, err) + } + + if _, ok := body.Data.Stream["route_churn_per_sec"]; ok { + t.Errorf("stream still has route_churn_per_sec: %s", rec.Body) + } +} + // newServerWithTestRoutes returns a server on a new database that holds a // route for testPrefixV4 and one for testPrefixV6. func newServerWithTestRoutes(t *testing.T) *Server { diff --git a/internal/templates/index.html b/internal/templates/index.html index 771d65c..bd8d5fe 100644 --- a/internal/templates/index.html +++ b/internal/templates/index.html @@ -400,7 +400,7 @@ } const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec; - document.getElementById('stat-updates').textContent = totalUpdates.toFixed(1); + document.getElementById('stat-updates').textContent = Math.round(totalUpdates).toLocaleString(); }) .catch(() => { document.getElementById('stat-status').textContent = 'Error'; diff --git a/internal/templates/status.html b/internal/templates/status.html index ee622ca..076de68 100644 --- a/internal/templates/status.html +++ b/internal/templates/status.html @@ -231,8 +231,8 @@ -
- Route Churn/sec - - + Route Churn/min + -
Total Data @@ -511,7 +511,7 @@ document.getElementById('messages_per_sec').textContent = '-'; document.getElementById('announcements').textContent = '-'; document.getElementById('withdrawals').textContent = '-'; - document.getElementById('route_churn_per_sec').textContent = '-'; + document.getElementById('route_churn_per_min').textContent = '-'; document.getElementById('total_wire_bytes').textContent = '-'; document.getElementById('wire_mbits_per_sec').textContent = '-'; document.getElementById('gc_num').textContent = '-'; @@ -578,7 +578,7 @@ document.getElementById('connection_duration').textContent = data.connection_duration; document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count); document.getElementById('total_messages').textContent = formatNumber(data.total_messages); - document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1); + document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec)); document.getElementById('total_wire_bytes').textContent = formatBytes(data.total_wire_bytes); document.getElementById('wire_mbits_per_sec').textContent = data.wire_mbits_per_sec.toFixed(2) + ' Mbps'; document.getElementById('asns').textContent = formatNumber(data.asns); @@ -591,8 +591,8 @@ document.getElementById('live_routes').textContent = formatNumber(data.live_routes); document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes); document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes); - document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1); - document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1); + document.getElementById('ipv4_updates_per_sec').textContent = formatNumber(Math.round(data.ipv4_updates_per_sec)); + document.getElementById('ipv6_updates_per_sec').textContent = formatNumber(Math.round(data.ipv6_updates_per_sec)); document.getElementById('oldest_route').textContent = formatRelativeTime(data.oldest_route); document.getElementById('newest_route').textContent = formatRelativeTime(data.newest_route); @@ -601,7 +601,7 @@ document.getElementById('bgp_peer_count').textContent = formatNumber(data.stream.bgp_peer_count); document.getElementById('announcements').textContent = formatNumber(data.stream.announcements); document.getElementById('withdrawals').textContent = formatNumber(data.stream.withdrawals); - document.getElementById('route_churn_per_sec').textContent = data.stream.route_churn_per_sec.toFixed(1); + document.getElementById('route_churn_per_min').textContent = formatNumber(data.stream.route_churn_per_min); } // Update GC stats -- 2.54.0