diff --git a/README.md b/README.md index 7b5d2ec..bb5fa7e 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` + counts the prefixes announced or withdrawn in the last full minute of the + clock. - `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..af3ba68 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 whole number of + prefixes announced or withdrawn in the last full 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..5e51088 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 // announced and withdrawn prefixes // 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 prefixes announced or withdrawn in +// the last full minute of the clock. +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..a1a0cc0 --- /dev/null +++ b/internal/metrics/minutecounter.go @@ -0,0 +1,59 @@ +package metrics + +import ( + "sync" + "time" +) + +// minuteCounter counts events in each minute of the clock 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..10f4c76 --- /dev/null +++ b/internal/metrics/minutecounter_internal_test.go @@ -0,0 +1,80 @@ +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") +} + +// 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