Alerts
alertsRoute SLO burn, downed dependencies, security detections and your own alerts to email, Slack, PagerDuty, Opsgenie or a webhook, once.
alertsFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
Route SLO burn, downed dependencies, security detections and your own alerts to email, Slack, PagerDuty, Opsgenie or a webhook, once.
At a glance
alerts:
min: warning
recipients:
oncall:
- { driver: pagerduty, key: secret:PAGERDUTY_ROUTING_KEY }
- { driver: slack, webhook: secret:SLACK_ALERTS_WEBHOOK }
routes:
- { match: { severity: critical }, to: [oncall], escalate: [{ after: 15m, to: [oncall] }] }
- { to: [oncall] }
quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata }import { alerts } from "mainsdk";
// one message per open key, however often it is raised; routes in main.yaml pick who hears it
await alerts.raise({ key: "import-failed", severity: "warning", title: "Nightly import failed", runbook: "https://acme.dev/runbooks/import" });
await alerts.resolve("import-failed");lib/alerts.test.ts
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: `schema: 1
project: prj_alerts
alerts:
recipients:
oncall:
- { driver: slack, webhook: secret:SLACK }
routes:
- { to: [oncall] }
`,
secrets: { SLACK: "https://hooks.slack.com/services/T0/B0/xyz" },
});
await app.alerts.raise({ key: "import-failed", severity: "warning", title: "Import failed" });
await app.alerts.raise({ key: "import-failed", severity: "warning", title: "Import failed" });
const open = await app.alerts.list({ state: "open" });
assert.equal(open.items.length, 1); // the second raise folded into the first
assert.equal(open.items[0]?.count, 2);Drivers
1 drivers. Select one to see its line in main.yaml.
alerts:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-ALERT-0001 | Checkmainsdk check, before deploy | An alerts value is invalid: an unknown driver, source or severity, a route to an undeclared recipient, a quiet-hours time or time zone that doesn't parse, or an escalation after shorter than group.wait |
MAIN-ALERT-0002 | Checkmainsdk check, before deploy | Production has an alerts section, but no route sends critical alerts to a recipient |
MAIN-ALERT-0003 | Checkmainsdk check, before deploy | A webhook recipient's host isn't in network.allow, or a recipient key or webhook URL is written as a literal |
MAIN-ALERT-0010 | RuntimeError responses and logs | An alert was raised or resolved; the log record carries the alert |
MAIN-ALERT-0011 | RuntimeError responses and logs | A recipient failed after its last retry; the others were still sent to |
MAIN-ALERT-0012 | RuntimeError responses and logs | The runtime store didn't answer; dedupe and escalation fell back to this instance |
MAIN-ALERT-0013 | RuntimeError responses and logs | alerts.raise refused or trimmed an input: a key over 200 characters, more than 20 attrs, or an attr outside telemetry@1 |
MAIN-ALERT-0014 | RuntimeError responses and logs | alerts.ack refused: the caller lacks alerts.manage (403) |
MAIN-ALERT-0015 | RuntimeError responses and logs | A recipient passed max-per-hour; the rest of the hour arrives as one summary |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| alerts section in main.yaml with its checks | Phase 1 | 0.4 | alerts#contract |
| raise, resolve, ack and list with dedupe by key | Phase 1 | 0.4 | alerts#core |
| Routes, grouping, repeat and storm limits | Phase 1 | 0.4 | alerts#routing |
| Quiet hours and escalation | Phase 1 | 0.4 | alerts#quiet-escalation |
| Slack, PagerDuty, Opsgenie, webhook and email recipients | Phase 1 | 0.4 | alerts#drivers |
| Delivery as jobs off the request path | Phase 1 | 0.4 | alerts#delivery |
| Health, security, resources, jobs and error sources (the backup source is planned) | Phase 1 | 0.4 | alerts#sources |
| Routes in mainsdk info and mainsdk alerts test | Phase 1 | 0.4 | alerts#cli |
MainSDK Cloud
Everything above is free in your app. Cloud adds these on top, by plan:
| Plan | What it adds |
|---|---|
| Fleet | Alert routing to owners across every app, with agent fix pull requests (spec fleet-observe) |
Quick start
alerts:
min: warning
recipients:
oncall:
- { driver: pagerduty, key: secret:PAGERDUTY_ROUTING_KEY }
- { driver: slack, webhook: secret:SLACK_ALERTS_WEBHOOK }
routes:
- { match: { severity: critical }, to: [oncall], escalate: [{ after: 15m, to: [oncall] }] }
- { to: [oncall] }
quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata }import { alerts } from "mainsdk";
// one message per open key, however often it is raised; routes in main.yaml pick who hears it
await alerts.raise({ key: "import-failed", severity: "warning", title: "Nightly import failed", runbook: "https://acme.dev/runbooks/import" });
await alerts.resolve("import-failed");Configuration
See the alerts.* rows in main.yaml reference. mainsdk check reports an invalid value as MAIN-ALERT-0001, a production app with no route for critical alerts as MAIN-ALERT-0002, and a literal key or a webhook host outside network.allow as MAIN-ALERT-0003.
What you get
- One message per open alert, however many instances raise it
- Critical alerts go at once; others wait out quiet hours as a digest
- Never on the request path; sends run as jobs with retries
- No personal data in an alert; provider dedupe keys are keyed hashes
Calls
| Call | What it does |
|---|---|
alerts.raise({ key, severity, title, attrs?, runbook? }) | Opens an alert, or updates the open one with the same key, and routes it once. |
alerts.resolve(key) | Closes the open alert with that key. |
alerts.ack(id) | Acknowledges an alert and stops its escalation; needs the alerts.manage permission. |
alerts.get(id) | Returns one alert. |
alerts.list({ state?, source?, cursor?, limit? }) | Pages through alerts, filtered by state or source. |
Sources
The SDK raises alerts from these sources. Limit them with alerts.from.
| Source | Raises | Key |
|---|---|---|
health | A dependency in health.critical is down (critical); SLO burn, fast (critical) or slow (warning) | dependency:<name>, slo:<slo>:<rule> |
resources | The instance enters shed (warning) | resources:shed |
security | Security events at high (warning) and critical (critical) | security:<type> |
jobs | A job moves to the dead-letter queue (MAIN-JOB-0002, warning) | job:<name> |
errors | A new error group or an error spike, from the error-reporting port | error:<fingerprint>, error-spike:<fingerprint> |
app | alerts.raise | the caller's key |
The backup source (a failed or overdue restore drill) is planned. alerts.from accepts the name, but nothing raises from it yet.
Health and resource alerts resolve when the cause clears. Security, job and new-error alerts never resolve themselves; a person acknowledges them.
Recipients and destinations
A recipient is a named group of destinations in alerts.recipients. Each destination uses one of five drivers: email, slack, pagerduty, opsgenie or webhook. Keys and webhook URLs are written as secret:NAME, never as literals (MAIN-ALERT-0003). A route sends an alert to recipients; an escalation sends an open, unacknowledged alert to another recipient after a set time. Quiet hours hold warnings and info as one digest; critical alerts always go at once.
Drivers
| Driver | Provider | |
|---|---|---|
builtin | Built in (sends through notify and email drivers) | default |
CLI
mainsdk alerts test oncall --env prod
mainsdk infoalerts test sends a test alert, marked as a test, through every driver of the recipient and prints each result. mainsdk info shows the recipients by name and driver (never their keys), the routes and the open alerts.
Errors and checks
alerts.raise and alerts.resolve never wait on a provider. Sends run as jobs with retries. If a recipient fails after its last retry, the others are still sent to (MAIN-ALERT-0011). If the runtime store does not answer, dedupe and escalation fall back to the instance (MAIN-ALERT-0012) and the app keeps serving.
MAIN-ALERT-0001MAIN-ALERT-0002MAIN-ALERT-0003MAIN-ALERT-0010MAIN-ALERT-0011MAIN-ALERT-0012MAIN-ALERT-0013MAIN-ALERT-0014MAIN-ALERT-0015
Each code's cause and fix is in check codes.
Swapping drivers
alerts has one driver, builtin, which sends through your notify and email drivers. To change where an alert goes, change the destination under a recipient (email, slack, pagerduty, opsgenie or webhook) and keep the calls as they are.
alerts:
recipients:
oncall:
- { driver: opsgenie, key: secret:OPSGENIE_KEY } # was pagerdutySee Swap a driver.
Testing
createTestApp runs alerts on the fake clock and captures outbound requests, so no provider is called. A raise with the key of an open alert folds into it.
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
const app = createTestApp({
yaml: `schema: 1
project: prj_alerts
alerts:
recipients:
oncall:
- { driver: slack, webhook: secret:SLACK }
routes:
- { to: [oncall] }
`,
secrets: { SLACK: "https://hooks.slack.com/services/T0/B0/xyz" },
});
await app.alerts.raise({ key: "import-failed", severity: "warning", title: "Import failed" });
await app.alerts.raise({ key: "import-failed", severity: "warning", title: "Import failed" });
const open = await app.alerts.list({ state: "open" });
assert.equal(open.items.length, 1); // the second raise folded into the first
assert.equal(open.items[0]?.count, 2);Next
Was this page helpful?