Alert thresholds and cooldowns

Threshold alert rules for shield-alerting: the five metrics, rule fields and validation, the five-minute evaluation sweep, cooldowns, history and acknowledgement.

Last reviewed September 18, 2026Fresh

Overview

shield-alerting holds the rules, evaluates them against a rolling hour of your events, dispatches notifications, and keeps a history you can acknowledge.

Who can use it. A tenant admin with a dashboard session, for every action. No plan gate. The function is console-only: it is absent from the worker allowlist, so there is no public URL, and the dashboard calls it through supabase.functions.invoke. pg_cron is the other caller, using a cron secret for the evaluate action only.

Actions: evaluate, list_rules, create_rule, update_rule, delete_rule, test_channel, list_history, acknowledge_alert. Every call needs tenant_id in the body; without it the answer is 400 Missing tenant_id.

Evaluation runs on its own. The pg_cron job shield-alerting-evaluate-5m, added by migration 20260903211000, fires every five minutes and sweeps every tenant with at least one active rule. Calling evaluate yourself is an immediate extra check, never a requirement.

Available metrics

Evaluation computes five metrics for the last hour with a single in-database aggregate (shield_alerting_hour_metrics). No row is read into the function and counted there, so a busy hour is counted exactly.

Metric keyTypeDefinition
events_per_hourintegerevents in the window
p95_latency_msinteger95th percentile of response_ms over events that have one, rounded
high_risk_countintegerevents with risk_score above 0.8
error_ratefloat 0 to 1events whose event_type matches "failure" or whose result is error or failure, over total, to three decimals
mean_risk_scorefloat 0 to 1mean of non-null risk_score values, to three decimals

Two things to know before you set a threshold. response_ms is the full request including awaited outbound actions, so p95_latency_ms moves when a third party you dispatch to is slow. And unlike the Observability tiles, this aggregate does not exclude test-mode signals, so sandbox traffic and dashboard test signals count toward events_per_hour and can fire a live rule.

There is no pre-aggregation layer: every evaluate recomputes the window.

Creating rules

const { data, error } = await supabase.functions.invoke("shield-alerting", {
  body: {
    action: "create_rule",
    tenant_id: "<uuid>",
    name: "High risk spike",
    metric: "high_risk_count",
    operator: ">",
    threshold: 10,
    severity: "critical",
    channel_type: "pagerduty",
    channel_config: { routing_key: "R0123456789ABCDEF" },
    cooldown_minutes: 30,
  },
});
FieldTypeRequiredDefault
namestringyestrimmed to 255 characters
metricenumyesone of the five above
channel_typeenumyesslack, pagerduty, opsgenie, webhook
operatorenumno>
thresholdnumberin practice yesno default; a missing or non-numeric value fails the finite check
severityenumnowarning
channel_configobjectno{}
cooldown_minutesnumberno15, valid 1 to 1440

The response is {"status":"created","rule":{"id":"...","name":"High risk spike"}} and an alerting.rule_created audit entry is written.

StatusBodyCause
400{"error":"Missing required fields: name, metric, operator, threshold, channel_type"}name, metric or channel_type absent
400{"error":"Invalid metric. Valid: events_per_hour, p95_latency_ms, high_risk_count, error_rate, mean_risk_score"}unknown metric
400{"error":"Invalid operator. Valid: >, >=, <, <=, =="}unknown operator
400{"error":"Invalid severity. Valid: critical, warning, info"}unknown severity
400{"error":"Invalid channel_type. Valid: slack, pagerduty, opsgenie, webhook"}unknown channel type
400{"error":"threshold must be a finite number"}threshold not numeric
400{"error":"cooldown_minutes must be between 1 and 1440"}cooldown outside the range
401{"error":"Authentication required"}no Authorization header
403{"error":"Tenant admin required"}member without the admin role
500{"error":"Failed to create rule"}the insert failed

Confirm it worked. Dashboard > Observability > Alert rules lists the new rule with its channel badge, severity, condition and cooldown.

Evaluation pipeline

One evaluate call, or one cron tick, does this for the tenant:

  1. Read active rules from shield_alert_rules, paged explicitly in blocks of 1,000 up to 5,000 per tick, least recently evaluated first (last_evaluated_at, stamped on every rule a tick evaluates). Hitting that ceiling sets truncated: true on the response rather than silently dropping rules, and the next tick reads the rules this one did not reach.
  2. Compute the five rolling-hour metrics.
  3. For each rule, apply the operator to the metric value against the threshold. An operator the function does not recognise evaluates to no breach.
  4. On a breach, claim the cooldown window: a conditional update that sets last_fired_at and increments fire_count only if last_fired_at still holds the value this run read. A rule inside its cooldown is skipped; a concurrent tick that loses the claim skips the firing entirely, so one breach never notifies twice.
  5. Insert the shield_alert_history row. If that insert fails, the claim is released so the next tick can retry rather than swallowing the alert.
  6. Dispatch to the channel, recording dispatch_ok and, on failure, dispatch_reason.
  7. Write one alerting.rules_evaluated audit entry if at least one alert fired.

A dispatch failure does not undo the firing: the history row stands and the cooldown has started, so an unreachable channel loses notifications rather than replaying them later.

{
  "status": "ok",
  "tenant_id": "<uuid>",
  "rules_evaluated": 5,
  "alerts_fired": 1,
  "fired_alerts": [
    {
      "rule_id": "<uuid>",
      "rule_name": "High risk spike",
      "metric": "high_risk_count",
      "operator": ">",
      "threshold": 10,
      "current_value": 14,
      "tenant_id": "<uuid>",
      "severity": "critical",
      "fired_at": "2026-09-16T14:22:00.000Z",
      "dispatch_ok": false,
      "dispatch_reason": "http_404"
    }
  ],
  "metrics": {
    "events_per_hour": 342,
    "p95_latency_ms": 87,
    "high_risk_count": 14,
    "error_rate": 0.012,
    "mean_risk_score": 0.45
  },
  "truncated": false
}

Rule management

Update a rule. Only these fields can be changed: name, metric, operator, threshold, severity, channel_type, channel_config, cooldown_minutes, is_active. Anything else in the body is ignored, so fire_count, created_by and last_fired_at cannot be written from outside.

{
  "action": "update_rule",
  "tenant_id": "<uuid>",
  "rule_id": "<uuid>",
  "threshold": 20,
  "cooldown_minutes": 60,
  "is_active": false
}

The reply is {"status":"updated","rule_id":"..."}. A body with no recognised field answers 400 No valid fields to update, and an invalid value answers with the same message as rule creation.

Delete a rule. {"action":"delete_rule","tenant_id":"...","rule_id":"..."} answers {"status":"deleted","rule_id":"..."}. The rule's history rows stay.

List rules. {"action":"list_rules","tenant_id":"..."} returns {"rules":[...]}, active and inactive, newest first. Since 2026-09-18 a rule's secrets (webhook_url, routing_key, api_key) are stored encrypted in the platform vault and never returned: channel_config in this answer carries a hint per secret (a URL's origin, a key's last four characters) and stored, which is vault once the row has been written that way. To test a stored rule, call test_channel with its rule_id.

Rule updates and deletions are not audit-logged today; creation and acknowledgement are.

Alert history and acknowledgement

List history.

{ "action": "list_history", "tenant_id": "<uuid>", "limit": 50 }

limit defaults to 50 and is capped at 200. The reply is {"history":[...]}, ordered by fired_at descending. Each entry carries id, rule_id, metric_value, severity, channel_type, payload, acknowledged_at, acknowledged_by and fired_at. The payload holds the rule name, metric, operator, threshold and current value as they were at firing time, so a later edit to the rule does not rewrite history.

Acknowledge.

{ "action": "acknowledge_alert", "tenant_id": "<uuid>", "alert_id": "<uuid>" }

Sets acknowledged_at and acknowledged_by to the calling user and answers {"status":"acknowledged","alert_id":"..."}. An unknown id answers 404 Alert not found; an entry already acknowledged answers 409 Already acknowledged. The action writes an alerting.alert_acknowledged audit entry.

Confirm it worked. Dashboard > Observability > Alert history, with the Pending filter: the entry moves out of that list and shows an Acknowledged badge.

Tuning thresholds

Start wide and tighten. A rule that never fires teaches you nothing. Set a first threshold you expect to trip occasionally, watch fire_count in the rules list for a week, then raise it.

Match the cooldown to the response. The cooldown is the minimum gap between two firings of the same rule, and the default is 15 minutes. A page-someone rule is usually 5 to 10 minutes; an informational Slack rule is better at 30 to 60, since evaluation happens every five minutes and a sustained breach would otherwise notify twelve times an hour.

Layer two rules on one metric. One at a lower threshold with severity: "warning" to Slack, one at a higher threshold with severity: "critical" to PagerDuty. Both fire independently, each with its own cooldown.

Test the channel before you need it. Dispatch failures are reported in fired_alerts[].dispatch_reason, but only after a real breach. test_channel tells you now, and reports the real outcome rather than a hopeful 200.

Watch for test traffic. Because the evaluation aggregate counts test-mode signals, a burst from the dashboard test button or a sandbox key can trip events_per_hour and error_rate. If that matters, favour high_risk_count and mean_risk_score, which test signals rarely move.

Related from the blog