Alerting Design¶
This page documents the opt-in outbound alerting feature for issue #2356: push notifications for backend health, circuit, and budget events. It describes the configuration surface, the events the router already produces, and the delivery contract. The implementation lives in src/services/alerting/, with producers in src/health.rs, src/core/circuit_breaker/state.rs, and src/control_plane/policy_store.rs.
Scope¶
Goals:
- Notify an operator when a backend is de-routed, when a circuit opens or closes, and when a key's monthly budget is exhausted or approaches a configured fraction.
- Keep the request path independent of delivery. A slow or unreachable receiver must never add latency to inference.
- Reuse signals the router already computes instead of adding new probes or accounting.
Non-goals:
- Replacing Prometheus, OTLP, the process log, or the Admin API. Alerting is a push convenience beside them, not a new source of truth.
- Routing rules such as per-team channels or escalation. One webhook endpoint receives every enabled event.
- Sending prompt or completion content. Alert payloads carry metadata only.
Configuration¶
The alerting: section is optional and off by default.
alerting:
enabled: false
webhook:
url: "https://hooks.example.com/continuum"
timeout: 5s
events:
backend_unhealthy: true
backend_recovered: true
circuit_opened: true
circuit_closed: true
budget_exhausted: true
budget_fraction: 0.9
cooldown: 5m
max_queue: 256
retry:
max_attempts: 3
initial_delay: 500ms
max_delay: 30s
| Field | Type | Default | Notes |
|---|---|---|---|
enabled | bool | false | When false the section is parsed and validated but no task is spawned and no request is made |
webhook.url | string | none | https endpoint. Required when enabled |
webhook.timeout | duration | 5s | Bound on one delivery attempt |
webhook.allow_private_network | bool | false | Allows a loopback or private-network receiver (see Security) |
events.backend_unhealthy | bool | true | Emitted on the health checker's healthy-to-unhealthy transition |
events.backend_recovered | bool | true | Emitted on the unhealthy-to-healthy transition |
events.circuit_opened | bool | true | Emitted when a backend circuit leaves closed |
events.circuit_closed | bool | true | Emitted when a circuit returns to closed |
events.budget_exhausted | bool | true | Emitted when a hub-synced key budget is exhausted. Requires the control-plane build |
events.budget_fraction | float | 0.9 | Fraction of a key budget at which one warning is emitted. 0 disables |
cooldown | duration | 5m | Per event and subject; further occurrences inside the window are counted, not sent |
max_queue | integer | 256 | Bounded queue between the request path and the delivery task |
retry.max_attempts | integer | 3 | Attempts per event, including the first |
retry.initial_delay | duration | 500ms | First backoff delay |
retry.max_delay | duration | 30s | Cap on backoff growth |
Secrets in webhook.headers (a follow-up field) use ${ENV_VAR} substitution and are masked in the Admin API and config export, like other credential fields.
Event Sources¶
| Event | Source | Trigger |
|---|---|---|
backend_unhealthy | src/health.rs | Consecutive failed probes reach health_checks.unhealthy_threshold |
backend_recovered | src/health.rs | Consecutive successful probes reach health_checks.healthy_threshold |
circuit_opened, circuit_closed | src/core/circuit_breaker/ | A transition to open or back to closed, recorded where circuit_breaker_transitions_total (src/core/circuit_breaker/metrics.rs) is incremented |
budget_exhausted | src/control_plane/policy_store.rs (hub snapshot plus local delta) | A synced key's monthly budget is spent |
budget_fraction | src/control_plane/policy_store.rs (hub snapshot plus local delta) | A synced key first crosses the configured fraction |
Without the control-plane feature there is no local spend authority, so the two budget events never fire. The startup log states that the budget events are inert, in line with the project rule that config types are always compiled while only the runtime is gated.
Delivery Contract¶
- Single task: one background task owns the queue and performs every HTTP POST. Event producers call a non-blocking
try_sendand continue. - Bounded queue: at
max_queueentriestry_sendfails, the new event is dropped, andalert_dropped_totalis incremented. Request handling is never delayed by a full queue. - Retries: failures other than a 2xx response retry with exponential backoff bounded by
retry.max_delay. A 2xx ends the event. Exhausted retries incrementalert_deliveries_total{result="failed"}. - Cooldown: the notifier keeps one timestamp per event and subject (backend name or key id). Occurrences inside
cooldownare counted; when the window closes, a summary carries the suppressed count. - Reload: the section is re-read on hot reload. A new URL takes effect for events queued after the reload; queued events keep the URL they were formed with.
- Shutdown: after the shutdown signal the delivery task drains queued events for up to a hard-coded 5 seconds, and the server does not await it, so events still pending when the process exits are dropped.
Payload¶
Every request is POST with Content-Type: application/json.
{
"event": "circuit_opened",
"occurred_at": "2026-10-28T10:00:00Z",
"router_id": "continuum-router-1",
"subject": { "backend": "openai-primary" },
"detail": { "previous_state": "closed", "state": "open", "failure_status_code": 503 },
"suppressed": 0
}
eventis one of the enabled event names above.router_idis the control-plane router id when the router is enrolled with a Hub, otherwise the host name.subjectnames the backend or the key id, never the key value.detailcarries only fields the source event already has, such as the provider status code or the exhausted budget window.suppressedcounts occurrences coalesced by the cooldown.
Security¶
- The URL must use
httpsand passes the strict SSRF validator (src/infrastructure/common/url_validator.rs), which refuses loopback, private, link-local, and cloud metadata addresses, including host names that resolve to them.webhook.allow_private_network: true(a new field,falseby default) lifts the address check for an in-cluster receiver. Validation alone does not close the gap between resolution and connection, so the delivery client pins the validated addresses. - A URL with embedded credentials is a load error; credentials belong in
webhook.headersthrough environment substitution. - Payloads never include prompt text, completion text, API keys, or hub tokens.
- Receiver errors are logged without the URL and without request headers.
Metrics¶
| Metric | Type | Labels |
|---|---|---|
alert_deliveries_total | counter | event, result (delivered, failed) |
alert_queue_depth | gauge | none |
alert_dropped_total | counter | none |
alert_coalesced_total | counter | event |
Testing Plan¶
- Unit tests for payload serialization, cooldown accounting, and queue shedding.
- A test with a local HTTP server that asserts retry on a 500, success on a later 2xx, exhausted retries counting a failure, and that enqueueing never touches the network.
- Config tests for validation: a missing URL when enabled, a non-https URL, embedded credentials, and an out-of-range fraction are load errors.
- The configuration-assistant drift test keeps the
sections_countand### alertingcatalog entry in step with the schema; keep this page and thealerting:block inconfig.yaml.examplein step when the section changes.
Relation to Client Notifications (#2416)¶
The rate-limit and quota webhook design (Webhook Notifications: Design Record, issue #2416) pushes events to the client application that owns a key. Alerting pushes operational events to the operator. The two sections stay separate because their receivers, subscriptions, and payloads differ, but they should share one delivery implementation (bounded queue, retry with backoff, SSRF validation, delivery metrics) so the guarantees match.
Rollout¶
The implementation landed in the order below.
- Config types and validation, inert until
enabledis true. - The notifier task and the health and circuit events.
- The budget events with the
control-planegate. - The
config.yaml.examplesection and the configuration-assistant entry, in the same change.