How Versus Observe turns service-level objectives into alert rules — a query-and-expression evaluation engine with a per-series state machine — and routes firing alerts to on-call via email, webhook, ServiceNow, Versus Desk, and PagerDuty-class paging providers.
Versus Observe is the platform's built-in observability workspace — datasources, dashboards, and a Grafana-compatible alerting engine. This guide covers the alerting side: how you express a service-level objective (SLO) as an alert rule, how the engine decides when that rule is breaching, and how a breach reaches the person carrying the pager.
There is no separate "SLO object" to learn. An SLO in Versus is an alert rule whose query computes your objective (availability, error rate, burn rate, latency budget) and whose condition fires when the objective is at risk. The same engine that watches an F5 CPU gauge watches your error budget — you just write a different query.
Everything here lives under /api/observe/alerting/, and the same resources appear in the Observe UI.
Four models make up the evaluation pipeline. Understanding them is enough to reason about any alert you build.
A dedicated scheduler process evaluates each group on its interval. On every tick it finds groups whose interval has elapsed, evaluates their non-paused rules in a thread pool, updates instance state, and routes any notification-worthy transitions. Instance timers are persisted in the database, so the scheduler is restart-safe — a pending alert's clock is not lost when the process restarts.
A rule's data is an ordered list of nodes. Two kinds exist:
type: "datasource") — a query dispatched to a telemetry provider (Prometheus, Loki, PostgreSQL, and others). Each has a refId and a look-back window.reduce — collapse a time series to one scalar per series (last, first, mean, min, max, sum, count).math — a safe arithmetic expression across other nodes' scalars, referenced as $A, $B, …. Only arithmetic and comparison operators are allowed — no function calls, no attribute access — so an expression can never execute arbitrary code.threshold — emit 1.0 (firing) or 0.0 (not firing) per series by testing a value against an evaluator (gt, gte, lt, lte, eq, ne, within_range, outside_range).condition_refid names the node whose output drives the state machine. A value of 1.0 (truthy) from that node means the condition is firing for that series.
Because you have a math node and multiple datasource nodes, you can compute an SLO directly in the rule. The idiom is: query the numerator and denominator, divide them in a math node, then threshold the result.
Here is a rule that fires when a service's fast-window error rate exceeds 2% of an error budget — a single-window burn-rate check:
{
"title": "Checkout SLO — error budget burning",
"eval_group": "grp-critical",
"condition_refid": "D",
"pending_period_seconds": 120,
"no_data_state": "NoData",
"exec_err_state": "Alerting",
"labels": { "severity": "critical", "team": "platform", "slo": "checkout-availability" },
"annotations": {
"summary": "Checkout error budget burning fast",
"description": "5m error ratio is above the 2% burn threshold for the 99.9% availability SLO.",
"runbook_url": "https://wiki.example.com/runbooks/checkout-slo"
},
"notification": { "mode": "direct", "contact_point_uid": "cp-pagerduty" },
"data": [
{ "refId": "A", "type": "datasource",
"datasource": { "uid": "prom-prod", "type": "prometheus" },
"relativeTimeRange": { "from": 300, "to": 0 },
"expr": "sum(rate(http_requests_total{job=\"checkout\",code=~\"5..\"}[5m]))" },
{ "refId": "B", "type": "datasource",
"datasource": { "uid": "prom-prod", "type": "prometheus" },
"relativeTimeRange": { "from": 300, "to": 0 },
"expr": "sum(rate(http_requests_total{job=\"checkout\"}[5m]))" },
{ "refId": "RA", "type": "reduce", "expression": "A", "reducer": "last" },
{ "refId": "RB", "type": "reduce", "expression": "B", "reducer": "last" },
{ "refId": "C", "type": "math", "expression": "$RA / $RB" },
{ "refId": "D", "type": "threshold", "expression": "C",
"conditions": [ { "evaluator": { "type": "gt", "params": [0.02] } } ] }
]
}
The engine queries A and B, reduces each to a scalar, divides them in C, and thresholds the ratio in D. Because condition_refid is D, the state machine keys off that comparison.
Multi-window burn rate — the pattern SRE teams use to page fast on severe burns and slow on gentle ones — is the same shape with more nodes: add a long-window datasource pair, reduce and divide them, and require both windows to breach by combining the two threshold outputs in a final math node ($D * $H, thresholded > 0). Give the fast and slow variants different severity labels and route them to different contact points.
For a latency SLO, query your latency quantile, reduce it, and threshold against your objective. For a raw availability SLO, threshold the ratio directly rather than a burn rate. The engine does not care what the number means — only whether the condition node says 1.0.
labels are static key/value pairs attached to every instance the rule produces (merged with any labels the query returns). They are your routing and identification keys — severity, team, slo, service. When a rule routes to Versus Desk, severity (critical/high/medium/low) maps to incident priority.annotations carry human context — summary, description, and runbook_url — and are included in the notification body so the responder has the runbook link in hand.Each instance runs an independent state machine. The states:
| State | Meaning |
|---|---|
Normal | Condition is not firing. |
Pending | Condition is firing but the pending_period_seconds timer has not yet elapsed. |
Alerting | Condition has held long enough — the alert is live and routable. |
NoData | The query returned no series (behavior set by no_data_state). |
Error | The query or expression failed (behavior set by exec_err_state). |
The normal path is Normal → Pending → Alerting → Normal. pending_period_seconds is your dwell time: set it to 0 for instant firing (a BGP peer dropping), or to a couple of minutes to ride out transient spikes (an SLO burn that must be sustained). When the condition clears, an Alerting instance transitions back to Normal — a resolve — and a resolve notification is sent.
no_data_state and exec_err_state decide what a gap or a failure means for your signal. Options are NoData/Error, Alerting, Normal, and KeepLast (hold the current state). For an availability SLO where "no data" means the scrape target is down, no_data_state: "Alerting" is often the right call; for a noisy log-rate rule, Normal avoids paging on quiet periods.
Only transitions into or out of Alerting are routable, so Pending churn never pages anyone. A NotificationLog row is written before each send and keyed by (fingerprint, contact_point, state), so a scheduler restart or a second worker cannot double-page for the same event.
A rule's notification field points at a contact point — the delivery target. Set it to {"mode": "direct", "contact_point_uid": "<uid>"}. Four contact-point types ship today:
| Type | Behavior on Alerting | Behavior on resolve |
|---|---|---|
email | SMTP message to the configured recipients (STARTTLS on 587/465). | Sends a RESOLVED email. |
webhook | POST an Alertmanager v2 payload to your endpoint. | POST with status: "resolved". |
servicenow | Create an Incident via the Table API (urgency/impact configurable). | PATCH the incident to state 6 (Resolved). |
versus_itsm | Open an incident in Versus Desk, with fingerprint dedup. | Comment "Alert resolved" on the incident. |
The webhook type is the bridge to dedicated paging providers. Because it speaks the Alertmanager wire format, you point it at a PagerDuty, Opsgenie, or Splunk On-Call inbound integration URL and let that provider run the rotation and escalation policy. Store the provider's routing key or GenieKey as the webhook's authorization secret.
The servicenow and versus_itsm types are the escalation path when the SLO breach should become a tracked incident, not just a page. Versus Desk intake is idempotent per alert fingerprint: a re-firing alert appends a comment to the existing open incident rather than opening a duplicate, and a resolve comments on the ticket rather than silently closing it (an operator may be mid-investigation).
Contact-point secrets — SMTP passwords, webhook authorization headers, ServiceNow credentials — are held in an encrypted blob, never returned by the API, and set write-only.
For workflow-driven paging (as opposed to alert-rule routing), Versus ships a vendor-neutral on-call client covering PagerDuty, Opsgenie, Splunk On-Call (VictorOps), xMatters, and OnPage. You register each as a Pager / On-Call source under Integrations, with its routing key and (optionally) a REST API key, then use it from a workflow's script step:
pager = versus.integrations.pager("Primary On-Call") # or pager() for the first enabled source
key = pager.critical(
"Checkout SLO exhausted",
body="Error budget for 99.9% availability is spent; page primary on-call.",
component="checkout", group="platform",
dedup_key="slo-checkout-availability",
)
# later, when the workflow verifies recovery:
pager.resolve(key)
The client normalizes trigger / acknowledge / resolve across all five vendors. dedup_key is the idempotency handle — a repeat trigger with the same key updates the existing incident instead of opening a new one, and it is what you pass to resolve() or acknowledge(). This lets a self-healing workflow page, attempt a fix, and clear its own page — see Workflow Builder and Running Workflows via the API.
Dry-run a rule without touching state — it runs the queries and the expression DAG and returns the condition results:
POST /api/observe/alerting/rules/{uid}/test/
Send a probe to a contact point to confirm delivery and credentials:
POST /api/observe/alerting/contact-points/{uid}/test/
Silence an instance — force a single firing series back to Normal with a reason (recorded in history):
POST /api/observe/alerting/instances/{id}/silence/
{ "reason": "Known maintenance window" }
Pause a rule — toggle a whole rule off (it is skipped on every tick until re-enabled):
POST /api/observe/alerting/rules/{uid}/pause/
List instances, filtered by rule or state, and inspect an instance's transition history:
GET /api/observe/alerting/instances/?rule={uid}&state=Alerting
GET /api/observe/alerting/instances/{id}/history/
The scheduler is a long-running management command. In a self-hosted install, run it as its own supervised process alongside the API:
python manage.py run_alert_scheduler --log-level INFO
It loops in the foreground, evaluating each group on its configured interval, and shuts down cleanly on SIGTERM/SIGINT.
All routes are under /api/observe/alerting/ and require an authenticated session.
| Resource | Route | Notes |
|---|---|---|
| Eval groups | GET/POST/PUT/DELETE eval-groups/ | GET eval-groups/{uid}/rules/ lists a group's rules. |
| Rules | GET/POST/PUT/DELETE rules/ | pause/, instances/, test/ sub-actions. |
| Instances | GET instances/ | Read-only; filter ?rule=&state=; history/, silence/ sub-actions. |
| Contact points | GET/POST/PUT/DELETE contact-points/ | test/ sub-action; secrets are write-only. |
Pager/on-call sources are managed separately under /api/integrations/pager-sources/.
allow_private_egress on the datasource or contact point.tenant_id and is isolated per tenant, consistent with the platform's row-level security posture described in Platform Architecture.