Issue #3. Prior to this, security-relevant contract activity — revocations, disputes, contract upgrades — wasn't monitored at all; an operator would only find out by manually querying state. This adds an event listener and two independent alerting paths so that activity is never silent.
monitoring/grafana/dashboards/critical-events.json
is the pre-incident view: it charts quorumproof_critical_events_total by category
over time, shows the 15-minute revocation window that drives RevocationSpike, and
plots the alert-dispatch failure rate that drives CriticalEventAlertingDegraded.
The alerts in monitoring/prometheus/alerts.yml are the paging layer: they fire
when a threshold is crossed and route to Slack/PagerDuty via Alertmanager. If you are
being paged, open the dashboard first to understand the trend leading up to the alert,
then use GET /events/critical/recent for per-event detail (topic, ledger, tx hash).
The distinction matters: an operator watching the dashboard can see a revocation count
trending toward the RevocationSpike threshold and investigate before being paged;
once PagerDuty fires, the dashboard is the context window that explains whether the
alert was a one-off spike or sustained activity.
Soroban RPC (getEvents)
│
▼
api-server/src/services/criticalEventListener.ts (polls every 15s, cursor-tracked)
│
├─→ classify topic (revocation / dispute / upgrade)
│
├─→ real-time push ──→ alertChannels.ts ──→ Slack webhook / PagerDuty Events API
│ (seconds-scale, per-event)
│
└─→ Prometheus counters (quorumproof_critical_events_total{category=...})
│
▼
monitoring/prometheus/alerts.yml (rule evaluation, e.g. spike detection)
│
▼
Alertmanager (monitoring/prometheus/alertmanager.yml) ──→ Slack / PagerDuty
(scrape-interval-scale)
Two paths exist deliberately: the direct push path reacts within seconds of
the event being observed (important for disputes/upgrades, where minutes
matter), while the Prometheus/Alertmanager path is better suited to
trend detection (e.g. an unusual rate of revocations) that a single-event
push can't express, and gives a second, independent delivery mechanism in
case the in-process push fails (see CriticalEventAlertingDegraded below).
Event classification is topic-pattern based, not an exact string list
(classifyTopic in criticalEventListener.ts), so it also catches
revocation/dispute/upgrade-shaped events added to the contract later
without a code change:
| Category | Topic pattern | Contract events currently matched |
|---|---|---|
revocation |
`/revok | suspend/i` |
dispute |
/disput/i |
dispute-resolution events |
upgrade |
`/upgrad | migrat/i` |
| Env var | Purpose |
|---|---|
CONTRACT_QUORUM_PROOF |
Required for the listener to start (same var soroban.ts uses). |
STELLAR_RPC_URL |
RPC endpoint to poll (defaults to testnet). |
EVENT_LISTENER_POLL_MS |
Poll interval, default 15000. |
CRITICAL_EVENT_MONITORING |
Set to disabled to opt out even when a contract id is configured. |
SLACK_ALERT_WEBHOOK_URL |
Slack incoming webhook for real-time push alerts. |
PAGERDUTY_ROUTING_KEY |
PagerDuty Events API v2 routing key, for critical-severity events only. |
If neither SLACK_ALERT_WEBHOOK_URL nor PAGERDUTY_ROUTING_KEY is set,
dispatchAlert no-ops silently (treated as success) rather than failing —
this keeps the listener usable in dev/CI without alert credentials
configured, at the cost of alerts going nowhere until a channel is added.
Real-time (per-event) Slack/PagerDuty push, one message per critical event, immediately on observation:
- Revocation (
warningseverity, Slack only) — a credential, delegation, or role was revoked. Expected in normal operation (issuers revoke credentials routinely); read as informational unless volume looks unusual (seeRevocationSpikebelow). - Dispute (
critical, Slack + PagerDuty) — a dispute was raised on-chain. Always page: disputes are rare and typically require an operator or arbitrator to review the underlying claim promptly. - Upgrade (
critical, Slack + PagerDuty) — a contract upgrade or migration event. Always page: an unexpected upgrade event is a strong signal of unauthorized admin action or a misfired deploy. First response: confirm with the deploying team whether this was a planned, authorized change before doing anything else — seedocs/contract-upgrade-strategy.mdfor the expected upgrade procedure to compare against.
Prometheus/Alertmanager rules (monitoring/prometheus/alerts.yml),
evaluated on the scrape interval:
RevocationSpike(warning) — more than 10 revocation-shaped events in 15 minutes. Compare against known issuer batch-revocation activity before escalating; a bulk credential-recall operation can legitimately trigger this.DisputeRaised(critical) — any dispute event in the last 5 minutes. Redundant with the real-time push by design — treat a Prometheus-sourced page with no matching Slack message as evidence the real-time path itself is broken.ContractUpgradeDetected(critical) — any upgrade/migration event in the last 5 minutes. Same redundancy rationale asDisputeRaised.CriticalEventAlertingDegraded(warning) — the listener observed a critical event but failed to deliver it to any channel (both Slack and PagerDuty requests failed). Treat this as "alerting itself is broken," not as evidence about contract state — go checkGET /events/critical/recentdirectly, since it may hold events that never reached a human.
GET /events/critical/recent— JSON: listener metrics plus the most recent critical events (topic, category, ledger, tx hash, decoded value). The source of truth when triaging an alert, and the fallback view whenCriticalEventAlertingDegradedfires.GET /metrics/events— Prometheus exposition of the counters consumed byalerts.yml. Scraped by theapi-server-critical-eventsjob inmonitoring/prometheus/prometheus.yml, every 15s (matchingEVENT_LISTENER_POLL_MS's default) — without that job the four critical-event alert rules below have no data and can never fire, no matter how many revocations/disputes/upgrades actually occur. Expected alert latency once scraped: up to one scrape interval (15s) for the counter to land in Prometheus, plus each rule's own evaluation window — effectively immediate (for: 0mon all four) forDisputeRaisedandContractUpgradeDetected(5m windows), and up to 15m of accumulation forRevocationSpike's volume threshold to be reached.
Every route Prometheus is meant to cover, kept here so a future new
/metrics/* route doesn't silently repeat this gap — cross-check against
monitoring/prometheus/prometheus.yml's scrape_configs when adding one:
| Route | Scrape job | Consumed by |
|---|---|---|
GET /metrics/events (this doc) |
api-server-critical-events |
RevocationSpike, DisputeRaised, ContractUpgradeDetected, CriticalEventAlertingDegraded |
quorumproof-exporter :9101/metrics |
quorumproof-exporter |
HighErrorRate, APIDown, ContractPaused, LowAttestationRate, RateLimitSpike, and most other rules in alerts.yml |
Prometheus self-scrape :9090 |
prometheus |
Prometheus's own operational metrics |
GET /metrics/ws (docs/websocket-scaling.md) and GET /metrics/rpc
(docs/resilience.md) are exposed by api-server but are not currently
scraped by monitoring/prometheus/prometheus.yml — no alerts.yml rule
depends on them today, but adding one without also adding a scrape job
would reproduce this exact bug.
monitoring/prometheus/alertmanager.yml routes severity: critical alerts
to a receiver with both Slack and PagerDuty configured, and everything else
to Slack only. Alertmanager doesn't expand ${VAR} placeholders on its
own — template the file at deploy time (envsubst, Helm, etc.) or replace
the placeholders with literal secrets from your secrets manager. The
in-process real-time path (alertChannels.ts) reads
SLACK_ALERT_WEBHOOK_URL / PAGERDUTY_ROUTING_KEY directly from the
api-server's environment — the two paths can point at the same or
different Slack channels/PagerDuty services depending on how much you want
to separate "live event fired" noise from "trend rule fired" noise.