DocsAlerts
Alert routing
On this page
Alerts are set up in alerts.yaml. Three ideas: destinations (where a message can go), rules (which alerts go to which destinations), and behavior (when, and how often). Every field is in the config reference. A hub without the file offers to create it: an admin adds the first destination under Alerts → Routing, and the hub writes it as the default with the fallback rule (PUT /api/v1/config/files/alerts.yaml with If-None-Match: *). What leaves the hub (retries, formats, templates, the signing headers) is in alert-delivery.md.
A small working file#
destinations:
chat:
type: webhook
default: true # exactly one destination is the default
url: ${env:CHAT_WEBHOOK_URL} # a URL can carry a token, so it is a secret reference
format: slack # yarrix_json | slack | discord | ntfy | template
mail:
type: email
to: [ops@example.net]
subject_prefix: "[yarrix] "
smtp:
host: smtp.example.net
port: 587
security: starttls
username: ${env:SMTP_USER}
password: ${env:SMTP_PASSWORD}
from: yarrix@example.net
rules:
- when: { severity: critical }
send: [chat, mail]
- fallback: [chat] # the last rule, always: the default destination
Check it with yarrix-hub config check, then send each destination a test: POST /api/v1/destinations/<id>/test (admin token). The reply tells you whether the receiver accepted it.
Destinations#
- webhook: an HTTP request to a URL.
formatpicks the body:yarrix_json(the API's own shape),slack,discord,ntfy, ortemplatefor your own. Addheaders:(each value is a secret, for anAuthorizationheader),sign:(below),events:to limit what it receives, andenabled: falseto switch one off without deleting it. - email:
to, optionalcc,from,subject_prefix. The server settings come from the sharedsmtp:block, and a destination may override them field by field with its ownsmtp:. - telegram: messages from your own bot to a chat.
bot_token(a secret reference),chat_id(the user, group or channel id; get it from the bot'sgetUpdatesafter you message it), optionalthread_idfor a forum topic, andapi_urlonly for a self-hosted Bot API server. Updates and the recovery are replies to the opening message. - pushover: a push to your phone through your own application.
app_tokenanduser_key(secret references), optionaldevice.prioritymaps each severity; by defaultcriticalis 1,warn0 andinfo-1. A priority of 2 is an emergency: Pushover repeats it everyemergency.retryuntil someone acknowledges it in the app, and stops afteremergency.expire(at most 3h). Emergencies need both fields, andretryis at least 30s. A recovery never sends an emergency.
destinations:
phone:
type: pushover
app_token: ${env:PUSHOVER_APP_TOKEN}
user_key: ${env:PUSHOVER_USER_KEY}
priority: { critical: 1, warn: 0, info: -1 }
tg:
type: telegram
bot_token: ${env:TELEGRAM_BOT_TOKEN}
chat_id: "-1001234567890"
Yarrix ships no bot and no Pushover application: each user creates their own, and the tokens stay in the environment or a file. The Telegram token sits in the request path, so Yarrix never writes that URL to a log, an error or the API.
SMTP setup#
smtp:
host: smtp.example.net
port: 587 # 587 with starttls, 465 with tls, 25 or 2525 with none
security: starttls # starttls (default) | tls | none
username: ${env:SMTP_USER}
password: ${env:SMTP_PASSWORD}
from: yarrix@example.net
timeout: 30s
starttlsupgrades a plain connection;tlsconnects encrypted from the start;nonesends everything, including the password, in clear and is accepted only for a loopback or private-network server (an internal relay).- For a mail server with a private certificate authority,
tls_ca_filenames the PEM file to trust. helosets the name announced to the server.- Delivery follows the same retry rules as webhooks: a
5xxreply fails for good, anything else is retried. - Send a test (
POST /api/v1/destinations/<id>/test) after changing it; a wrong password or port shows up in the reply'serror.
Rules: which alerts go where#
Rules are read top to bottom and every matching rule adds its destinations: they are additive, not first-match-wins. An alert goes to the union of what its matching rules send to. A rule is only used if it has a when. If no destination is left (nothing matched, or the ones that matched are disabled or do not take that event), the default destination receives the alert even when it is disabled, so an alert is never silently dropped. The last rule must be - fallback: [<the default>] to make that explicit.
rules:
- when: { severity: critical }
send: [chat, mail]
ignore_quiet_hours: true
- when: { domain: backups }
send: [mail]
- when: { host: lab-1 }
hold: true # lab-1 never alerts
- fallback: [chat]
when can match severity (info, warn, critical), domain (hosts, apps, backups, heartbeats, system), host, check, tag (the tags of an app, a custom check or a heartbeat) and host_state (asleep, down, up). A field you leave out matches anything; all the fields you give must match. A destination's own events: list is checked too: it receives only the events it lists.
hold: true swallows the matching alerts, and wins over every other matching rule, which is how you mute a noisy host or the alerts of a machine that is asleep (when: { host_state: asleep }) without editing the other rules. also: is another spelling of send: for readability.
GET /api/v1/alerts/routing (operator) shows alerts.yaml as the hub loaded it, with secrets masked, and each destination's last delivery.
Behavior#
behavior:
after_consecutive_failures: 2 # an app or custom check must fail this many runs in a row first
send_recovery: true # also tell me when it resolves
renotify_unacked_critical: 4h # repeat an unacknowledged critical alert
quiet_hours: { from: "22:00", to: "07:00", tz: Europe/London, hold: [warn, info] }
digest: { at: "07:00", tz: Europe/London, send: [mail] }
reports:
- { every: weekly, day: mon, at: "08:00", tz: Europe/London, send: [mail] }
During quiet_hours, alerts of the listed severities wait and a single incident.summary goes out when the window ends; a rule with ignore_quiet_hours: true sends at once. maintenance: lists windows in which one host, check or backup does not alert (target: host:atlas). People can also snooze or silence from the web app (POST /api/v1/silences).
One message for several problems on a host#
When a host is up but several things on it fail together (a dead Docker daemon, say), you get one message per destination, not one per service: "atlas: 6 problems (3 critical)", worst first. Apps, backups and heartbeats of one host that finish their behavior.group_wait together (default 90s) are grouped when two or more are bound for the same destination; a lone problem is sent as itself. Each incident keeps its own row, timeline and ack.
- A destination gets only the incidents your rules routed to it. Grouping never widens who hears what.
- A destination whose
events:list does not includeincident.groupedgets the individual messages. - Problems that start within
behavior.group_interval(default 5m) after the message join it as one follow-up ("atlas: 2 more problems"). Later ones are sent alone. - Recoveries inside that window are held and sent as one "atlas: all 6 resolved". If the window ends first, each recovery is sent on its own.
- Turn it off everywhere with
behavior.group_by_host: false, or for one host withalert_grouping: falseinhosts/<id>.yaml(trueturns it back on there).group_wait: 0ssends at once and groups nothing. - The wait is anchored on the first problem on the host: when it ends, every other problem on that host that is only waiting for
group_waitleaves with it, however late it was detected. - "Ack all on <host>" in the Alerts tab, or
POST /api/v1/alerts/hosts/{id}/ack, acknowledges every open problem on the host. Each incident is acked and audited on its own. Operators and read-write keys may; read-only may not. - A host that is down is covered by its own alert (see dependencies); there is nothing to group.
Pause and silence#
A silence and a pause both keep a notification from going out, but they differ in what the hub does with the object.
- A silence keeps checking the object and keeps its incident open. It only stops the notification.
- A pause stops judging the object. Its checks, SSH polls and backup probes stop, its open incidents resolve with event
paused(no recovery notification), and its state readspausedrather than red. Agent pushes are still stored, so charts have no gap.
Pauses are runtime state, set from the host or object drawer or POST /api/v1/pauses, not from alerts.yaml. A host pause covers everything defined under that host. Global pause ("pause all monitoring") needs an admin and an end time of seven days or less. An operator's pause needs an end, and the paused time on that scope in the last 30 days (active, resumed and expired pauses all count) plus the new pause must come to at most 30 days; a pause with no end, or one past that window, needs an admin. Every pause with no end is counted in the banner on every page, so it cannot be forgotten. Any pause ends by itself at its until. A heartbeat whose ping was due during the pause does not alert on resume: it gets a new baseline when the pause ends.
Verifying the signature#
Give a webhook a signing secret and the receiver can prove a request came from your hub and was not altered or replayed:
destinations:
chat:
type: webhook
url: ${env:CHAT_WEBHOOK_URL}
sign: { type: hmac_sha256, secret: "${env:YARRIX_SIGNING_SECRET}" }
Every request then carries:
X-Yarrix-Timestamp: 1794182294
X-Yarrix-Signature: sha256=565859d73aebe2b03e3a9fd28f160f40d6ceff6c743234dc1c27804019cc5ca6
The signature is the lowercase hex HMAC-SHA256, keyed with your secret, of the timestamp, a dot, and the exact raw request body: HMAC(secret, timestamp + "." + body). Verify against the raw bytes, before any JSON parsing, compare in constant time, and reject a timestamp that is too old (five minutes below). These snippets were each run against a request signed by a real hub.
Node
const { createHmac, timingSafeEqual } = require("node:crypto");
// headers: lowercase header names. Returns true when the body is genuine and recent.
function verify(secret, headers, rawBody, now = Date.now() / 1000, toleranceSeconds = 300) {
const ts = headers["x-yarrix-timestamp"];
if (!/^\d+$/.test(String(ts)) || Math.abs(now - Number(ts)) > toleranceSeconds) return false;
const want = "sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const got = String(headers["x-yarrix-signature"] ?? "");
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
Python
import hashlib, hmac, time
def verify(secret: str, headers: dict, raw_body: bytes, now=None, tolerance=300) -> bool:
"""headers: lowercase names. raw_body: the bytes exactly as received."""
ts = headers.get("x-yarrix-timestamp", "")
now = time.time() if now is None else now
if not ts.isdigit() or abs(now - int(ts)) > tolerance:
return False
want = "sha256=" + hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(headers.get("x-yarrix-signature", ""), want)
Shell (openssl), for a script that has saved the body to a file:
#!/bin/sh
# usage: verify.sh SECRET TIMESTAMP SIGNATURE BODYFILE
secret=$1 ts=$2 sig=$3 bodyfile=$4
want="sha256=$( { printf '%s.' "$ts"; cat "$bodyfile"; } | openssl dgst -sha256 -hmac "$secret" | sed 's/^.* //')"
[ "$want" = "$sig" ] && echo good || echo BAD
(The shell version does not check the timestamp's age; compare $ts with date +%s as well if replay matters to you.)
A signature proves origin and integrity, not secrecy: use an https:// URL as well. Rotate the secret by changing it in your secret store and on the receiver together.