DocumentationSearch docs
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Node servers and scripts
  4. More JavaScript platforms

    1. 04SvelteKit
    2. 05Nuxt and Nitro
    3. 06React Router and Remix
    4. 07NestJS
    5. 08Strapi
    6. 09Netlify
    7. 10Firebase
    8. 11Convex
    9. 12Trigger.dev
    10. 13Inngest
    11. 14Cloudflare Workers
    12. 15Supabase and pg_cron
  5. Ruby

    1. 16Ruby on Rails
    2. 17Ruby
  6. Python

    1. 18Django
    2. 19Celery
    3. 20Python
  7. PHP

    1. 21Laravel
    2. 22Symfony
    3. 23WordPress
    4. 24Drupal
    5. 25Craft CMS
    6. 26PHP
  8. Go

    1. 27Go
    2. 28Go schedulers
  9. Rust

    1. 29Rust
    2. 30Rust schedulers
  10. Elixir

    1. 31Elixir
    2. 32Elixir schedulers
  11. Java

    1. 33Java
    2. 34Java schedulers
  12. .NET

    1. 35.NET
    2. 36.NET schedulers
  13. Reference

    1. 37Schedules, grace, and timeouts
    2. 38What it catches
    3. 39Alerts
    4. 40AI triage
    5. 41Stores
    6. 42Dashboard and API
    7. 43MCP server
    8. 44Agent skill
    9. 45TypeScript API reference
    10. 46Limits and design notes
    11. 47Environment variables
    12. 48Stability
    13. 49Deprecations

What it catches

What it catches

Every condition is opened once, sends one alert, and stays open until it clears. Once it has cleared, the next successful run that leaves nothing open sends one recovered message naming what was recovered from, or, for a job that lost its schedule while missed, the next check does (see recovered). A job failing all night pages you once.

missed

The schedule said a run was due and none started within the grace period. Decided by cw.check(); see Schedules. Closes when a run starts; the recovered message follows the next successful run. If the job loses its schedule while missed is open, the next check closes it with a recovery of its own.

failed

Any of:

  • the function threw (the error’s name, message, and the first stack frames are recorded),
  • the handler returned a Response with status 400 or above,
  • the job has an expect rule and the output did not satisfy it.

Alerts on the first failure by default. Set failuresBeforeAlert: 3 to wait for the third consecutive one, for jobs that flake and self-heal. It must be a whole number, 1 or more; cw.job() throws otherwise. Consecutive failures are counted either way and shown on the dashboard.

stuck

A run started and never reported finishing within timeout. Marked as timeout by the next check, counted as a failure. Usually a killed process: a serverless limit, a deploy, an OOM. Closes when the next run starts; the recovered message follows the next successful run.

A run started with job.start() and never finished is caught the same way. See Runs that span calls.

slow

A successful run took longer than it should. The threshold is:

  • maxDuration, if the job sets one, or
  • twice the p95 of the job’s last twenty successful runs, once there are at least five to compare against, with a floor of ten seconds so a job that usually takes 200ms is not called slow at 500ms.

Closes when a successful run is back under the threshold.

over_budget

A metric reported with job.metric(name, value) went above its limit. The limit is:

  • budget[name], if the job sets one (a finite number, 0 or more; budget: { errors: 0 } alerts on any error), or
  • three times the median of that metric over the last twenty successful runs, once there are at least five.

All breaching metrics are listed in one alert. Closes when a run’s metrics are all within limits again.

under_floor

A successful run reported a metric below its floor: the job ran cleanly but did less than it should, or nothing at all. A permission that changed, a source that came back empty, a query whose filter stopped matching. The floor is:

  • floor[name], if the job sets one (any finite number; floor: { rows: 1 } alerts on a run that wrote no rows), or
  • for a metric without one, more than 0: a run that reports 0 or less breaches when the last twenty successful runs that reported it (at least five) all reported more than 0.
cw.job("import", { floor: { files: 1 } });           // fewer than one file is under the floor

await job.run(async (j) => {
  const rows = await importRows();
  j.metric("rows", rows);                            // 0 after a week of thousands: under the floor
  j.metric("files", files.length);
});

A metric that has reported 0 or less in any of those runs is not judged without a floor, so a job that often has nothing to do (a reminder sender with nobody to remind) does not alert, and neither does a metric that is always 0, such as an error count. floor: { name: 0 } turns the check off for one metric that should be allowed to reach 0. A floor may not be above the same metric’s budget; cw.job() throws if it is.

A job that keeps reporting 0 stays under its floor for as long as it does, however long that is: the metrics under their floor are kept in the job’s state, so a run of zeros never becomes the baseline it is judged against. All breaching metrics are listed in one alert. Closes when a run’s metrics are all at or above their floors again. Only metrics a run reported are judged; one it did not report at all is not a breach.

recovered

A run succeeded and no condition remains open. The message names everything that alerted and has cleared since the last recovery, for example "succeeded after: missed and failed." A condition that closed while another stayed open waits for this message, so every alert is answered by a recovery once the job is healthy again.

One recovery comes from a check rather than a run. When a job with missed open no longer has a schedule (it was declared again without one, or the pg_cron reader retired a job that was renamed, unscheduled, or paused), nothing is due any more, so the next check closes missed and sends a recovered alert:

nightly is no longer scheduled
Missed since 2026-01-05 03:15:00 UTC (6h ago). It has no schedule now, so nothing is due; the missed alert is closed.
  • Its details are { after: ["missed"], reason: "unscheduled", since }, where since is when missed opened.
  • It answers missed alone. Failed, stuck, slow, over budget, and under floor stay open until a successful run closes them, and that run’s recovery names them but not missed again.
  • Channels treat it like any recovery (Twilio, Honeybadger, and Bugsnag send it only with recovered: true).
  • While the job is silenced, missed closes without a message.

expect rules

expect turns a quiet success into a failure when the job produced no evidence of doing its work. The output is whatever the job logged with job.log(), or the string the function returned if it logged nothing. The rule sees all of it, or for very long output its first and last 16,384 UTF-16 code units, so a line logged early still counts even though only the tail is stored.

cw.job("export", { expect: "wrote" });                       // output must contain the string
cw.job("export", { expect: /wrote \d+ files/ });             // or match the pattern
cw.job("export", { expect: (out) => out.split("\n").length > 3 });   // or pass a function

Every port takes the same three forms in its own language: a string, a pattern (a Ruby Regexp, a Python re.Pattern, and so on), or a function. Elixir’s pattern is {:matches, "source", "flags"}, a JavaScript pattern run by the package’s own engine, since an Elixir Regex reads a pattern differently; Java’s is expectMatch("source", "flags"), for the same reason, where a java.util.regex.Pattern would read it differently; and .NET’s is Expect.Matches("source", "flags"), since a .NET Regex does too. Each language’s page has the spelling.

A pattern runs in your own process, on the platform’s engine, and like an expect function it has no time limit in Node or Python. Most engines backtrack, so a pattern with several unbounded repeats that can match the same text (/\n*\n*\n*x/, /(a+)+b/, or even /.*x/) can take seconds or longer on an output that almost matches but does not. Anchor a pattern where you can, avoid a repeat next to or inside another over the same characters, and prefer a plain string when a substring will do. The other ports bound it where their engine allows, and a pattern that runs out of its bound counts as not matching, so the run fails with the usual Output did not match message: Ruby gives each match a one second timeout, PHP stops at PCRE’s backtrack limit (pcre.backtrack_limit) and adds its reason, (Backtrack limit exhausted), Rust stops a pattern it reads back from another process’s stored definition after fifty million steps, Elixir stops any pattern after ten million, and Java and .NET stop a stored pattern after fifty million. Go’s regexp and Rust’s regex crate run in linear time and need no bound.

Baselines

Baselines use the last twenty successful runs, reading past any failures in between, and need at least five. Before that, only explicit limits apply. A job’s history is its own: a slow job is compared to itself.

Silence

cw.silence(name, "2h") (every port has the same call in its own spelling, such as silence(name, for: "2h") in Ruby), the dashboard button, or the MCP tool. While silenced, nothing new is recorded as an incident and no new alert is sent; conditions that clear during the silence do clear, and the recovery for an alert that already went out is still sent, so an incident someone was told about is always closed. When the silence ends, the next problem alerts normally.

Output and metrics

Output, whether logged or returned, and errors keep a tail of at most 16,384 UTF-16 code units per run, plus a trimming notice when needed. See output limits for the unit and memory bounds. Before either is stored, values that look like secrets are replaced with [redacted]: password=, api_key:, :secret => "...", and similar pairs (quoted values in full), credentials in URLs, Bearer, Basic, and Token authorization values, PEM private keys, JWTs, Slack and Discord webhook URLs, and AWS, GitHub, Slack, Stripe, Google, and API key formats. Redaction runs before the cap, so the cut never keeps the rest of a secret whose label it cut off. NUL bytes are removed. expect rules see the output before redaction. Pass redact to cronwatch() to use your own function, or false to turn it off. Every port blanks the same patterns by default and has the same option in its own spelling (redact: in Ruby, WithRedact in Go, and so on). Metrics are numbers keyed by name; report as many as you like. Both are stored with the run, shown on the dashboard and in alerts, and handed to the MCP server and to triage.

Runs that span calls

A run is normally one call to run(). Work that starts in one call and ends in another (the steps of an Inngest function, a queue that hands work to another process, a webhook that reports completion later) can be one run too: job.start() records it as running, and finish() on the handle it returns, or on one from job.resume(runId) in another process, ends it.

const run = await job.start({ id: event.id });      // records a running run
// later, perhaps elsewhere
const same = await job.resume(event.id);
same.log("sent 40 emails");
await same.finish();                                  // or same.fail(error)

Starting closes missed and stuck like any run starting. Finishing is judged like any run finishing: expect, failures, duration from the start, budgets. It opens nothing a finished run() would not. A run that is never finished is marked stuck after the job’s timeout, so set timeout to cover the whole span, waits included. See the run handle.

A run is judged once, however many times it is finished. The finish is written only while the stored run is still running (or marked timed out by a check), in one step, so when two processes finish the same run at once (a queue that delivers the completion twice, say) one records it and counts it, and the other gets null and hears through onError that the run was already finished. The built-in stores do this; a custom store without updateRunIf falls back to a read then a write, which is only safe when one process finishes a given run. A finish that arrives after a check marked the run stuck is still recorded: a success closes stuck with a recovery, and a failure is not counted a second time.

  • expect sees what run() would: the handle keeps the first 16,384 UTF-16 code units of everything logged through it, so a line logged early still matches after flush() has sent it on and the stored output kept only the tail.
  • A run belongs to the job that started it. start({ id }) with an id another job holds throws, whether that job’s start is still in flight or long done, and a handle that finds another job’s run under its id finishes and flushes nothing. Ids starting with pgcron: are the pg_cron reader’s and are refused.
  • When the store fails during finish(), nothing is recorded, the error goes to onError, and the handle stays active: call finish() again once the store is back. The lines and metrics logged are kept.