Uptime monitoring
You want to know when one of your apps is down before your users tell you. Health checks inside a stack say whether a replica answers; they cannot see a broken DNS record, an expired certificate, a tunnel that stopped, or an ingress that routes nowhere. An uptime monitor checks what users see, from outside the app, every interval, and tells your notification channels once when it goes down and once when it comes back.
Every app with a served domain gets a monitor of its own (app-<name>)
within a minute, so with a channel that hears monitor.* there is nothing
else to set up:
isb secret create OPS_HOOK <<<'https://hooks.slack.com/services/...'
isb notify create ops --slack OPS_HOOK --events 'monitor.*,health.*'
The web UI's Uptime section (under Org) lists every monitor with its status, 24 hours of uptime bars, a latency sparkline and recent incidents; each app's Monitoring tab shows the monitors watching it, and the org's overview shows a banner while any monitor is down.
What a monitor checks#
| Type | Checks | Up when |
|---|---|---|
app |
an app, by name: its served domain's public URL (the first, or domain), with path |
the answer's status is in expected_status and the keywords hold |
http |
any url (http:// or https://) |
the same |
tcp |
host and port |
a connection opens |
An app monitor follows the app: rename its domain, add one, scale it, and
the monitor checks what is served now. With no domain it checks the app's own
endpoint instead: its published port, else a replica in rotation (and with no
replica in rotation it is down, saying so).
HTTP checks take:
| Field | Default | |
|---|---|---|
method |
GET |
or HEAD |
expected_status |
200-399 |
codes and ranges: 200, 200,204, 200-299,301 |
keyword, keyword_absent |
none | text the body (its first 256 KiB) must, or must not, contain |
follow_redirects |
false |
follow up to 5; otherwise a 3xx is judged as it is |
headers |
none | [{name, value}] or [{name, secret}]: a secret's value is read at each check and never shown |
cert_expiry_days |
14 |
monitor.cert_expiring this many days before an HTTPS certificate expires, once per certificate; 0 never |
And every monitor:
| Field | Default | |
|---|---|---|
interval |
60 |
seconds between checks, at least 30 |
timeout |
10 |
seconds a check may take (connect, TLS, answer), under the interval |
failure_threshold |
2 |
failed checks in a row that make it down |
recovery_threshold |
2 |
successful checks in a row that make it up again |
paused |
false |
a paused monitor is not checked; its history stays |
# Over REST (or MCP): any URL, with a keyword.
curl -H "Authorization: Bearer $ISB_TOKEN" -H 'Content-Type: application/json' \
-d '{"name": "docs", "type": "http", "url": "https://docs.example.com/", "keyword": "Welcome"}' \
https://isb.example.com/orgs/acme/api/v1/tools/monitor_create
Pending, down, up, and nothing in between#
- A new monitor starts pending and stays so until its first successful
check. Checks that fail before that are pending checks: shown grey in the
UI ("Waiting for the first successful check"), never an incident, never a
monitor.down, not counted towardfailure_threshold, and left out of uptime percentages and the uptime bars' red. The first success makes it up, without amonitor.up. After that the thresholds below apply as usual, so real downtime is never hidden. - An
appmonitor (including an app's own) does not even look at the app while it is pending and the app has no live deployment (no healthy replica in rotation); it records a pending "waiting for the app's first live deployment" check instead. Once it has been up, a failed rollout that leaves the old revision serving does not page, and a real outage does. - A monitor still pending with only failures after 30 minutes is flagged never came
up (
never_up: trueinmonitor_listandmonitor_get; shown red as "Never came up"). It opens an incident dated from its first check and sends onemonitor.downsaying so; it stays pending, and its first success sendsmonitor.upand closes the incident. failure_thresholdfailures in a row make a monitor down: channels getmonitor.downonce, and an incident opens, dated from the first failure.recovery_thresholdsuccesses in a row make it up: channels getmonitor.upwith how long it was down, and the incident closes. One success between failures does not count (hysteresis).- Notifications alternate: never two downs or two ups in a row, and an up only after a down was sent. A new monitor's first success is not news.
- A monitor that went down 3 times within 30 minutes is flapping: the down that makes it so is sent (saying so), then nothing until it has held one state for 30 minutes; then the state it settled in is sent, if channels last heard otherwise. Incidents are still recorded meanwhile.
- State lives in the history database, so a daemon restart sends nothing twice.
The notification#
monitor.down, monitor.up and monitor.cert_expiring are event kinds like
any other (Event kinds). An app monitor's
events are about the app's service (so a rule's apps and projects filters
match them); other monitors' are about the stack @monitors, service = the
monitor. The webhook body carries the details:
{
"id": "1791068703714-2",
"org": "acme",
"kind": "monitor.down",
"level": "error",
"stack": "shop-production",
"service": "web",
"project": "shop",
"message": "Monitor app-web is DOWN: https://shop.example.com/: HTTP 503 (expected 200-399) (2 failed checks in a row)",
"details": {
"monitor": "app-web",
"type": "app",
"app": "web",
"url": "https://shop.example.com/",
"status": 503,
"latency_ms": 32,
"error": "HTTP 503 (expected 200-399)",
"via": "public",
"checked_at": 1791068703620,
"down_since": 1791068672596,
"failures": 2,
"flapping": false,
"link": "https://isb.example.com/orgs/acme/uptime/app-web"
},
"at": 1791068703655,
"seq": 20,
"test": false
}
monitor.up adds downtime_ms and downtime (1m 31s);
monitor.cert_expiring adds cert_expires_at (unix seconds) and
cert_days_left. Slack, Discord, Telegram and email get the message with the
latency and the link. The link needs --public-url. URLs in messages and
details never carry their query string, which may hold a token.
Apps' own monitors#
Every app with a served domain gets app-<name>: GET its first domain,
200-399, every 60 s, down after 2 failures. Edit it like any other monitor
(it stays the app's). It goes away when the app does, or loses its domains.
Deleting it adds the app to the org's exclusions so it does not come back;
monitor_settings turns the whole thing off (auto_monitors: false) or edits
the exclusions (exclude_apps).
Behind Cloudflare Access#
A domain behind Cloudflare Access answers a sign-in
redirect, not the app. isb recognises the redirect (to
*.cloudflareaccess.com) and never counts it as up:
- Give monitors a service token (recommended): create an Access service
token allowed by the application's policy, and store it as the org secrets
CF_ACCESS_CLIENT_IDandCF_ACCESS_CLIENT_SECRET. Everyappmonitor that sets no Access headers of its own presents them, so the check goes through Access to the app, as users do. Any monitor can also name them inheaders(CF-Access-Client-Id,CF-Access-Client-Secret). - Without a token, an
appmonitor checks the app's own endpoint instead and says so in its last check (via,note); anhttpmonitor is down with "redirected to Cloudflare Access sign-in", since that is all it can see.
Where checks run, and what they may reach#
Checks run in the daemon that runs the org's apps: isb serve on a single
host; for an org placed on a server, that server's agent (the
monitor_* tools follow the org there like every other org tool, and so do
its monitors, history and channels). The agent sees the app's real URL from
the network the app lives on, and keeps checking and notifying if the control
plane is down. What the agent cannot report is its own death: the control
plane raises server.unreachable when a server stops answering its heartbeat
(3 misses in a row, 10 s apart) and server.recovered when it answers again, and a
heartbeat covers a host that dies outright.
- A scheduler hands due checks to 8 workers through a bounded queue, so slow targets never pile up threads and a check never overlaps itself. First checks are spread by name within a minute; then every interval plus a little jitter.
- URLs and hosts a member types are held to the platform's address policy,
the one notification channels
use: every address a name resolves to must be public, the connection goes
to a checked address, and redirects are checked again. A platform admin's
notification_settingsallow_private_targetsrelaxes it for monitors too. - An app's own endpoint (a replica, a published port) is found by reference,
not typed, so it is reached directly whatever the policy. An
appmonitor whose domain resolves to a private address while private targets are refused checks that endpoint instead, and says so.
History#
Per org, in <state>/orgs/<org>/monitors/monitors.db (SQLite): every check
for 7 days, hourly rollups (checks, successes, latency p50 and p95) for 90
days (pending checks are kept but never counted), incidents for 90 days after
they end. A monitor checked every 30 s keeps
about 20,000 rows. monitor_list and monitor_get report uptime over 24 h,
7 d and 30 d and latency p50/p95 over 24 h; monitor_checks returns buckets
over 1h, 24h, 7d, 30d or 90d and the latest raw checks. Definitions
are monitors.json and settings.json beside it.
Host down: a dead man's switch#
No event from the daemon can say the host died. Point isb serve at an
outside "ping" check (healthchecks.io, Better Stack, Cronitor, an Uptime Kuma
push monitor) and it GETs the URL every interval; when the pings stop, that
service alerts you.
# The URL's path is the check's token: keep it out of argv and logs.
ISB_HEARTBEAT_URL=https://hc-ping.com/<uuid> isb serve ...
| Flag | Environment | Default | |
|---|---|---|---|
--heartbeat-url |
ISB_HEARTBEAT_URL |
off | the URL to GET; logs name only its host |
--heartbeat-interval |
ISB_HEARTBEAT_INTERVAL |
60s |
10 s to 1 h; set the outside check's period a little longer |
A 2xx or 3xx answer counts. The daemon logs when the heartbeat starts and stops working. Agents take the same flags, so each server can have its own check.
Tools#
| Tool | Who | Does |
|---|---|---|
monitor_create |
member | A monitor: name, type, and the fields above. |
monitor_list |
viewer | Every monitor with status, last check, uptime, latency, bars and sparkline; down, the org's recent incidents, and settings. |
monitor_get |
viewer | One monitor, with its last 20 incidents and checks. |
monitor_update |
member | Change fields (null puts one back to its default). |
monitor_delete |
member | The monitor and its history; an app's own one excludes the app. |
monitor_pause, monitor_resume |
member | Stop and start checking. |
monitor_checks |
viewer | History over range (1h, 24h, 7d, 30d, 90d): buckets, uptime, raw checks. |
monitor_settings |
member | auto_monitors, exclude_apps. |
Every change is in the audit log.
Not yet#
- A public status page per org.
- Checks from more than one place (a monitor runs where the org's apps run).
isb monitorCLI commands: use the tools over REST or MCP, or the web UI.