Alerts
An alert is how Measured Cloud tells you something went wrong. Three model rows back it: an AlertRule is the configuration ("on every failure of the Nightly orders validation, post to Slack"); an Alert is one firing — a rule fires at most once per execution run; an AlertEvent is the dispatch log of that firing (the message it sent, or failed to send). Every rule's firings are listed in its Alert history, each linked to its run.
How alerts work
An alert rule answers four questions:
- What does it watch? One or more chosen validations, all validations, or the whole workspace.
- When does it fire? On a failed run, a passed run, every run, or a detected anomaly.
- Which failures matter? The rule severities it fires on: any mix of Info, Warning, and Critical (Critical by default). This lets you page on-call only for Critical failures and send Warnings to a Slack channel.
- Where does it go? One channel per rule: Slack, email, Microsoft Teams, a webhook, PagerDuty, Jira, Linear, ServiceNow, Opsgenie, and more. To send one failure to two places, create two rules with the same scope.
Create rules on the Alerts page with Add alert rule. Each rule is listed by its name, for example Nightly orders · Slack on failure. Send test checks the destination without touching real data. When an execution run finishes, every rule that matches fires once for that run, and each notification it sends, or fails to send, is recorded in the Alert history. Open Alert history on the Alerts page for every rule at once, or click a rule to see only its own. See Set up Slack or email alerts for a step-by-step walkthrough.
The rest of this page is the reference: the stored fields, every channel's payload, and how routing and deduplication behave.
Data model
AlertRule (per-tenant):
| Column | What it stores |
|---|---|
scope_type | What the rule watches: specific (one or more chosen validations), all_validations, or workspace (the default). The alerts list shows it as the validation's name, "N validations" (hover for the names), All validations, or Workspace. |
validation_ids | The validations a specific rule targets (1..N, via the alert_rule_validations join). Empty for the other two scopes. The legacy single checkpoint_id / rule_group_id scope columns are retired and always NULL. |
trigger | on_failure / on_success / always / on_anomaly. |
channel | create_incident (internal) / email / slack / teams / webhook / pagerduty / jira / linear / discord / mattermost / twilio_sms / datadog / jira_ticket / servicenow / github_issues / opsgenie. |
destination | A JSONB blob with channel-specific config (Slack webhook URL, email addresses, PagerDuty routing key…). Sensitive fields are encrypted at rest. Empty ({}) for the internal create_incident action — it opens a native incident rather than notifying an external service. |
enabled | Toggle. |
severities | The explicit set of severities the rule fires on — any non-empty combination of info / warning / critical (a multi-select in the form; the alerts list's Severities column shows exactly the selected ones). The rule fires only when the result's severity is a member of the set — it is not a minimum. Default: ["critical"]. This is the three-level alert scale, derived from result pass/fail percentages — distinct from the anomaly severity scale described in Anomalies. (#154 reduced the scale from four levels to three, folding the old error tier into critical.) |
min_severity | Deprecated — the old "this level or higher" floor, superseded by severities (#146). Still accepted on write (it's converted to the equivalent set) and returned on read for back-compat, but it no longer routes anything and the UI no longer shows it. |
is_system_default | true on the workspace's default incident rule. It can be turned on or off (enabled) but not edited (409) or deleted (409). |
mentions | Chat mentions (Slack / Teams). Slack mentions are stored as member / user-group IDs (<@U…> / <!subteam^S…>) and picked by name in the form when a Slack bot token is connected — see Show Slack display names in mentions. |
Alert (per-tenant): one row per (rule, execution run), written when the rule fires. The unique (alert_rule_id, execution_run_id) pair is what stops a retried or concurrently finalised run from firing a rule twice.
| Column | What it stores |
|---|---|
alert_rule_id | Which rule fired. |
execution_run_id | The one run it fired for. |
workspace_id / team_id / team_restricted | Copied from the run. |
external_ticket_id / external_ticket_url | The ticket a ticketing channel opened for this firing (NULL otherwise). |
fired_at | When it fired. |
AlertEvent (per-tenant), the dispatch log:
| Column | What it stores |
|---|---|
alert_rule_id | Which rule fired. |
alert_id | The Alert this dispatch belongs to. NULL for an anomaly-driven event (anomalies have no execution run, so they are logged as events only) and for a run from before checkpoints were retired that has no execution run. |
validation_result_id | The run's result that triggered the rule (NULL for anomaly-driven events). |
anomaly_event_id | The anomaly that triggered the rule (NULL for result-driven events). |
status | pending / sent / failed. |
sent_at / error | Delivery metadata. |
external_ticket_id | Upstream record identifier when the channel is ticketing-shaped (Jira issue key, ServiceNow INC… number, GitHub #N, Opsgenie alias). NULL for chat channels. |
external_ticket_url | Operator-clickable URL into the upstream service. NULL for chat channels. |
external_ticket_status | Upstream's state string forwarded verbatim. NULL for chat channels. |
Channels
| Channel | Status |
|---|---|
| Create incident (internal action) | shipped (#656) — not offered for new rules while incidents are hidden (#159); existing rules on it keep working and still show their channel |
| Email (SMTP) | shipped — dispatcher in worker/.../dispatch_alert.py |
| Slack incoming webhook | shipped — dispatcher in worker/.../dispatch_alert.py |
| Microsoft Teams | shipped (FEATURE-15) |
| Generic webhook (signed) | shipped (FEATURE-16) |
| PagerDuty | shipped (FEATURE-17) |
Jira — legacy (channel jira) | shipped (FEATURE-28) — superseded by jira_ticket below. Per #727 it is no longer offered for new rules; the Edit modal still surfaces it (labelled Jira (legacy)) for rules already on it, so existing rules keep working |
| Linear | shipped (FEATURE-28) |
| Discord | shipped (issue #81) |
| Mattermost | shipped (issue #81) |
| Twilio SMS | shipped (issue #81) |
| Datadog Events | shipped (issue #81) |
Jira (NotifierAdapter, channel jira_ticket) | shipped (FEATURE-82) — the recommended Jira channel, shown in the UI simply as Jira |
| ServiceNow | shipped (FEATURE-82) |
| GitHub Issues | shipped (FEATURE-82) |
| Opsgenie | shipped (FEATURE-82) |
All channels share the same dispatch contract — a NotifierAdapter subclass in dq_shared/notifiers/ validates the destination at write time, redacts bearer secrets on read, and builds the channel-native payload at dispatch time. Adding a new channel is one new file plus its tests. The one exception is the internal Create incident action below, which writes to the local database rather than POSTing anywhere and so has no NotifierAdapter.
Create incident (internal action)
The create_incident channel (#656) is the one internal action: when the rule fires it opens a native Incident in the app instead of notifying an external service. Incidents are hidden in the UI for the moment, so the channel picker no longer offers it for a new rule and the alerts list's By channel filter doesn't list it (#159). A rule already on it still renders (its edit form shows it under an Internal group) and still fires as described below.
A fired create_incident rule:
- opens an
Incidentwithsource = "alert"(the Incidents list's Source column shows it, distinct from a hand-createdmanualincident or the failed-runautoone),severitymapped from the result severity, a title derived from the failure (e.g.Nightly orders failed — 2 of 5 expectation(s) failed), andvalidation_result_idlinking back to the triggering run (plus the validation it belongs to); - carries no destination —
destinationis{}and no bearer secret is stored; - respects the rule's scope,
trigger, andseveritieslike every other channel. It writes itsAlertandAlertEvent, and opens the incident straight away rather than queuing a dispatch, so it works for agent-mode runs too; - takes its workspace and team from the run, and links the incident to the
Alert(incidents.alert_id).
Dedup / throttle. At most one open incident exists per validation across both sources (alert and the default rule's auto). An asset's or a rule's Run is a run of the asset's default validation, so it dedupes against that validation's incident; a legacy checkpoint run dedupes against the validation its checkpoint maps to. A repeated failure of the same validation while an open/acknowledged incident already covers it appends a timeline entry to that incident rather than opening a new one — so a 2-minute schedule can't spawn thousands. Resolution stays a manual action; auto-resolve-on-pass is a possible later option.
The default incident rule
Every workspace has one built-in alert rule, marked Default on the Alerts page (is_system_default). It fires on every failed validation run, at any severity (on_failure, all validations, Info + Warning + Critical) and its action is Create incident, so a failed run always opens (or adds to) an incident (source = "auto"). Org-wide validations have one more default rule of their own at the org level.
- Pause it to stop these incidents; Enable it to turn them back on. That is the only change it accepts: editing its trigger, action, scope or severities, or deleting it, is refused (
409). - A new workspace gets one when it is created. A workspace that has none (for example, one created at sign-up) gets one the first time one of its runs is checked for alerts.
- It replaced the old behaviour where the worker opened an incident on every failed run by itself. It behaves the same way, and it can now be switched off.
Ticketing channels
The Jira (jira_ticket), ServiceNow, GitHub Issues, and Opsgenie channels share the NotifierAdapter abstraction with the chat channels above but differ in one important way: a fired alert creates an upstream record with its own lifecycle, and the worker stamps the resulting identifier / URL / status onto the AlertEvent row (the three external_ticket_* columns) and the identifier / URL onto its Alert. A follow-up that resolves the ticket when the validation recovers is on the roadmap (bidirectional sync — out of scope for the initial release).
Jira (NotifierAdapter)
Channel value: jira_ticket. Required destination fields: site, project_key, email, api_token (encrypted). Optional: issue_type (default Task), labels (whitespace-bearing labels are silently dropped — Jira rejects those), custom_fields passthrough dict for required project-specific fields (story points, components, parent epic). POSTs to https://{site}/rest/api/3/issue with HTTP Basic auth (email:api_token).
This is the canonical Jira channel — the UI lists it simply as Jira, and it is the only Jira option offered when creating a new rule. The older FEATURE-28 jira channel above also creates an issue (same POST /rest/api/3/issue) but is a functional subset, so to avoid the confusing duplicate the new-rule picker called out in #727 no longer lists it. The Edit modal still shows it (labelled Jira (legacy)) for a rule already on the jira channel, so existing rules keep working — the jira_ticket adapter additionally tracks the created issue back onto the AlertEvent and supports custom_fields.
ServiceNow
Channel value: servicenow. Required: instance (subdomain like acme, or fully qualified acme.example.com), username, password (encrypted). Optional: table (one of incident / problem / cmdb_ci, default incident), urgency / impact (default 2), assignment_group sys_id, custom_fields passthrough. POSTs to https://{instance}.service-now.com/api/now/table/{table}. The response's result.number (the human-facing INC0012345) is persisted as external_ticket_id.
Authentication (#402) is selected by auth_mode:
auth_mode: "basic"(default) — HTTP Basic auth withusername/password. This is the original shipped behaviour; destinations that omitauth_modeuse it.auth_mode: "oauth"— ServiceNow OAuth (Resource-Owner-Password-Credentials grant). Adds two required fields:client_id(the registered OAuth application's client id — non-secret, returned on read) andclient_secret(encrypted at rest asclient_secret_encrypted, redacted to"configured"on read). At dispatch the worker first POSTs tohttps://{instance}/oauth_token.dowithgrant_type=passwordplusclient_id/client_secret/username/passwordto mint a bearer token, then calls the Table API withAuthorization: Bearer <token>. ServiceNow's OAuth API only offers the password grant for first-party integrations, so the username/password are still required — the client credentials scope the token to a registered OAuth application rather than authenticating directly. A missingclient_idorclient_secretis rejected with a 400 at write time, not a 500 at dispatch.
GitHub Issues
Channel value: github_issues. Required: owner, repo, token (encrypted — a classic PAT, fine-grained PAT, or GitHub App installation token). Optional: labels, assignees, milestone (integer). POSTs to https://api.github.com/repos/{owner}/{repo}/issues with Authorization: Bearer <token> and the API-version pin (X-GitHub-Api-Version: 2022-11-28). The body is markdown-rendered; the deep-link to the validation result auto-links.
Opsgenie
Channel value: opsgenie. Required: api_key (encrypted). Optional: region (us default, or eu for the EU API host), priority (P1-P5, default P3), responders (pass-through list of responder objects), tags. POSTs to https://api.opsgenie.com/v2/alerts with Authorization: GenieKey <api_key> — not Bearer <api_key> (Opsgenie's documented format; the wrong auth scheme is silently 401'd). The alias is dq-cloud-rule-<alert_rule_id>, so consecutive firings of the same alert rule coalesce into one open alert — same dedup posture as the PagerDuty channel below.
Generic webhook
A generic webhook channel POSTs a JSON envelope to an operator-supplied URL — handy for SIEM ingestion, ops dashboards, or any receiver that doesn't speak Slack-block-kit. Configure two fields on the destination:
url(required): where to POST. Stored encrypted at rest; the API only ever returns the sentinel string"configured"on read.signing_secret(optional): if set, every POST carries anX-DQ-Signature: sha256=<hex>header where the signature is the HMAC-SHA256 of the request body bytes. Also stored encrypted at rest.
Payload shape
{
"event": "validation_result",
"result_id": "<uuid>",
"checkpoint_id": "<uuid|null>",
"rule_suite_id": "<uuid|null>",
"success": false,
"failed_expectations": 3,
"total_expectations": 12,
"trigger": "on_failure",
"run_at": "2026-05-13T12:34:56+00:00",
"deep_link": "https://dq.example.com/results/<uuid>"
}
checkpoint_id is a legacy field: it is null for every validation run, including asset and rule runs, and set only on a result from a retired checkpoint. rule_suite_id is legacy too: rule suites were retired, so it is null for results written since then. deep_link is only present when the operator has configured PUBLIC_BASE_URL. The body is serialised with json.dumps(payload, separators=(",", ":")) so the bytes you sign are the bytes the receiver sees — re-serialising with default whitespace would land on a different signature.
Verifying the signature
The receiver should compute the HMAC over the raw body bytes before any JSON parsing, then compare with hmac.compare_digest:
import hashlib
import hmac
def verify(body_bytes: bytes, header: str, secret: str) -> bool:
if not header.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(header.split("=", 1)[1], expected)
Reject the delivery if verify(...) returns False. The absence of the header means the rule is unsigned — accept those only if your deployment expects unsigned deliveries.
Dispatch flow
- The worker writes a
ValidationResult(orAnomalyEvent). - The worker evaluates every matching
AlertRulein the same job. - For each match, an
AlertEventis written and adispatch_alertjob is enqueued. - The dispatcher posts to the channel, with retry-with-backoff up to 3 attempts.
- On success:
status = sent,sent_atset. On terminal failure:status = failed,errorpopulated.
Operations you can do
- Create / edit / delete a rule —
Alertsroute in the UI. Deleting a rule shows an Undo toast for a few seconds before the delete is sent, so an accidental delete is one click to reverse. (Schedule and rule-set deletes work the same way.) A rule that has already fired can be deleted: its Alert History entries and fired alerts are kept, just no longer linked to a rule. If something still blocks a delete, the API answers409with a message naming what holds it, and the app shows that message in a notice that stays even if you've moved to another page. - Send a test alert — fires a synthetic payload to the configured destination without touching real data.
- See per-event delivery status — every
AlertEventshows its status and last error. - Mute a rule by toggling
enabledto false; events still get logged but aren't dispatched.
Who can change alert rules. Reading alert rules needs
can_view_alert; creating, editing, pausing, or deleting one needscan_manage_alert— granted to editor, workspace admin, and super admin (the same tier that authors schedules). A viewer sees the rules and their delivery status but the Add / Edit / Pause / Delete controls render disabled with a "Requires …" tooltip, mirroring the server's 403. Because each rule's destination is a bearer secret (a webhook URL, API token, or routing key), the create / edit paths additionally require a recent password proof (step-up auth).
PagerDuty
The PagerDuty channel POSTs an Events API v2 trigger event to https://events.pagerduty.com/v2/enqueue. The destination stores one field — the routing key (PagerDuty also calls this the integration key), a 32-character hex string found in PagerDuty under Services → Integrations → Events API v2. The routing key is encrypted at rest under the tenant DEK on the same path Slack uses (HARDEN-15) and never echoed back to the UI.
Deduplication. The dedup_key Measured Cloud sends is dq-cloud-rule-<alert_rule_id>. PagerDuty uses this to coalesce consecutive firings of the same alert rule into a single open incident rather than minting a new page on every run. This is what on-call rotations typically want — one page per broken pipeline, not one page per scheduled run while the pipeline stays broken. The trade-off is that a fresh failure on an already-open incident does not re-page anyone until the prior incident is resolved (manually in PagerDuty, or, once it ships, via a resolve event on recovery — see FEATURE-17 for the open follow-up).
Alert rules created before checkpoints were retired that were scoped to a single checkpoint used dq-cloud-checkpoint-<checkpoint_id>; that scope column is retired, so every rule now uses the alert-rule key.
The payload's payload.summary includes the alert rule's name and the failed-expectation count, payload.severity is error, and payload.custom_details carries the asset, expectation counts, and — if PUBLIC_BASE_URL is set — a deep_link back to the failing ValidationResult in the Measured Cloud UI.
Jira
The Jira channel (FEATURE-28) opens an issue every time a rule fires. The destination stores six fields:
| Field | What it stores |
|---|---|
site | The Jira Cloud host, e.g. acme.atlassian.net. The worker builds https://{site}/rest/api/3/issue from this. |
project_key | The target project's short key, e.g. DQ. |
issue_type | The issue type to create (defaults to Task). |
email | The Atlassian account email used as the HTTP Basic username. |
api_token | The matching API token (the HTTP Basic password). Encrypted at rest under the tenant DEK; never echoed back to the UI. |
labels | Optional list of labels to apply on create. Labels containing whitespace are dropped silently (Jira rejects them). |
Token instructions
Mint an API token under id.atlassian.com → API tokens. Atlassian Cloud documents the HTTP Basic auth scheme — username is your account email, password is the API token — under Basic auth for REST APIs.
Payload shape
The worker POSTs the v3 Issue REST payload. description is an Atlassian Document Format (ADF) object — not the plain-text body the v2 REST API took — because Jira Cloud's v3 endpoint refuses string descriptions.
{
"fields": {
"project": {"key": "DQ"},
"summary": "Measured Cloud · checkpoint FAILED",
"description": {
"type": "doc",
"version": 1,
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Checkpoint status: FAILED\nTotal expectations: 12\nFailed: 3\n..."}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "Open in Measured Cloud: "},
{"type": "text", "text": "https://dq.example.com/results/...", "marks": [{"type": "link", "attrs": {"href": "https://dq.example.com/results/..."}}]}
]}
]
},
"issuetype": {"name": "Task"},
"labels": ["data-quality"]
}
}
The deep-link paragraph is only present when PUBLIC_BASE_URL is set.
Linear
The Linear channel (FEATURE-28) opens an issue via Linear's GraphQL API. The destination stores three fields:
| Field | What it stores |
|---|---|
team_id | Linear team UUID. Find it under Settings → Workspace → Teams → API. |
api_key | Linear personal API key. Encrypted at rest under the tenant DEK; never echoed back to the UI. |
priority | Optional override for the issue priority (0 = no priority, 1 = urgent, 4 = low). When unset, priority tracks the failure severity. |
Token instructions
Mint a personal API key under Linear Settings → API. Linear's auth header is the bare key — not Bearer <key>, contrary to most OAuth-style APIs. The Measured Cloud dispatcher sends Authorization: <api_key> accordingly. Wrapping with Bearer is the single most common bug; Linear answers 401 in that case.
Payload shape
The worker POSTs a GraphQL issueCreate mutation to https://api.linear.app/graphql:
{
"query": "mutation IssueCreate($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id identifier url } } }",
"variables": {
"input": {
"teamId": "00000000-0000-0000-0000-000000000000",
"title": "Measured Cloud · checkpoint FAILED",
"description": "Checkpoint status: FAILED\nTotal expectations: 12\nFailed: 3\n...\n\nOpen in Measured Cloud: https://dq.example.com/results/...",
"priority": 2
}
}
}
The Open in Measured Cloud: line is only appended when PUBLIC_BASE_URL is set.
Severity mapping
When the operator hasn't pinned a priority, the dispatcher derives it from the validation-result severity (see Severity routing below):
| Severity | Linear priority |
|---|---|
info (all passed) | 4 — Low |
warning (>0% failed) | 3 — Medium |
critical (>50% failed, or every expectation failed) | 1 — Urgent |
Severity routing
Every AlertRule carries a severities set (#146 — it replaced the FEATURE-18 min_severity floor). When the worker evaluates rules against a ValidationResult it first grades the result on a three-level scale (#154 collapsed the former four-level ladder, folding the old error band up into critical):
| Result shape | Severity |
|---|---|
| All expectations passed (or a passing zero-expectation run) | info |
| >0% of expectations failed | warning |
| >50% of expectations failed, every expectation failed, or a failing zero-expectation run | critical |
A rule fires only when the computed severity is one of the rule's selected severities — explicit set membership, not "this level or higher". A rule set to just Warning fires on partial failures but stays quiet when the whole run is critical; select Warning and Critical to get both.
Routing by severity
This lets a single validation fan its noisy and its on-call traffic to different destinations — one rule per channel, each firing on exactly the severities it cares about:
| Channel | trigger | severities | Why |
|---|---|---|---|
| PagerDuty | on_failure | critical | Page on-call only when the whole run is broken. |
Slack #data-warnings | on_failure | warning | Catch partial failures during business hours — without double-posting the critical ones PagerDuty already covers. |
| Email (digest mailbox) | always | info, warning, critical | Audit trail of every run. |
The default set is ["critical"], which preserves the pre-FEATURE-18 behaviour for rules written before severity routing shipped. A legacy rule that only carries the old min_severity floor reads as the equivalent set (a warning floor means warning + critical).
Alert history
The Alerts page has a collapsible Alert history card below the rules list: every firing across all of the workspace's alert rules, newest first, with its status (Sent / Failed / Pending), channel, time, and — for a failure — the redacted error. Each firing is labelled with its rule's name — the same name the rule's own page is titled with, <scope> · <Channel> <trigger> (e.g. Nightly orders · Slack on failure), linked to that rule — plus fired by the validation whose run triggered it, linked to that validation, and a View run link to the execution run it fired for. (An alert rule has no free-text name of its own; its name is derived from what it watches and where it sends.) A single rule's firings live in the Alert History card on that rule's page. A firing on a run you can't see — a team-restricted run of another team — is left out of both, even when the rule itself is workspace-wide (the default incident rule, say).
Over the API it's GET /api/v1/organizations/{org_id}/workspaces/{ws_id}/alert-rules/events (viewer+, opt-in cursor pagination). Each row carries the parent rule's rule_trigger / rule_channel / rule_scope_type / rule_validation_ids, the resolved rule_validation_names, and the firing's own validation_id / validation_name (null when the event didn't come from a validation run), plus its alert_id and execution_run_id (null for an anomaly firing). Names are only returned for validations you can see.
Discord
The Discord channel (issue #81) POSTs to a Discord Incoming Webhook URL. Mint one under Server Settings → Integrations → Webhooks → New Webhook, then paste the URL into the rule's destination.webhook_url. The URL is encrypted at rest under the tenant DEK on the same path Slack uses; the API only ever returns the sentinel string "configured" on read.
The payload is Discord's native shape — a single embeds[0] block with title / description / color (and url when PUBLIC_BASE_URL is set). The severity colour maps to the three-level alert scale: blue for info, amber for warning, red for critical. Operator-supplied mention strings (e.g. <@USER_ID>, <@&ROLE_ID>) flow through destination.mentions and render in the top-level content field so they actually ping.
Mattermost
The Mattermost channel (issue #81) targets a self-hosted Mattermost's Slack-compatible Incoming Webhook endpoint. Configuration mirrors Discord — paste the webhook URL into destination.webhook_url; it's encrypted at rest. The payload is the Slack-style attachments envelope (with color, title, title_link, fallback, and text) rather than Block Kit because Mattermost's Block Kit support is partial across versions.
Twilio SMS
The Twilio SMS channel (issue #81) POSTs to /2010-04-01/Accounts/{sid}/Messages.json with HTTP Basic auth (account_sid as the username, auth_token as the password). The destination stores four fields:
| Field | What it stores |
|---|---|
account_sid | Twilio Account SID (AC…). Not a secret in isolation — it appears in the URL — but kept on the destination for symmetry. |
auth_token | The matching auth token. Encrypted at rest under the tenant DEK; never echoed back to the UI. |
from_number | The Twilio-owned From number in E.164 format. |
to_number | The recipient number in E.164 format. SMS is point-to-point; fan-out to multiple recipients requires multiple alert rules. |
SMS bodies are limited to 1600 chars (Twilio splits long messages into multiple segments and bills per segment); the adapter truncates at 1500 to leave headroom for an "Open: <url>" tail that always survives the truncation. Twilio rejects requests with delivery errors via 4xx — those re-raise so ARQ's retry-with-backoff kicks in.
Datadog Events
The Datadog Events channel (issue #81) POSTs to /api/v1/events with the DD-API-KEY header. The destination stores three fields:
| Field | What it stores |
|---|---|
api_key | Datadog API key (under Organization Settings → API Keys). Encrypted at rest under the tenant DEK. |
site | Datadog region host. Default api.datadoghq.com (US1); other supported values are api.datadoghq.eu (EU1), api.us3.datadoghq.com, api.us5.datadoghq.com, api.ap1.datadoghq.com. The value must be a bare host starting with api. — pasting a full URL or a typo'd prefix is rejected at write time. |
tags | Optional list of key:value tag strings appended to every event. The dispatcher always emits source:dq-cloud and trigger:<rule.trigger> in addition to whatever's configured here. |
Datadog Events have three alert types (info / warning / error); our three-level scale (#154) maps info → info, warning → warning, and critical → Datadog's error (its most severe Event type — there's no tier above it). Operators who want true urgency tiers should use Datadog Monitors and target a webhook channel back into Datadog's notification system.