Skip to content
runlot
ObservabilityHealth checks

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 health
slowplay · 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

LevelMeansWhere you see it
warnA trend or thin headroom: at this rate the app reaches a limit, but not todayDashboard, runlot health, the weekly digest
alertA failure is likely within a day, or has already startedAll 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.

IdCompares
app_memoryThe 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_restartsRestarts 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_memoryMemory pressure on the project's database. It predicts slow queries, not a restart.
app_cpuThe 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_cpuCPU 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_stallTimes the app stopped answering requests while using its whole core, and was restarted.
busy_latencyA 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_admissionWhether 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_disposedThe 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_refusalsThe share of env.ai calls refused for rate or concurrency, or because no lent desktop could take the call.
org_budgetThe 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_sizeThe database's size with the plan's database limit, and when it will reach it at the current growth.
file_storageStored files with the storage quota, and when they will reach it.
log_capLog events with the project's daily log-event cap. Past the cap, logs are sampled, and you lose detail you may need later.
cron_overlapCron 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
OptionWhat it does
--jsonPrints the API's answer as it is: status, risks, checks and capacity. With --all, the organization's answer.
--allOne line per project, worst first, and the organization's budget check. Run runlot health in a project for its detail.
--fail-on warn|alertExits 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-shareTurns 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 health

If 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 concurrency

The 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.15
Your 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.

On this page