Health checks
Runlot compares your project's memory, CPU, database, AI calls and budget with the limits they will hit, and tells you before one of them stops the app. Read it with `runlot health`, in the dashboard, or from an AI agent.
An app usually fails at a limit: it runs out of memory, one request holds the CPU, the database stops taking more work, or the organization's budget runs out. Runlot already records the numbers that lead up to each of these. Health checks compare those numbers with the limits they will hit, so you hear about a risk while you can still act on it.
The checks run on every plan and need no setup.
Reading it
runlot healthslowplay · checked 2 min ago · 2 risks
ALERT App memory at 93% of its memory limit
peak 238 MiB of 256 MiB (3d)
Next: call dispose() on RPC results (or `using`); compare heap snapshots
Infrastructure: https://dash.runlot.io/o/acme/p/slowplay/observability/infrastructure?range=3d
WARN POST /api/sessions/:id/debrief/stream: JS CPU p99 1.8 s per request
p99 1.8 s (24h)
Next: profile this route; move the work to a queue
12 other checks ok · 1 not run: db_memory (the database was not running in the last hour)Each open risk has a level, one sentence about what is close to which limit, the numbers behind it, what to do next, and a link to the chart that shows it. The last line counts the checks that passed and names the ones that could not run.
The same risks appear on the project's overview in the dashboard. When nothing is open, the dashboard still shows one line with the time of the last check, so you can tell "checked and fine" from "not checked".
Warn, alert, recover
| Level | Means | Where you see it |
|---|---|---|
| warn | A trend or thin headroom: at this rate the app reaches a limit, but not today | Dashboard, runlot health, the weekly digest |
| alert | A failure is likely within a day, or has already started | All of the above, and an email to the organization's admins |
- A risk is one standing entry, not a stream of messages. It stays open while the condition holds; its numbers update in place.
- An alert is emailed once, when the risk opens at alert or moves from warn to alert. Nothing is sent while it stays open, and nothing when it recovers.
- A risk closes when the value has been back under a lower bar for a while (for memory, three days). A value that hovers at the line keeps the risk as it is instead of opening and closing it.
- Deploys do not open risks. Readings taken while two versions overlap during a deploy, stops you or the platform planned, and the first minutes after a start (when the app compiles its code) are not judged.
- An alert email goes to the organization's admins, and each admin's error-mail setting for the project applies. For Slack, a webhook or PagerDuty, create a monitor for the same metric.
The checks
Each check has an id. You see the id in runlot health --json and in the API.
| Id | Compares |
|---|---|
app_memory | The app's peak memory with its memory limit, day by day, and how fast it grows after a start. A deploy whose app starts at half its limit or more is a warning straight away. |
app_restarts | Restarts you did not ask for (out of memory, a crash, a failed start) in the last 24 hours. Deploys, parking and stops do not count. |
db_memory | Memory pressure on the project's database. It predicts slow queries, not a restart. |
app_cpu | The app's CPU in its busiest minutes with one core. Your code runs on one thread, so one core is the most an app can use. |
request_cpu | CPU per request, per route, with the 60-second limit for one request. A request that holds the CPU for a second makes every other request wait that second. |
app_stall | Times the app stopped answering requests while using its whole core, and was restarted. |
busy_latency | A route's slowest requests (p95) in the busiest hour of the day against its quieter hours. When it is much slower only when busy, requests are queueing. |
db_admission | Whether any env.db call gave up after waiting 10 seconds for a connection or hit the 10-second statement limit in the last 24 hours, and how much of a CPU core the database used in its busiest minutes. |
rpc_not_disposed | The share of requests that log An RPC result was not disposed properly or An RPC stub was not disposed properly. With rising memory, this is usually the cause. |
ai_refusals | The share of env.ai calls refused for rate or concurrency, or because no lent desktop could take the call. |
org_budget | The organization's requests, CPU time and spend with the plan's allowance and your spending limit, at the last 24 hours' rate. When it runs out, every project of the organization answers 429 budget_exhausted. |
db_size | The database's size with the plan's database limit, and when it will reach it at the current growth. |
file_storage | Stored files with the storage quota, and when they will reach it. |
log_cap | Log events with the project's daily log-event cap. Past the cap, logs are sampled, and you lose detail you may need later. |
cron_overlap | Cron runs skipped because the previous run was still going, and runs that take most of the time until the next one. |
A check is ok when it was measured and is fine. unknown means it was not measured, and the reason says why: the project has too little data yet (several checks need three days), the data the check reads is not there (no env.db calls, no AI calls), or the checks have not run for this project yet. Treat unknown as "no answer", not as "fine".
runlot health
runlot health [dir] # the project in runlot.json, or --org/--project
runlot health --json # the server's answer, unchanged
runlot health --all # every project of the organization, one line each
runlot health --fail-on warn # exit 1 when anything is at warn or alert| Option | What it does |
|---|---|
--json | Prints the API's answer as it is: status, risks, checks and capacity. With --all, the organization's answer. |
--all | One line per project, worst first, and the organization's budget check. Run runlot health in a project for its detail. |
--fail-on warn|alert | Exits with code 1 when the status is at or above that level. Without it, the command exits 0 whenever it could read the checks. With --all, the worst project and the budget decide. |
--users, --requests-per-user, --peak-hour-share | Turns your own traffic numbers into requests a second, next to the estimate below. All three go together. |
After a successful runlot deploy, the CLI prints one line when the project has open risks:
2 capacity risks are open — runlot healthIf that check cannot be read, the deploy prints nothing more and its result does not change.
How much traffic can it take?
When the app served at least 1,000 requests in the last 3 days, runlot health adds an estimate:
Capacity (last 3d, 120,000 requests reached the app)
Not counted 3,200 requests Runlot answered before the app ran (rate limit, budget, access, wake)
Busiest minute 4.1 requests/s served at 2026-11-08T03:04:00Z, app 0.05 cores
App CPU 12 ms per request (mean, process CPU)
Ceiling about 58.3 requests/s (0.7 of one core ÷ app CPU per request)
Headroom 14.2× the busiest minute
Memory peak 93% of the app's memory limit
Not covered memory per concurrent request, AI concurrencyThe ceiling is CPU arithmetic from your production traffic: how many requests of today's average cost fit in 0.7 of a core, which leaves room for garbage collection and bursts. It does not see memory per concurrent request, AI limits, database locks, or a slow API your app calls. Memory is usually the first limit an app reaches, which is why the memory peak is printed beside it. Below 1,000 requests there is no estimate.
Only requests that reached your app count. A request Runlot answered itself — a 429 from the rate limit or the spending limit, the access gate, an app that could not be woken — used none of the app's CPU, so it is left out of the mean and printed on its own line. A load test that runs into the rate limit therefore shows fewer requests here than it sent.
Runlot does not guess how many requests a user makes. Give it your own numbers:
runlot health --users 1000 --requests-per-user 100 --peak-hour-share 0.15Your traffic: 1,000 users × 100 requests × 0.15 in the peak hour ÷ 3,600 s ≈ 4.2 requests/s (the peak hour's mean)
Measured ceiling: about 58.3 requests/s — 14× that mean.
The busiest minute of an hour is usually several times the hour's mean; leave room for that.--peak-hour-share is the fraction of a day's requests that arrive in its busiest hour. On the free plan, 1,000 users × 100 requests is the whole daily allowance of 100,000 requests, so the budget runs out before the CPU does.
To measure the real curve with your own code and data, run a load test against a preview.
From an AI agent
The MCP server has the tool get_project_health, and get_org_health for the organization. Both are reads, so they are available under runlot mcp --read-only. Ask your agent to call it before a launch, after a deploy, and when the app is slow or restarting. The agent gets the same JSON as runlot health --json, including which checks were not measured.
The API is GET /v1/orgs/{org}/projects/{project}/health and GET /v1/orgs/{org}/health, with the observability:read scope.