Alerting rules
Alerting turns the anomaly and drift signals tripl finds during a scan into notifications and tickets. You configure it per project on the Alerting tab (Observe → Alerting). Any member can read alerting config; creating, editing, retrying, or muting requires the editor or owner role.
The model has three layers:
Destination (a channel) → Rule (routes matching signals to one destination) → Delivery (a single send attempt, carrying the matched items).
A rule lives under a destination, and a destination belongs to a project, so by default a rule evaluates the signals produced by every scan in the project. A rule can also be narrowed to a single scan with the Scan picker in the rule editor — see Narrowing a rule to one scan.
In a generated demo project the only destination that can exist is the local demo sink: the API refuses to create a Slack, Telegram, webhook, email, Jira, or Linear destination there, and every delivery is rendered and recorded locally rather than sent — the UI labels those rows as simulated, never as a real send. That local sink never fails on its own, so the demo deliberately seeds one failed earlier attempt at the same incident: the failed-delivery state and the Retry action below are both reachable without leaving the demo, and retrying re-dispatches down the normal path and succeeds. See The demo workspace.
Where signals come from
A rule never invents an alert — it reacts to signals the anomaly detector
produces on each scan. In short: for each scope the detector compares the latest
bucket against a seasonal baseline and scores the gap as
z = (actual − expected) / spread, recording a spike or drop when
|z| ≥ sigma_threshold (default 4) and the expected volume clears
min_expected_count (default 50). It also emits distribution-drift signals
(a value mix shifted) and release-regression signals (a new app version
under-fires an event), plus variable-value drift when an event observes
values outside its effective documented variable list.
The full math — seasonal vs rolling baselines, the robust spread and its floor, the PSI drift score, and the release-regression test — is in How anomaly detection works. The rule controls below are an additional filter on top of that detection.
Destinations
A destination is where alerts go. Each has its own connection settings and the message formats it supports.
| Channel | Key settings | Formats |
|---|---|---|
| Slack | Incoming webhook URL (must be a hooks.slack.com HTTPS hook) | plain, Slack mrkdwn |
| Telegram | Bot token + chat ID (numeric, or @channel) | plain, HTML, MarkdownV2 |
| Webhook | HTTPS target URL (SSRF-guarded) + one optional custom header | plain JSON |
| Up to 50 recipients, optional From / subject; uses the instance SMTP settings | plain | |
| Jira | Base URL + project key + issue type (default Task) | plain |
| Linear | API token + team, optional initial state and labels | plain |
Jira and Linear create one ticket per delivery (with a dedup guard so the same delivery doesn't open duplicates). The chat channels (Slack, Telegram) post a message; Webhook POSTs a JSON payload. MarkdownV2 falls back to plain text automatically if a message can't be rendered safely.
Testing a destination
Test on a destination card sends one fixed, clearly-marked message through
the channel itself —
POST /api/v1/projects/{slug}/alert-destinations/{destination_id}/test, editor
or owner only. It is the difference between "a bot token is stored" and "a bot
token works": a revoked Telegram token, a webhook whose channel was archived, and
a perfectly healthy destination all look identical in the form.
The reply is { "ok": …, "error": …, "sent_at": … }, and:
- It always answers 200. A channel refusing the message is the answer you
asked for, not a fault on our side, so a refusal comes back as
ok: falsewith the channel's own message rather than as a 5xx the UI would render as "tripl is broken".errorisnullon success andsent_atisnullon failure — both keys are always present. - It works on a disabled destination. Disabled means "route no alerts here"; checking credentials before switching one back on is the commonest reason to press Test, so refusing would make the button useless exactly when it is wanted.
- It records no delivery. A test is not an alert — writing one would mean borrowing a real rule and scan and claiming they fired, and it would stamp that rule's cooldown and silence the next genuine alert. What is recorded is the operator action, in the project audit log, naming the destination it was pressed on. The Delivery log tab below therefore keeps meaning "an alert fired".
- A demo project refuses it, with
ok: falseand an explanation: a demo is zero-egress. The exception is the local demo sink, which answersok: true, because rendering and recording locally is exactly what a real delivery through it does.
Whoever reads that channel did not ask for the message, so it says on its own line that nothing is wrong and that someone pressed Test. Use rule replay to validate matching, and confirm the first real delivery in the Delivery log; a failing webhook or an unverified bot token is the most common transport failure.
What deleting one would destroy
Deleting a rule deletes its deliveries with it, and deleting a destination deletes every rule under it and every delivery under those. The Inbox reads through those same deliveries, so the incidents they carried go too. That makes "Delete?" the wrong question to ask, and both cards state the damage instead — the numbers come back on the destination and rule payloads themselves:
| Field | On | Means |
|---|---|---|
total_deliveries | a rule | Every delivery this rule has ever made |
incident_count | a rule | Distinct incidents those deliveries carried |
delivery_count | a destination | Every delivery through this destination |
incident_count | a destination | Distinct incidents across all of its rules |
A destination's incident_count is not the sum of its rules'. Two rules of
one destination can carry the same incident, and adding two distinct counts would
report that incident twice, so the destination total is counted in its own right.
total_deliveries on a rule is the same all-time number
GET /monitors/{rule_id} reports under that name — a monitor is an alert rule,
so it is one number with one name. Do not confuse it with delivery_count on an
Inbox incident, which counts the deliveries of that one incident.
What a rule reports about its own state
Alongside those counts, a rule carries its mute state and its delivery health, so its card can answer "is this silenced, and has this channel ever actually carried anything" without a second request:
| Field | Means |
|---|---|
muted | The rule is muted right now. A muted_until that has already passed is not muted. |
muted_until | The instant the mute lifts — emitted raw, whether or not it has passed. |
last_delivery_at | When this rule last sent anything, or null if it never has. |
last_delivery_status | That same delivery's pending / sent / failed; null whenever last_delivery_at is. |
All four are the values GET /monitors/{rule_id} already reports for the same
rule, under the same names — a monitor is an alert rule seen from the other side,
and one object may not carry two shapes.
muted_until is not the question to askRead muted. muted_until on a rule is the stored timestamp and keeps being
sent after it lapses, so "muted until <a past date>" is a normal thing to see on
an unmuted rule; it means "when the last mute was set to lift", not "this rule is
silenced". An Inbox incident answers this differently — see
What an incident row carries.
What a Webhook destination POSTs
The body is JSON, so downstream automation (Zapier, n8n, your own service) can
read individual fields instead of scraping the rendered message:
{
"project": { "name": "Checkout", "slug": "checkout" },
"destination": { "id": "…", "name": "Ops Webhook" },
"rule": { "id": "…", "name": "Volume drops" },
"scan": { "id": "…", "name": "Hourly scan" },
"matched_count": 1,
"message": "…the same text the chat channels would receive…",
"items": [
{
"scope_type": "event",
"scope_ref": "…",
"scope_name": "purchase:success",
"direction": "drop",
"actual_count": 10,
"expected_count": 20,
"absolute_delta": 10,
"percent_delta": 50.0,
"bucket": "2026-04-11T09:00:00+00:00",
"details_url": "…",
"monitoring_url": "…",
"drift_field": null,
"drift_type": null,
"sample_value": null
}
]
}
percent_delta is null when there is no baseline"percent_delta" is null, not 0, whenever "expected_count" is 0 —
a scope resuming after an outage, an event firing for the first time, a schema
drift. There is no ratio to report for those, and reporting 0 would tell a
consumer testing percent_delta > threshold that nothing changed about the
anomalies that changed the most. Use "absolute_delta" for that class; it is
the number that means something. The same rule applies to the item list inside a
delivery's payload_snapshot and to the typed items[] array of
GET /projects/{slug}/alert-deliveries/{id} — one delivery cannot answer the
same question two ways.
Deliveries recorded before this behaviour shipped still carry 0.0 in their
stored payload_snapshot — a delivery is a frozen record and is not rewritten.
Read expected_count == 0 to disambiguate historical rows.
The test POST is a different body
Pressing Test on a webhook destination (see Testing a destination) POSTs to the same URL with the same optional header, but the body is not the one above:
{
"event": "tripl.destination_test",
"destination": "Ops Webhook",
"message": "