Compare commits
1
Commits
next
...
f021cbd7c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f021cbd7c6 |
@@ -81,7 +81,9 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
|||||||
### API v1
|
### API v1
|
||||||
|
|
||||||
- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage
|
- `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
|
- `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
|
(`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
|
PTR names. `{ip}` is an IP address, a host name, or a comma-separated list of
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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,
|
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
|
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ type Tracker struct {
|
|||||||
// Announcement/withdrawal metrics
|
// Announcement/withdrawal metrics
|
||||||
announcementCounter metrics.Counter
|
announcementCounter metrics.Counter
|
||||||
withdrawalCounter metrics.Counter
|
withdrawalCounter metrics.Counter
|
||||||
churnRate metrics.Meter // combined announcements + withdrawals per second
|
churnPerMinute minuteCounter // announced and withdrawn prefixes
|
||||||
|
|
||||||
// BGP peer tracking
|
// BGP peer tracking
|
||||||
bgpPeerCount atomic.Int32
|
bgpPeerCount atomic.Int32
|
||||||
@@ -56,7 +56,7 @@ func New() *Tracker {
|
|||||||
ipv6UpdateRate: metrics.NewMeter(),
|
ipv6UpdateRate: metrics.NewMeter(),
|
||||||
announcementCounter: metrics.NewCounter(),
|
announcementCounter: metrics.NewCounter(),
|
||||||
withdrawalCounter: 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
|
// RecordAnnouncement records a route announcement
|
||||||
func (t *Tracker) RecordAnnouncement() {
|
func (t *Tracker) RecordAnnouncement() {
|
||||||
t.announcementCounter.Inc(1)
|
t.announcementCounter.Inc(1)
|
||||||
t.churnRate.Mark(1)
|
t.churnPerMinute.add()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordWithdrawal records a route withdrawal
|
// RecordWithdrawal records a route withdrawal
|
||||||
func (t *Tracker) RecordWithdrawal() {
|
func (t *Tracker) RecordWithdrawal() {
|
||||||
t.withdrawalCounter.Inc(1)
|
t.withdrawalCounter.Inc(1)
|
||||||
t.churnRate.Mark(1)
|
t.churnPerMinute.add()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBGPPeerCount updates the current BGP peer count
|
// SetBGPPeerCount updates the current BGP peer count
|
||||||
@@ -198,9 +198,10 @@ func (t *Tracker) GetWithdrawalCount() uint64 {
|
|||||||
return uint64(count)
|
return uint64(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChurnRate returns the route churn rate per second
|
// GetChurnPerMinute returns the number of prefixes announced or withdrawn in
|
||||||
func (t *Tracker) GetChurnRate() float64 {
|
// the last full minute of the clock.
|
||||||
return t.churnRate.Rate1()
|
func (t *Tracker) GetChurnPerMinute() int {
|
||||||
|
return t.churnPerMinute.lastFullMinute()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRouteMetrics returns current route update metrics
|
// GetRouteMetrics returns current route update metrics
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -441,10 +441,10 @@ type GCStats struct {
|
|||||||
//
|
//
|
||||||
//nolint:tagliatelle // field names are the existing JSON API
|
//nolint:tagliatelle // field names are the existing JSON API
|
||||||
type StreamStats struct {
|
type StreamStats struct {
|
||||||
Announcements uint64 `json:"announcements"`
|
Announcements uint64 `json:"announcements"`
|
||||||
Withdrawals uint64 `json:"withdrawals"`
|
Withdrawals uint64 `json:"withdrawals"`
|
||||||
RouteChurnPerSec float64 `json:"route_churn_per_sec"`
|
RouteChurnPerMin int `json:"route_churn_per_min"`
|
||||||
BGPPeerCount int `json:"bgp_peer_count"`
|
BGPPeerCount int `json:"bgp_peer_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsResponse represents the API statistics response
|
// StatsResponse represents the API statistics response
|
||||||
@@ -545,7 +545,7 @@ func (s *Server) streamStats() StreamStats {
|
|||||||
return StreamStats{
|
return StreamStats{
|
||||||
Announcements: metricsTracker.GetAnnouncementCount(),
|
Announcements: metricsTracker.GetAnnouncementCount(),
|
||||||
Withdrawals: metricsTracker.GetWithdrawalCount(),
|
Withdrawals: metricsTracker.GetWithdrawalCount(),
|
||||||
RouteChurnPerSec: metricsTracker.GetChurnRate(),
|
RouteChurnPerMin: metricsTracker.GetChurnPerMinute(),
|
||||||
BGPPeerCount: metricsTracker.GetBGPPeerCount(),
|
BGPPeerCount: metricsTracker.GetBGPPeerCount(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// newServerWithTestRoutes returns a server on a new database that holds a
|
||||||
// route for testPrefixV4 and one for testPrefixV6.
|
// route for testPrefixV4 and one for testPrefixV6.
|
||||||
func newServerWithTestRoutes(t *testing.T) *Server {
|
func newServerWithTestRoutes(t *testing.T) *Server {
|
||||||
|
|||||||
@@ -400,7 +400,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
|
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(() => {
|
.catch(() => {
|
||||||
document.getElementById('stat-status').textContent = 'Error';
|
document.getElementById('stat-status').textContent = 'Error';
|
||||||
|
|||||||
@@ -231,8 +231,8 @@
|
|||||||
<span class="metric-value" id="withdrawals">-</span>
|
<span class="metric-value" id="withdrawals">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Route Churn/sec</span>
|
<span class="metric-label">Route Churn/min</span>
|
||||||
<span class="metric-value" id="route_churn_per_sec">-</span>
|
<span class="metric-value" id="route_churn_per_min">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<span class="metric-label">Total Data</span>
|
<span class="metric-label">Total Data</span>
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
document.getElementById('messages_per_sec').textContent = '-';
|
document.getElementById('messages_per_sec').textContent = '-';
|
||||||
document.getElementById('announcements').textContent = '-';
|
document.getElementById('announcements').textContent = '-';
|
||||||
document.getElementById('withdrawals').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('total_wire_bytes').textContent = '-';
|
||||||
document.getElementById('wire_mbits_per_sec').textContent = '-';
|
document.getElementById('wire_mbits_per_sec').textContent = '-';
|
||||||
document.getElementById('gc_num').textContent = '-';
|
document.getElementById('gc_num').textContent = '-';
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
document.getElementById('connection_duration').textContent = data.connection_duration;
|
document.getElementById('connection_duration').textContent = data.connection_duration;
|
||||||
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
|
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
|
||||||
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
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('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('wire_mbits_per_sec').textContent = data.wire_mbits_per_sec.toFixed(2) + ' Mbps';
|
||||||
document.getElementById('asns').textContent = formatNumber(data.asns);
|
document.getElementById('asns').textContent = formatNumber(data.asns);
|
||||||
@@ -591,8 +591,8 @@
|
|||||||
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||||
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
|
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
|
||||||
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_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('ipv4_updates_per_sec').textContent = formatNumber(Math.round(data.ipv4_updates_per_sec));
|
||||||
document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1);
|
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('oldest_route').textContent = formatRelativeTime(data.oldest_route);
|
||||||
document.getElementById('newest_route').textContent = formatRelativeTime(data.newest_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('bgp_peer_count').textContent = formatNumber(data.stream.bgp_peer_count);
|
||||||
document.getElementById('announcements').textContent = formatNumber(data.stream.announcements);
|
document.getElementById('announcements').textContent = formatNumber(data.stream.announcements);
|
||||||
document.getElementById('withdrawals').textContent = formatNumber(data.stream.withdrawals);
|
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
|
// Update GC stats
|
||||||
|
|||||||
Reference in New Issue
Block a user