Architecture and component reference for the cost-and-storm-protection features: prompt caching, severity-based routing, group-cooldown, storm-mode, circuit-breaker, and pipeline failure-phase differentiation.
This document covers architecture, components, configuration, observability,
failure modes, and developer notes. For the day-to-day operator workflow
(when to enable each knob, rollout sequence, recommended PromQL), see
cost-and-storm-protection.md.
- What it solves
- At a glance
- Architecture
- Components
- Configuration
- Metrics
- Failure-mode runbook
- Component interaction matrix
- Developer notes
- Limitations
Two pressure axes against the analyzers:
- Cost per analysis — high-severity alerts can use Opus; lower-severity alerts can be routed to Haiku or even skip the tool loop entirely. Prompt caching reduces token usage on the cached prefix.
- Load and Anthropic-API failure modes — distinct fingerprints in a burst can each trigger a full analysis (50 crashing pods × 10 tool-rounds is expensive); Anthropic outages can cascade if every retry tries the broken API again.
The features in this document address both axes: caching and severity routing optimize the per-analysis cost; group-cooldown deduplicates alerts that share a coarser key; storm-mode forces cheaper static analysis under sustained burst load; the circuit-breaker halts API calls during sustained failures and prevents Alertmanager-retries from amplifying the problem (the Storm-Verstärker-Bug fix).
Most features are opt-in and default disabled. Caching and the four token-cost metrics are always on.
| Feature | Knob | Default | Effect when enabled |
|---|---|---|---|
| Group-cooldown | GROUP_COOLDOWN_SECONDS |
0 (off) |
Coarser dedup: alerts sharing alertname+namespace (k8s) or host+service (checkmk) collapse to one analysis per TTL window |
| Storm-mode | STORM_MODE_THRESHOLD |
0 (off) |
At >N alerts/5min, all severities are forced to rounds=0 (no tool loop) and notifications are aggregated |
| Circuit-breaker | CIRCUIT_BREAKER_THRESHOLD |
0 (off) |
At N consecutive analysis failures, the breaker opens and rejects calls for OPEN_SECONDS; one half-open probe per cycle |
| Pipeline phase-tracking | always on | n/a | Cooldowns are kept on ErrCircuitOpen and post-API failures (Verstärker-Bug fix) |
┌────────────┐ 1. POST /webhook
│ Alertmgr / │───────────────────────────────────┐
│ CheckMK │ │
└────────────┘ ▼
┌─────────────────────┐
│ handler │
│ │
│ auth check │
│ parse payload │
│ │
│ ┌───────────────┐ │
│ │ CheckAndSet │ │ fingerprint + group
│ │ WithGroup │ │ cooldowns (atomic)
│ └─────┬─────────┘ │
│ │ pass │
│ ▼ │
│ storm.Record() │
│ │ │
│ ▼ │
│ server.Enqueue ───┼──────► work queue
└─────────────────────┘ (5 workers)
│
▼
┌─────────────────────────┐
│ pipeline.ProcessAlert │
│ │
│ phase = phasePreAPI │
│ GatherContext(...) │
│ │
│ phase = phaseAPI │
│ permit, err := │
│ Breaker.Acquire() ───┼──► ErrCircuitOpen?
│ │ yes → BreakerNotify.Add()
│ if Storm or Probe: │ no → continue
│ rounds = 0 │
│ │
│ Analyzer.Analyze() / │
│ RunToolLoop(...) │
│ │
│ phase = phasePostAPI │
│ StormNotify.Add() / │
│ PublishAll(...) │
│ │
│ defer: │
│ permit.Done(err) │
│ phase-cleanup │
└─────────────────────────┘
The new pieces are all in internal/shared/:
storm.go— sliding-window counterbreaker.go— circuit-breaker with permit tokensnotify_aggregator.go— buffered notification emittercooldown.go— extended with atomic group-cooldown methodspolicy.go— extended withStormpointer +IsDegraded()
The k8s and checkmk pipelines call into them; both cmd/*-analyzer/main.go
construct the components from env vars at startup.
A sliding-window counter (internal/shared/storm.go) that tracks how many
new alerts entered the system in the last 5 minutes. The window has 5
buckets of 1 minute each.
type StormDetector struct {
threshold int
now func() time.Time // injectable for tests
mu sync.Mutex
buckets [5]bucket
}
func NewStormDetector(threshold int, now func() time.Time) *StormDetector
func (d *StormDetector) Record()
func (d *StormDetector) Count() int
func (d *StormDetector) Threshold() intDisabled-as-nil pattern: NewStormDetector(threshold<=0, _) returns
nil. All methods are nil-safe. The pipeline never has to special-case
"storm-mode off".
Where Record() is called: in the handler, after the cooldown check, before enqueue. This way the detector counts incoming pressure (real new alerts), not successful enqueues — even if the queue is full and we're returning 503s, storm-mode still triggers.
Where Count() is read: by AnalysisPolicy.IsDegraded() from inside the
pipeline, on every alert. The pipeline forces rounds=0 when IsDegraded()
returns true.
A three-state breaker (internal/shared/breaker.go) gated at the
logical-call level — one analysis attempt is one Acquire/Done cycle,
regardless of how many HTTP roundtrips the analysis does internally.
type CircuitBreaker struct {
threshold int
openDuration time.Duration
maxProbeDuration time.Duration // probe-watchdog timeout
now func() time.Time
mu sync.Mutex
state breakerState
consecFailures int
openedAt time.Time
probeStartedAt time.Time
halfOpenInFlight bool
}
type Permit struct { /* ... */ }
func NewCircuitBreaker(threshold int, openDuration, maxProbeDuration time.Duration, now func() time.Time) *CircuitBreaker
func (b *CircuitBreaker) Acquire() (*Permit, error) // ErrCircuitOpen on rejection
func (b *CircuitBreaker) State() int // 0=closed, 1=open, 2=half-open
func (p *Permit) IsProbe() bool
func (p *Permit) Done(err error) // idempotentWhy a permit token instead of BeforeCall/RecordResult? Spec section
2.3 documents the rationale: a separate before/after pair has two structural
problems —
- Probe-correlation is global, not call-local. Without a per-call
token,
IsHalfOpenProbe()returns global state, not the state of the specific caller who acquired. - Panic-cleanup. A panic between
BeforeCall()=niland the missingRecordResult()leakshalfOpenInFlight=trueforever.
The permit pattern solves both: permit.IsProbe() is call-local, and
defer permit.Done(err) ensures cleanup even on panic.
Probe-watchdog: if a half-open probe takes longer than
maxProbeDuration (e.g. probe goroutine hung on a slow Claude call), the
next Acquire() resets halfOpenInFlight=false and treats the probe as
failed (state=open, openedAt=now). Without the watchdog, a stuck probe
would block all callers until pod restart.
Disabled-as-nil: NewCircuitBreaker(threshold<=0, …) returns nil.
Acquire() on nil returns a no-op permit.
A single-owner-goroutine buffer (internal/shared/notify_aggregator.go)
that collapses many alert notifications into one summary per interval.
Used by the pipeline when the breaker is open (BreakerNotify) or when
storm-mode is active (StormNotify).
type NotifyAggregator struct {
publishers []Publisher
interval time.Duration
titleFmt string
priority string
drops prometheus.Counter
in chan string
stopReq chan stopRequest
stopOnce sync.Once
stopped chan struct{}
stopErr error
stopping atomic.Bool
}
func NewNotifyAggregator(publishers []Publisher, interval time.Duration, titleFmt, priority string, drops prometheus.Counter) *NotifyAggregator
func (a *NotifyAggregator) Add(alertTitle string) bool
func (a *NotifyAggregator) Stop(ctx context.Context) errorConcurrency model: the owner goroutine is the only writer to the
buffer. Add() and Stop() communicate via channels — no shared-memory
mutation. This eliminates the mutex+timer race that the initial design
had (see spec Round-2 review notes).
Add() back-pressure: non-blocking. If the in-channel is at capacity, the alert is dropped and the drops counter increments. This keeps webhook-handler latency bounded under storm bursts.
Stop() protocol: request-reply via stopRequest{ctx, ack}. First
caller's sync.Once.Do sends the request; later callers wait on
<-a.stopped. The owner drains the in-channel with a rolling 10ms deadline
(absorbs in-flight Add() goroutines), then flushes with the caller-supplied
ctx, then sends ack and exits. Final flush uses caller ctx, not
context.Background() — a hung publisher cannot leak the goroutine.
Drops are observable: every dropped alert (channel-full,
post-stop, race against stop, publish-error) increments
notify_aggregator_drops_total{aggregator="…"}. Sustained non-zero
drops mean the aggregation interval is too long for the load, or the
publisher is failing.
internal/shared/cooldown.go gains three methods around an additional
group-cooldown map:
func (cm *CooldownManager) CheckAndSetGroup(groupKey string, ttl time.Duration) bool
func (cm *CooldownManager) ClearGroup(groupKey string)
func (cm *CooldownManager) CheckAndSetWithGroup(
fingerprint string, fpTTL time.Duration,
groupKey string, groupTTL time.Duration,
) boolCheckAndSetWithGroup is the default path used by handlers. It
guarantees:
- Atomic semantics: either both cooldowns are set, or neither.
- Rollback on fingerprint-block: if the group is free but the fingerprint is already in cooldown, the group entry is rolled back before returning false. No orphan group entries.
- Lock hierarchy:
groupMuis acquired beforefpMu, both are held over the entire decision. No other method takes them in reverse order → no deadlock. - Empty-group fast path: if
groupKey == ""orgroupTTL == 0, the group is skipped and the call reduces to a plain fingerprint check.
Group-key derivation lives in the handlers (it's source-specific):
| Source | Group key | Empty-value fallback |
|---|---|---|
k8s (internal/k8s/handler.go) |
alertname:namespace |
alertname:_cluster_ |
checkmk (internal/checkmk/handler.go) |
host:service |
host:_host_ |
The sentinel suffixes (_cluster_, _host_) prevent two distinct alerts
both with empty namespace/service from collapsing into the same group.
The pipeline orchestration is shared: shared.ProcessAlert
(internal/shared/pipeline.go) owns the phase state machine, breaker-permit
settlement, and cooldown cleanup for both products; internal/k8s/pipeline.go
and internal/checkmk/pipeline.go only supply product hooks (context
gathering, prompt construction, the static-vs-agentic decision, notification
naming) via the shared.PipelineHooks interface. It uses a failurePhase
enum and a separate analysisErr variable — not the named return — so
that cleanup decisions cannot be flipped by a later post-API error.
type failurePhase int
const (
phasePreAPI failurePhase = iota
phaseAPI
phasePostAPI
)
func ProcessAlert(ctx context.Context, deps PipelineDeps, hooks PipelineHooks, alert AlertPayload) {
var (
phase = phasePreAPI
analysisErr error
permit *Permit
)
// Cleanup defer reads phase + analysisErr (NOT the named return).
defer func() {
if r := recover(); r != nil {
if analysisErr == nil {
analysisErr = fmt.Errorf("panic recovered: %v", r)
}
defer panic(r) // re-panic AFTER cleanup
}
if permit != nil {
permit.Done(analysisErr) // FIRST so the breaker sees panics + late errors
}
switch phase {
case phasePreAPI:
// clear cooldowns, AlertsFailed++
case phaseAPI:
if analysisErr == nil { return }
if errors.Is(analysisErr, ErrCircuitOpen) {
// Verstärker-Mitigation: KEEP cooldowns
return
}
// clear cooldowns, AlertsFailed++
case phasePostAPI:
// analysis succeeded; ntfy-failure logged separately, KEEP cooldowns
return
}
}()
// ... pipeline body, sets phase + analysisErr along the way ...
}Why two variables (phase + analysisErr) instead of just err?
The cleanup decision is "did the analysis fail and was it not
ErrCircuitOpen?". Reading the named return would conflate the analysis
error with a later PublishAll error from the post-API path —
publish-failure on a successful analysis would (incorrectly) clear the
cooldown. With a separate analysisErr only set in the API phase, the
defer's switch is unambiguous.
Why permit.Done() runs INSIDE the cleanup defer? Because deferred
calls run LIFO. If defer permit.Done(analysisErr) is registered separately,
it runs FIRST during a panic — at which point analysisErr is still nil
and the breaker would record a SUCCESS for the panicked analysis. Folding
Done() into the cleanup defer body (after recover()) ensures the
breaker observes panics correctly. Regression test:
TestProcessAlert_AnalyzerPanicOpensBreaker in both pipelines.
All storm-robustness environment variables are optional and default disabled.
Validated at startup via shared.ParseIntEnv — out-of-range values cause
a hard exit with a clear message.
GROUP_COOLDOWN_SECONDS (int, 0–86400) default: 0 (suggested: 60)
STORM_MODE_THRESHOLD (int, 0–100000) default: 0 (suggested: 50)
STORM_MODE_NOTIFY_INTERVAL (duration) default: 60s
CIRCUIT_BREAKER_THRESHOLD (int, 0–100) default: 0 (suggested: 5)
CIRCUIT_BREAKER_OPEN_SECONDS (int, 1–3600) default: 60
CIRCUIT_BREAKER_MAX_PROBE_SECONDS (int, 1–3600) default: 60
CIRCUIT_BREAKER_NOTIFY_INTERVAL (duration) default: 300s
The "suggested" values are starting points — see
cost-and-storm-protection.md for the
recommended migration sequence.
Three new metrics on the existing :METRICS_PORT/metrics endpoint:
| Metric | Type | Labels | Notes |
|---|---|---|---|
storm_mode_active |
Gauge | source |
0/1 — 1 when policy.IsDegraded() |
claude_circuit_breaker_state |
Gauge | source |
0=closed, 1=open, 2=half-open |
notify_aggregator_drops_total |
Counter | aggregator |
aggregator="storm"|"breaker" |
Both gauges are refreshed on every alert (set in the pipeline before
Acquire()). The counter increments inside Add() for channel-full /
post-stop / race drops, and inside the owner goroutine for
publish-error drops.
| Symptom | Likely cause | Where to look |
|---|---|---|
claude_circuit_breaker_state == 1 for >2min |
Sustained Claude API failures | Anthropic status page, claude_api_errors_total, claude_api_duration_seconds p95 |
claude_circuit_breaker_state == 2 flapping |
Probe failures: half-open → open immediately | Last successful probe in logs; check Anthropic auth and rate-limit headers |
storm_mode_active == 1 for >5min |
Real alert burst from one source | k8s deployment thrashing, mass node failure, etc. — operator-triage |
notify_aggregator_drops_total{aggregator="storm"} rising |
Storm exceeding aggregator buffer | Lower STORM_MODE_NOTIFY_INTERVAL or accept the drops as "known incident" |
notify_aggregator_drops_total{aggregator="breaker"} rising |
Many alerts during breaker-open | Expected during outages; Grafana annotation, no action |
alerts_cooldown_total low under storm |
Group-cooldown not enabled | Set GROUP_COOLDOWN_SECONDS=60 |
Webhook returns 503, alerts_queue_full_total rising |
Worker saturation; storm threshold may not be triggering | Verify STORM_MODE_THRESHOLD is set; check storm_mode_active |
Two named bugs the design explicitly mitigates:
- Storm-Verstärker-Bug — pre-Phase-2, an API failure cleared cooldowns
and Alertmanager retries hammered the broken API. Now:
phaseAPI+ErrCircuitOpenkeeps cooldowns. Test:TestVerstaerkerBug_OpenBreakerKeepsCooldown_NoSecondAnalysisin both pipeline test files. - Half-open-probe-stuck — a slow Claude call inside a probe could
block the breaker indefinitely. Now: probe-watchdog auto-fails after
maxProbeDuration. Test:TestCircuitBreaker_ProbeWatchdogReleasesStuckProbe.
When multiple features are simultaneously active:
| Storm | Breaker | Behavior | Notification |
|---|---|---|---|
| no | closed | Normal API call with configured rounds | Per-alert ntfy |
| no | open | No API call, ErrCircuitOpen, cooldowns kept |
Breaker-aggregator |
| no | half-open | One probe with rounds=0, others get ErrCircuitOpen |
Breaker-aggregator |
| yes | closed | Analyze-only (rounds=0), storm-degraded |
Storm-aggregator |
| yes | open | No API call, cooldowns kept | Breaker-aggregator (priority) |
| yes | half-open | Probe with rounds=0, all others rejected |
Breaker-aggregator |
Breaker dominates Storm for the call-permission decision. Both gauges are simultaneously visible in Grafana — operators see "the system is in storm-mode AND the breaker is open" as two correlated signals.
The storm-robustness components are all nil-safe. The pipeline never
needs an if breaker != nil guard around Acquire() — a nil breaker
returns a no-op permit. Same for Storm.Record(), StormNotify.Add(),
policy.IsDegraded(). Code that constructs the components decides
disabled-vs-enabled by passing 0 for the threshold/interval; everything
downstream is uniform.
Acquire() and Done() are correlated by the *Permit token. The
contract:
- Every successful
Acquire()MUST be followed by aDone(). Done()is idempotent — multiple calls are safe but only the first has effect.permit.Done()should run AFTER anyrecover()that captures a panic intoanalysisErr, so the breaker observes panics as failures. Pattern: foldpermit.Done()into the cleanup defer body, not a separate defer.
Violations in code review: a separate defer permit.Done(analysisErr)
is a red flag. Either it runs before recover() (LIFO ordering) or it
captures analysisErr at registration time (closure semantics) — both
are bugs the original spec didn't catch and the implementation review
flagged.
groupMu < fpMu
CheckAndSetWithGroup is the only method that holds both. Order:
acquire groupMu, then fpMu, release in reverse via deferred unlock.
Do not add a method that takes them in reverse order — that would
introduce a deadlock-cycle. If you need a method that mutates only one
map, use CheckAndSet / CheckAndSetGroup / Clear / ClearGroup.
If you change the storm-protection internals, make sure these still pass
under -race:
TestVerstaerkerBug_OpenBreakerKeepsCooldown_NoSecondAnalysis(both pipelines) — cooldowns survive an open breaker; Claude is not called on retries.TestProcessAlert_HalfOpenProbeForcesRoundsZero(both pipelines) — half-open probe forcesrounds=0, no tool loop.TestProcessAlert_StormDegradedForcesRoundsZero(both pipelines) — storm-degraded forcesrounds=0.TestProcessAlert_AnalyzerPanicOpensBreaker(k8s) — panic during analysis trips the breaker.TestProcessAlert_AnalyzerErrorOpensBreaker(both pipelines) — error during analysis trips the breaker.TestCircuitBreaker_ProbeWatchdogReleasesStuckProbe— stuck probe is auto-released aftermaxProbeDuration.TestCircuitBreaker_ConcurrentHalfOpenAcquireGivesOnlyOneProbe— under contention, exactly one probe permit is granted.TestCooldownManager_CheckAndSetWithGroup_*(5 tests) — atomicity, rollback, empty-group fast path, concurrent winner-selection.TestNotifyAggregator_StopRaceNoLosses— 1000 parallel Add() against a Stop,published+drops == 1000(no silent loss).TestNotifyAggregator_StopConcurrentCallersAgree— 50 parallel Stop() callers must agree on the result.TestNotifyAggregator_HungPublisher_StopReturnsTimeout— hung publisher does not leak the owner goroutine.
// WRONG — analysisErr is evaluated at defer-registration time (always nil here)
defer permit.Done(analysisErr)
// CORRECT — closure reads analysisErr at execution time
defer func() { permit.Done(analysisErr) }()
// CURRENT — folded into the cleanup defer for correct panic ordering
defer func() {
if r := recover(); r != nil { /* set analysisErr */ defer panic(r) }
if permit != nil { permit.Done(analysisErr) }
/* phase-switch cleanup */
}()The current implementation is the third form because the first two have distinct bugs (the first ignores the analysis error entirely; the second records SUCCESS for panicked analyses).
The storm-robustness features are intentionally narrow. Out-of-scope:
- Hard kill-switch / spend cap. Anthropic's workspace spend limit is the external backstop. We don't track per-day cost.
- Persistent state. Cooldowns, storm-counter, breaker state are all pod-local in-memory. A pod restart resets everything.
- Multi-replica consistency. Every replica has its own cooldown map,
storm window, and breaker state. Alertmanager retries can land on a
different pod and bypass the cooldown set on the first pod. Operators
with HPA should keep
replicaCount=1unless absolutely necessary. Scale up only on sustained load. The mitigation is documented as the explicit Wirksamkeitsgrenze ("effectiveness boundary") of the design. - Dynamic config reloads. All thresholds are read at startup from env vars. Changing them requires a pod restart.
- Per-source notification customization. Storm and breaker
notification format is hard-coded; if the operator wants different
templates per source, they'd need to extend
NotifyAggregator.
For each of these, the cost and storm-robustness metrics give operators the visibility to decide whether the limitation is acceptable in their deployment.