Skip to content

Commit 64f1fa9

Browse files
oten91claude
andauthored
feat: surface circuit-breaker state and per-domain cooldown counts as Prometheus metrics (#513)
## Summary Two metrics that were previously only observable via `/ready` introspection or direct Redis access are now first-class Prometheus gauges: - **`path_circuit_breaker_state{service_id, domain}`** — 1 if domain is currently locked out, 0 otherwise - **`path_endpoints_in_cooldown{domain, rpc_type, service_id}`** — count of endpoints currently in strike cooldown Both metrics fill real operational visibility gaps that came up during PR #512 work — operators could see broken-domain effects in error-rate graphs but couldn't directly answer "which domains are circuit-broken right now?" without Redis access. ## What this enables on dashboards Once Grafana picks these up: - **"Currently Broken Domains" table** — list-of-domains view with service_id + domain + state, sortable by service. - **"Broken Domains" stat tile** — single number for at-a-glance health (0 = healthy, >5 = widespread infra issues). - **"Cooldown" column** on the Supplier Quality table — operators can see "how many of my endpoints are in cooldown right now?" alongside RPS, success%, etc. These dashboard panels are not in this PR (kept to the metric-only diff for review clarity); they'll go in a small follow-up commit that updates `local/observability/dashboards/*.json` once this metric is deployed. ## Implementation notes ### `path_circuit_breaker_state` State transitions in `gateway/domain_circuit_breaker.go`: | Trigger | Gauge becomes | |---|---| | `MarkBroken` | 1 | | `ClearService` | 0 (for every cleared domain) | | `refreshLocal` finds expired entries | 0 (for each expired domain) | | `refreshFromRedis` finds expired entries | 0 (for each), and re-asserts 1 for currently-broken domains so fresh pods that lazily pick up Redis state stay consistent without going through MarkBroken locally | All gauge sets happen *outside* `cb.mu` to avoid taking the metrics lock under the cache mutex. There is a small inherent staleness window: a circuit-breaker entry whose TTL just expired remains at gauge=1 until the cache TTL elapses (5s default) and `refreshLocal` runs. That's bounded and fine for a dashboard. ### `path_endpoints_in_cooldown` New `LeaderboardDataProvider.GetCooldownCountData(ctx)` method, implemented on Shannon's `Protocol`. Walks active sessions, fetches each endpoint's reputation score, increments per-(domain, service_id, rpc_type) when `score.IsInCooldown()` returns true. Published every 10s alongside the existing leaderboard / mean score / supplier score metrics. Resets between snapshots so a domain dropping to zero cooldown'd endpoints shows zero (rather than sticking at its last value via Prometheus' 5-min staleness window). ## Test plan - [x] `go test ./...` — all green - [x] `go vet ./...` — clean - [x] New test: `TestDomainCircuitBreaker_MetricGaugeTransitions` covers MarkBroken → 1, ClearService → 0, TTL expiry + refresh → 0 - [ ] Canary deploy: verify `path_circuit_breaker_state` and `path_endpoints_in_cooldown` series appear in Prometheus - [ ] Trigger a circuit break (or use `/admin/circuit-breaker/clear/{serviceId}` to test the clear path) and confirm the gauge transitions - [ ] Verify staleness behavior: entry expires → gauge drops to 0 within ~5s ## Cardinality Both new metrics are bounded by `services × domains` — already low cardinality (~50-200 unique domains × ~80 services on mainnet). No risk of cardinality explosion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cda5557 commit 64f1fa9

13 files changed

Lines changed: 617 additions & 13 deletions

gateway/domain_circuit_breaker.go

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,40 @@ import (
1111
"github.com/pokt-network/poktroll/pkg/polylog"
1212
"github.com/redis/go-redis/v9"
1313

14+
"github.com/pokt-network/path/metrics"
1415
"github.com/pokt-network/path/protocol"
1516
)
1617

1718
const defaultMaxTTL = 30 * time.Minute
1819

20+
// classifyCircuitBreakReason maps the free-text reason string passed to
21+
// MarkBroken into a bounded category, suitable for use as a Prometheus label.
22+
// The raw reason contains response snippets, error messages, and status codes
23+
// (high cardinality) — for metrics we only need the prefix bucket.
24+
//
25+
// Keep in sync with the metrics.CircuitBreakReason* constants and with the
26+
// reason strings produced by MarkBroken call sites in
27+
// gateway/http_request_context_handle_request.go.
28+
func classifyCircuitBreakReason(reason string) string {
29+
// Match by prefix; reasons are typically formatted as "<category>: <details>"
30+
// or "<category>_<detail>: ...". Order matters for prefixes that share leading
31+
// substrings (parallel_retry vs retry).
32+
switch {
33+
case strings.HasPrefix(reason, "parallel_retry"):
34+
return metrics.CircuitBreakReasonParallelRetry
35+
case strings.HasPrefix(reason, "batch_transport"):
36+
return metrics.CircuitBreakReasonBatchTransport
37+
case strings.HasPrefix(reason, "batch_heuristic"):
38+
return metrics.CircuitBreakReasonBatchHeuristic
39+
case strings.HasPrefix(reason, "retry"):
40+
return metrics.CircuitBreakReasonRetry
41+
case strings.HasPrefix(reason, "heuristic"):
42+
return metrics.CircuitBreakReasonHeuristic
43+
default:
44+
return metrics.CircuitBreakReasonUnknown
45+
}
46+
}
47+
1948
// DomainCircuitBreaker tracks broken domains across pods via Redis.
2049
// When any pod discovers a domain is returning errors, it marks it broken
2150
// so all pods skip that domain on initial attempts for the TTL window.
@@ -112,6 +141,17 @@ func (cb *DomainCircuitBreaker) MarkBroken(ctx context.Context, serviceID, domai
112141
entry.domains[domain] = brokenDomainState{expiry: expiry, hitCount: hitCount, reason: reason}
113142
cb.mu.Unlock()
114143

144+
// Surface state via Prometheus gauge so dashboards can show "currently broken
145+
// domains" without needing Redis access. Idempotent — re-setting to 1 is fine.
146+
metrics.SetCircuitBreakerState(serviceID, domain, true)
147+
148+
// Record a "broken" event with reason category so dashboards can show rate
149+
// of breaks decomposed by cause (retry / batch_transport / batch_heuristic /
150+
// parallel_retry / heuristic / unknown). Counter increments on every call
151+
// — including hit-count escalations — which is the right semantic: each
152+
// MarkBroken is a discrete trigger event.
153+
metrics.RecordCircuitBreakerEvent(serviceID, domain, classifyCircuitBreakReason(reason), metrics.CircuitBreakerEventBroken)
154+
115155
// Always log circuit break events at error level for production visibility.
116156
// This is critical for diagnosing why domains get locked out.
117157
cb.logger.Error().
@@ -172,17 +212,24 @@ func filterExpiredDomains(domains map[string]brokenDomainState) map[string]bool
172212
// Used when Redis is not available (local-only mode).
173213
func (cb *DomainCircuitBreaker) refreshLocal(serviceID string) map[string]bool {
174214
cb.mu.Lock()
175-
defer cb.mu.Unlock()
176215

177216
entry, ok := cb.cache[serviceID]
178217
if !ok {
218+
cb.mu.Unlock()
179219
return nil
180220
}
181221

182-
// Remove expired entries
222+
// Remove expired entries; collect their names + reasons so we can drop the
223+
// gauge to 0 and record a "recovered" event outside the lock.
183224
now := time.Now()
225+
type expiredEntry struct {
226+
domain string
227+
reason string
228+
}
229+
var expired []expiredEntry
184230
for domain, state := range entry.domains {
185231
if !state.expiry.After(now) {
232+
expired = append(expired, expiredEntry{domain: domain, reason: state.reason})
186233
delete(entry.domains, domain)
187234
}
188235
}
@@ -192,6 +239,12 @@ func (cb *DomainCircuitBreaker) refreshLocal(serviceID string) map[string]bool {
192239
for d := range entry.domains {
193240
result[d] = true
194241
}
242+
cb.mu.Unlock()
243+
244+
for _, e := range expired {
245+
metrics.SetCircuitBreakerState(serviceID, e.domain, false)
246+
metrics.RecordCircuitBreakerEvent(serviceID, e.domain, classifyCircuitBreakReason(e.reason), metrics.CircuitBreakerEventRecovered)
247+
}
195248
return result
196249
}
197250

@@ -264,15 +317,24 @@ func (cb *DomainCircuitBreaker) refreshFromRedis(ctx context.Context, serviceID
264317
cb.redisClient.HDel(ctx, key, expiredFields...)
265318
}
266319

267-
// Merge with local cache (local entries might be newer than Redis)
320+
// Merge with local cache (local entries might be newer than Redis).
321+
// Capture pre-merge cache contents so we can drop the gauge to 0 for any
322+
// domain that was previously broken but is no longer present after merge.
268323
cb.mu.Lock()
324+
type expiredLocal struct {
325+
domain string
326+
reason string
327+
}
328+
var previouslyBroken []expiredLocal
269329
existingEntry, ok := cb.cache[serviceID]
270330
if ok {
271331
for domain, state := range existingEntry.domains {
272332
if state.expiry.After(now) {
273333
if redisState, exists := domains[domain]; !exists || state.expiry.After(redisState.expiry) {
274334
domains[domain] = state
275335
}
336+
} else {
337+
previouslyBroken = append(previouslyBroken, expiredLocal{domain: domain, reason: state.reason})
276338
}
277339
}
278340
}
@@ -282,6 +344,29 @@ func (cb *DomainCircuitBreaker) refreshFromRedis(ctx context.Context, serviceID
282344
}
283345
cb.mu.Unlock()
284346

347+
// Drop gauge for previously-broken-but-now-expired domains, plus the explicit
348+
// expiredFields list we already collected from Redis. Done outside the lock.
349+
// We don't have a reason for expiredFields entries (parsed Redis value gave us
350+
// one, but for compactness we don't carry it through here) — they get the
351+
// "unknown" reason bucket on recovery, which is acceptable.
352+
for _, d := range expiredFields {
353+
metrics.SetCircuitBreakerState(serviceID, d, false)
354+
metrics.RecordCircuitBreakerEvent(serviceID, d, metrics.CircuitBreakReasonUnknown, metrics.CircuitBreakerEventRecovered)
355+
}
356+
for _, e := range previouslyBroken {
357+
metrics.SetCircuitBreakerState(serviceID, e.domain, false)
358+
metrics.RecordCircuitBreakerEvent(serviceID, e.domain, classifyCircuitBreakReason(e.reason), metrics.CircuitBreakerEventRecovered)
359+
}
360+
// Re-assert gauge=1 for currently-broken domains. Idempotent and ensures the
361+
// metric is correct on a fresh pod that lazily picks up Redis state without
362+
// going through MarkBroken locally. Note: we deliberately do NOT record a
363+
// "broken" event here — these aren't new transitions, just a metric resync
364+
// for a state that was already counted at MarkBroken time on whichever pod
365+
// originally fired it.
366+
for d := range domains {
367+
metrics.SetCircuitBreakerState(serviceID, d, true)
368+
}
369+
285370
// Build result
286371
result := make(map[string]bool, len(domains))
287372
for d := range domains {
@@ -297,8 +382,17 @@ func (cb *DomainCircuitBreaker) ClearService(ctx context.Context, serviceID stri
297382
cb.mu.Lock()
298383
entry, ok := cb.cache[serviceID]
299384
count := 0
385+
type clearedEntry struct {
386+
domain string
387+
reason string
388+
}
389+
var cleared []clearedEntry
300390
if ok {
301391
count = len(entry.domains)
392+
cleared = make([]clearedEntry, 0, count)
393+
for d, state := range entry.domains {
394+
cleared = append(cleared, clearedEntry{domain: d, reason: state.reason})
395+
}
302396
delete(cb.cache, serviceID)
303397
}
304398
cb.mu.Unlock()
@@ -307,6 +401,15 @@ func (cb *DomainCircuitBreaker) ClearService(ctx context.Context, serviceID stri
307401
cb.redisClient.Del(ctx, cb.keyPrefix+serviceID)
308402
}
309403

404+
// Drop the gauge AND record a recovered event for every cleared domain. Each
405+
// admin-clear represents an explicit "operator forced these domains back to
406+
// healthy" action, distinct from natural TTL-driven recovery — the recovery
407+
// counter still captures it because operationally it's the same transition.
408+
for _, c := range cleared {
409+
metrics.SetCircuitBreakerState(serviceID, c.domain, false)
410+
metrics.RecordCircuitBreakerEvent(serviceID, c.domain, classifyCircuitBreakReason(c.reason), metrics.CircuitBreakerEventRecovered)
411+
}
412+
310413
cb.logger.Info().
311414
Str("service_id", serviceID).
312415
Int("cleared_domains", count).

gateway/domain_circuit_breaker_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,22 @@ import (
88

99
"github.com/pokt-network/poktroll/pkg/polylog"
1010
"github.com/pokt-network/poktroll/pkg/polylog/polyzero"
11+
"github.com/prometheus/client_golang/prometheus/testutil"
1112

13+
"github.com/pokt-network/path/metrics"
1214
"github.com/pokt-network/path/protocol"
1315
)
1416

1517
func testCircuitBreakerLogger() polylog.Logger {
1618
return polyzero.NewLogger(polyzero.WithOutput(os.Stderr))
1719
}
1820

21+
// readCircuitBreakerGauge returns the current value of the per-(serviceID, domain)
22+
// circuit-breaker state gauge. Used by metric-transition tests.
23+
func readCircuitBreakerGauge(serviceID, domain string) float64 {
24+
return testutil.ToFloat64(metrics.DomainCircuitBreakerState.WithLabelValues(serviceID, domain))
25+
}
26+
1927
func TestDomainCircuitBreaker_MarkAndGet(t *testing.T) {
2028
cb := NewDomainCircuitBreaker(nil, testCircuitBreakerLogger())
2129
ctx := context.Background()
@@ -427,3 +435,116 @@ func TestParseRedisValue_InvalidFormat(t *testing.T) {
427435
t.Fatal("expected parse to fail for empty string")
428436
}
429437
}
438+
439+
// TestClassifyCircuitBreakReason verifies that the free-text reason strings
440+
// passed to MarkBroken get bucketed into the correct bounded category for use
441+
// as a Prometheus label. Order-sensitive prefix matches must be stable —
442+
// "parallel_retry" must NOT match the "retry" branch.
443+
func TestClassifyCircuitBreakReason(t *testing.T) {
444+
cases := []struct {
445+
raw string
446+
expected string
447+
}{
448+
{"retry: heuristic_html | status=502 | response=<html>...", metrics.CircuitBreakReasonRetry},
449+
{"batch_transport_error: connection refused", metrics.CircuitBreakReasonBatchTransport},
450+
{"batch_heuristic: empty_array | status=200 | response=[]", metrics.CircuitBreakReasonBatchHeuristic},
451+
{"parallel_retry: timeout after 5s", metrics.CircuitBreakReasonParallelRetry},
452+
{"heuristic: structured error response", metrics.CircuitBreakReasonHeuristic},
453+
{"completely unknown reason format", metrics.CircuitBreakReasonUnknown},
454+
{"", metrics.CircuitBreakReasonUnknown},
455+
}
456+
for _, c := range cases {
457+
got := classifyCircuitBreakReason(c.raw)
458+
if got != c.expected {
459+
t.Errorf("classifyCircuitBreakReason(%q) = %q, want %q", c.raw, got, c.expected)
460+
}
461+
}
462+
}
463+
464+
// TestCircuitBreakerEventsCounter verifies that broken/recovered transitions
465+
// increment the events counter with the correct reason_category bucket.
466+
func TestCircuitBreakerEventsCounter(t *testing.T) {
467+
cb := NewDomainCircuitBreaker(nil, testCircuitBreakerLogger())
468+
cb.defaultTTL = 50 * time.Millisecond
469+
ctx := context.Background()
470+
const serviceID = "events-test-svc"
471+
const domain = "events-test.example.com"
472+
473+
// Capture pre-state of the counter for the expected (service, domain, retry, broken)
474+
// label combination so the test is hermetic against other tests' increments.
475+
preBroken := testutil.ToFloat64(metrics.DomainCircuitBreakerEventsTotal.WithLabelValues(
476+
serviceID, domain, metrics.CircuitBreakReasonRetry, metrics.CircuitBreakerEventBroken,
477+
))
478+
preRecovered := testutil.ToFloat64(metrics.DomainCircuitBreakerEventsTotal.WithLabelValues(
479+
serviceID, domain, metrics.CircuitBreakReasonRetry, metrics.CircuitBreakerEventRecovered,
480+
))
481+
482+
// MarkBroken with a "retry: ..." reason should record one broken event with
483+
// reason_category="retry"
484+
cb.MarkBroken(ctx, serviceID, domain, "retry: heuristic_html | status=502 | response=oops")
485+
postBroken := testutil.ToFloat64(metrics.DomainCircuitBreakerEventsTotal.WithLabelValues(
486+
serviceID, domain, metrics.CircuitBreakReasonRetry, metrics.CircuitBreakerEventBroken,
487+
))
488+
if postBroken-preBroken != 1 {
489+
t.Fatalf("expected broken counter to increment by 1, got delta=%v", postBroken-preBroken)
490+
}
491+
492+
// ClearService should record one recovered event with the same reason_category
493+
cb.ClearService(ctx, serviceID)
494+
postRecovered := testutil.ToFloat64(metrics.DomainCircuitBreakerEventsTotal.WithLabelValues(
495+
serviceID, domain, metrics.CircuitBreakReasonRetry, metrics.CircuitBreakerEventRecovered,
496+
))
497+
if postRecovered-preRecovered != 1 {
498+
t.Fatalf("expected recovered counter to increment by 1, got delta=%v", postRecovered-preRecovered)
499+
}
500+
}
501+
502+
// TestDomainCircuitBreaker_MetricGaugeTransitions verifies that the circuit-breaker
503+
// state gauge moves between 0 and 1 correctly: set to 1 on MarkBroken, dropped to 0
504+
// on ClearService, and dropped to 0 by refreshLocal once a TTL expires. We test
505+
// directly against the Prometheus client metric rather than scraping /metrics so the
506+
// test stays hermetic.
507+
func TestDomainCircuitBreaker_MetricGaugeTransitions(t *testing.T) {
508+
cb := NewDomainCircuitBreaker(nil, testCircuitBreakerLogger())
509+
cb.defaultTTL = 50 * time.Millisecond
510+
ctx := context.Background()
511+
const serviceID = "metric-test-svc"
512+
const domain = "metric-test-domain.example.com"
513+
514+
// Initial state: gauge should be 0 (or absent — the Set on first MarkBroken creates it).
515+
if v := readCircuitBreakerGauge(serviceID, domain); v != 0 {
516+
t.Fatalf("expected gauge=0 initially, got %v", v)
517+
}
518+
519+
// MarkBroken → gauge should flip to 1
520+
cb.MarkBroken(ctx, serviceID, domain, "test_reason")
521+
if v := readCircuitBreakerGauge(serviceID, domain); v != 1 {
522+
t.Fatalf("expected gauge=1 after MarkBroken, got %v", v)
523+
}
524+
525+
// ClearService → gauge drops to 0 for cleared domains
526+
cb.ClearService(ctx, serviceID)
527+
if v := readCircuitBreakerGauge(serviceID, domain); v != 0 {
528+
t.Fatalf("expected gauge=0 after ClearService, got %v", v)
529+
}
530+
531+
// MarkBroken again, then wait for TTL expiry, then refresh — gauge should drop to 0
532+
cb.MarkBroken(ctx, serviceID, domain, "test_reason_2")
533+
if v := readCircuitBreakerGauge(serviceID, domain); v != 1 {
534+
t.Fatalf("expected gauge=1 after second MarkBroken, got %v", v)
535+
}
536+
537+
time.Sleep(60 * time.Millisecond)
538+
// Force the cache entry to look stale so GetBrokenDomains takes the refresh path.
539+
// In production, the same effect happens automatically once cacheTTL elapses
540+
// (default 5s) after the entry was last refreshed.
541+
cb.mu.Lock()
542+
if entry, ok := cb.cache[serviceID]; ok {
543+
entry.refreshAt = time.Now().Add(-time.Second)
544+
}
545+
cb.mu.Unlock()
546+
cb.GetBrokenDomains(ctx, serviceID)
547+
if v := readCircuitBreakerGauge(serviceID, domain); v != 0 {
548+
t.Fatalf("expected gauge=0 after TTL expiry + refresh, got %v", v)
549+
}
550+
}

gateway/http_request_context_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func TestIsDeceptiveResponsePattern(t *testing.T) {
112112
{"jsonrpc_invalid_empty_array", true},
113113
{"jsonrpc_empty_object_result", true},
114114
{"rest_protocol_mismatch", true},
115+
{"rest_protocol_mismatch_error", false},
115116
{"cometbft_invalid_empty_array", true},
116117
{"jsonrpc_fabricated_parse_error", true},
117118
{"jsonrpc_wrapped_service_error", true},

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ require (
192192
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
193193
github.com/kr/pretty v0.3.1 // indirect
194194
github.com/kr/text v0.2.0 // indirect
195+
github.com/kylelemons/godebug v1.1.0 // indirect
195196
github.com/lib/pq v1.10.9 // indirect
196197
github.com/linxGnu/grocksdb v1.8.14 // indirect
197198
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect

metrics/leaderboard.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ type SupplierScoreEntry struct {
4040
Score float64
4141
}
4242

43+
// CooldownCountEntry represents per-domain count of endpoints currently in strike
44+
// cooldown for a given service / rpc_type.
45+
type CooldownCountEntry struct {
46+
Domain string
47+
RPCType string
48+
ServiceID string
49+
Count int
50+
}
51+
4352
// LeaderboardDataProvider is an interface for getting endpoint distribution data
4453
type LeaderboardDataProvider interface {
4554
// GetEndpointLeaderboardData returns all endpoint entries grouped by the required dimensions
@@ -49,6 +58,10 @@ type LeaderboardDataProvider interface {
4958
// GetSupplierScoreData returns per-(supplier, service_id) reputation scores.
5059
// Optional: implementations may return nil if per-supplier scoring is not supported.
5160
GetSupplierScoreData(ctx context.Context) ([]SupplierScoreEntry, error)
61+
// GetCooldownCountData returns per-(domain, service_id, rpc_type) counts of
62+
// endpoints currently in strike cooldown. Optional: implementations may return
63+
// nil if cooldown tracking is not supported.
64+
GetCooldownCountData(ctx context.Context) ([]CooldownCountEntry, error)
5265
}
5366

5467
// LeaderboardPublisher publishes endpoint leaderboard metrics every 10 seconds
@@ -183,6 +196,24 @@ func (lp *LeaderboardPublisher) publishLeaderboard(ctx context.Context) {
183196
}
184197
lp.logger.Debug().Int("entries", len(supplierScores)).Msg("Published supplier scores")
185198
}
199+
200+
// Publish per-domain endpoint cooldown counts. Reset between snapshots so a
201+
// domain that drops to zero cooldown'd endpoints actually shows zero (instead
202+
// of sticking at its last value via Prometheus' 5-min staleness window).
203+
cooldownCounts, err := lp.provider.GetCooldownCountData(ctx)
204+
if err != nil {
205+
lp.logger.Warn().Err(err).Msg("Failed to get cooldown count data")
206+
return
207+
}
208+
209+
EndpointsInCooldown.Reset()
210+
211+
if len(cooldownCounts) > 0 {
212+
for _, entry := range cooldownCounts {
213+
EndpointsInCooldown.WithLabelValues(entry.Domain, entry.RPCType, entry.ServiceID).Set(float64(entry.Count))
214+
}
215+
lp.logger.Debug().Int("entries", len(cooldownCounts)).Msg("Published cooldown counts")
216+
}
186217
}
187218

188219
// PublishOnce can be called to manually trigger a leaderboard publish (for testing)

0 commit comments

Comments
 (0)