Add CronWatch to this project so that every scheduled job is recorded and I am told when one is missed, fails, gets stuck, runs slow, goes over budget, or quietly does nothing. CronWatch is an open source library: @cronwatch/sdk for TypeScript (Node, Cloudflare Workers, Deno, Bun), the cronwatch gem for Ruby and Rails, cronwatch-sdk for Python (Django, Celery, APScheduler), cronwatch/cronwatch for PHP (Laravel, Symfony, WordPress, Drupal, Craft CMS), cronwatch.dev/go for Go (robfig/cron, gocron, River, Asynq), the cronwatch crate for Rust (tokio-cron-scheduler, apalis), the cronwatch package on Hex for Elixir (Oban, Quantum), dev.cronwatch:cronwatch on Maven Central for Java (Spring Boot, Quartz, JobRunr), and the Cronwatch package on NuGet for .NET (ASP.NET Core, Hangfire, Quartz.NET). It runs inside my app and writes to my own database. There is no hosted service and no account to make; the only key it can use is an Anthropic API key, for triage, which is optional. First decide which steps this project needs: - package.json (Node, Next.js, or another JavaScript runtime): TypeScript. With wrangler.toml or wrangler.jsonc it is Cloudflare Workers; follow the Workers notes. - Gemfile: Ruby. A Rails app follows the steps as written; for plain Ruby see https://cronwatch.dev/docs/ruby/ - pyproject.toml, requirements.txt, or manage.py: Python. - composer.json or wp-config.php: PHP. artisan is Laravel, bin/console Symfony, wp-config.php WordPress, drupal/core-recommended Drupal, craftcms/cms Craft CMS. - go.mod: Go. robfig/cron/v3, go-co-op/gocron/v2, riverqueue/river, or hibiken/asynq in it names the scheduler. - Cargo.toml: Rust. tokio-cron-scheduler or apalis among its dependencies names the scheduler. - mix.exs: Elixir. :oban or :quantum among its deps names the scheduler; :phoenix is a Phoenix app. - pom.xml, build.gradle, or build.gradle.kts: Java. spring-boot among its dependencies is a Spring Boot app; org.quartz-scheduler:quartz or org.jobrunr:jobrunr names the scheduler. - *.csproj, *.sln, or *.slnx: .NET. Hangfire.Core or Quartz among its PackageReferences names the scheduler; Sdk="Microsoft.NET.Sdk.Web" is an ASP.NET Core app. If scheduled jobs live in more than one, set up each side where its jobs run; they can share one database. Where alerts go: ask me before you write the client, unless I already said. The examples below use Slack, but every language has the same channels, and the client takes as many as I want: - Slack or Discord: an incoming webhook URL. - Webhook: a URL of mine, and a secret if the requests should be signed. - Email: Resend, Postmark, SendGrid, Mailgun, or Amazon SES. Each needs the provider's API key (for SES, AWS credentials with ses:SendEmail and the region), a from address on a domain the provider has verified, and the address or addresses to send to. CronWatch has no mail server of its own. - SMS: Twilio, with an account SID, an auth token, a from number, and the numbers to text. - Error trackers: Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, or New Relic, with the project's DSN or API key. - WordPress, Drupal, and Craft CMS: the settings page has every channel above, and email through the site's own mail, which needs no provider. Nothing to write in code: tell me what to fill in there, and to press "Send a test alert" once it is saved. With no channel, alerts only go to the console, which nobody reads in production. Read every key, token, and address from the environment or the framework's secrets, never from the code. Add the variable names to .env.example or this project's equivalent, and tell me at the end which accounts, keys, and verified sender I still have to set up. If I named a channel without its details, wire it up from those variables anyway rather than leaving it out. Each channel's options: https://cronwatch.dev/docs/alerts/ and the language's page. TypeScript and Node 1. Install it with the driver for the store you will use in step 2 (it needs Node 22 or newer). The drivers are optional peers, so install one yourself. npm install @cronwatch/sdk better-sqlite3 # for sqlite npm install @cronwatch/sdk pg # for postgres npm install @cronwatch/sdk # for d1 on Workers: no driver 2. Create one client for the whole app, in its own module, and export it. Pick the store that matches this project: sqlite for a long-running server, postgres for serverless or anything with more than one instance, d1 on Cloudflare Workers. import { cronwatch } from "@cronwatch/sdk"; import { sqlite } from "@cronwatch/sdk/sqlite"; // or: postgres from "@cronwatch/sdk/postgres" import { slack } from "@cronwatch/sdk/slack"; // Other channels, one import each: discord, webhook, resend, postmark, // sendgrid, mailgun, ses, twilio, sentry, honeybadger, datadog, rollbar, // bugsnag, newrelic. custom(name, fn) from "@cronwatch/sdk" makes a channel // of your own. Omit alerts for the console. export const cw = cronwatch({ store: sqlite({ path: "./data/cronwatch.db" }), alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })], }); On Workers there is no module-level env: make the client inside the handler with store: d1(env.DB) from "@cronwatch/sdk/d1", passing every secret from env. See https://cronwatch.dev/docs/cloudflare/ If the project uses pg_cron (Supabase Cron included), add sources: [pgCron(pool)] from "@cronwatch/sdk/pg-cron" with a pg Pool on that database; each check then reads pg_cron's own tables. Do not declare those jobs yourself. See https://cronwatch.dev/docs/supabase/ 3. Declare each scheduled job once, next to the code that runs it. The schedule is a cron expression, "@hourly", or "every 15m". Add a grace period, and a timeout for anything that can hang. export const nightlyReport = cw.job("nightly-report", { schedule: "0 2 * * *", timezone: "UTC", grace: "15m", timeout: "20m", }); 4. Wrap the work. In a route handler use .handler(); anywhere else use .run(). .handler() checks the Authorization bearer against CRON_SECRET for you, and answers 503 without running when CRON_SECRET is unset outside development. export const GET = nightlyReport.handler(async (job) => { const report = await buildReport(); job.log("Report written:", report.path); job.metric("pages", report.pages); }); .handler() and cw.routes() are fetch handlers. For Express, Koa, NestJS , or a plain Node server, wrap them with toNodeHandler (or toKoaMiddleware) from "@cronwatch/sdk/node". 5. Make missed and stuck runs detectable. A job that never fires cannot report itself, so something has to look. In a long-running server call cw.startChecking() once at boot. On serverless, mount the routes and point a cron at the check endpoint every few minutes. On Workers, give the check a Cron Trigger of its own that calls cw.check() in scheduled(); never cw.startChecking() there. // app/cronwatch/[[...path]]/route.ts in Next.js; elsewhere mount it at // /cronwatch or pass basePath to cw.routes() export const { GET, POST, DELETE } = cw.routes({ token: process.env.CRONWATCH_TOKEN }); // then: GET /cronwatch/api/check every 5 minutes, with the bearer token 6. Set the environment variables you referenced: CRON_SECRET, CRONWATCH_TOKEN, and whichever alert channel you chose. Do not invent other names. Ruby and Rails (Ruby 3.2 or newer, Rails 7.2 or newer) 1. Install the gem, generate the migration and initializer, and migrate: bundle add cronwatch bin/rails generate cronwatch:install bin/rails db:migrate 2. Leave the ActiveRecord store the initializer sets (web, workers, and the check must see the same runs), and set the alert channel there: # config/initializers/cronwatch.rb Cronwatch.configure do |c| c.store = Cronwatch::Stores::ActiveRecord.new c.alerts = [Cronwatch::Alerts::Slack.new(webhook_url: ENV["SLACK_WEBHOOK_URL"])] end Other channels under Cronwatch::Alerts: Discord, Webhook, Resend, Postmark, Sendgrid, Mailgun, Ses, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, Console, and Custom.new("name") { |alert| ... } for a channel of your own. Their options: https://cronwatch.dev/docs/ruby/ If the project uses pg_cron, require "cronwatch/pg_cron" and add c.sources = [Cronwatch::Sources::PgCron.new(ActiveRecord::Base)]. Each check then reads pg_cron's own tables. Do not declare those jobs yourself. 3. Watch each scheduled job in its class. For ActiveJob: class NightlyReportJob < ApplicationJob include Cronwatch::ActiveJob cronwatch schedule: "0 2 * * *", grace: "15m", timeout: "20m" def perform cronwatch.log("Report written") end end For a Sidekiq job class, include Cronwatch::Sidekiq after Sidekiq::Job and call cronwatch the same way. When the job is scheduled by Solid Queue (config/recurring.yml) or sidekiq-cron (config/schedule.yml), use schedule: :from_scheduler instead of copying the schedule. To watch every entry in that file at once, add Cronwatch.declare_from_scheduler!(grace: "10m") to the initializer after Cronwatch.configure. 4. Schedule Cronwatch::CheckJob every few minutes, beside the other recurring jobs, so missed and stuck runs are noticed. In config/recurring.yml: production: cronwatch_check: class: Cronwatch::CheckJob schedule: every 5 minutes With sidekiq-cron, add the same class with cron "*/5 * * * *", or Cronwatch::Sidekiq::CheckWorker when the Sidekiq jobs do not go through ActiveJob. With neither, a crontab line runs bin/rails cronwatch:check every 5 minutes. 5. Mount the dashboard in config/routes.rb (no require needed in Rails): mount Cronwatch.client.routes => "/cronwatch" Outside development it needs CRONWATCH_TOKEN. Behind the app's own admin auth, pass token: nil instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN and whichever alert channel you chose. Do not invent other names. Python (Python 3.11 or newer) 1. Install it with the extras for what the project uses: pip install "cronwatch-sdk[postgres,django,celery]" Extras: postgres, django (Django 5.2 or newer), celery (Celery 5.5 or newer), apscheduler (APScheduler 3.10 or newer, not 4), anthropic (triage). The import name is cronwatch. Add it to the project's own dependency file (pyproject.toml, requirements.txt) the way the project already does. 2. Use a store every process shares (web, workers, and the check must see the same runs): cronwatch.stores.postgres.PostgresStore() (reads DATABASE_URL), or cronwatch.stores.SqliteStore("./data/cronwatch.db") on one machine. In Django, configure it in settings.py and add the app: INSTALLED_APPS += ["cronwatch.django"] CRONWATCH = { "STORE": "cronwatch.stores.postgres.PostgresStore", "ALERTS": [Slack(webhook_url=os.environ["SLACK_WEBHOOK_URL"])], # from cronwatch.alerts import Slack "TOKEN": os.environ.get("CRONWATCH_TOKEN", ""), } Outside Django, make one client with cronwatch.configure(store=..., alerts=[...]) in a module every entry point imports. Other channels in cronwatch.alerts: Discord, Webhook, Resend, Postmark, Sendgrid, Mailgun, Ses, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, Console, and Custom("name", fn) for a channel of your own. Email senders take from_ (from is a Python keyword). Their options: https://cronwatch.dev/docs/python/ If the project uses pg_cron, add sources=[PgCron(os.environ["DATABASE_URL"])] ("SOURCES" in Django) with from cronwatch.sources.pgcron import PgCron. Each check then reads pg_cron's own tables. Do not declare those jobs yourself. 3. Watch each scheduled job: - Celery: cronwatch.celery.install(app) after the app is made. Every task beat schedules is watched with beat's schedule, no task changes. Add "cronwatch-check": {"task": "cronwatch.celery.check", "schedule": 300.0} to beat_schedule. - APScheduler: cronwatch.apscheduler.watch(scheduler) before scheduler.start(). Every job is watched with its trigger. - Django management commands and cron scripts: declare the job in /cronwatch_jobs.py (Django imports it at startup), for example nightly = cronwatch.django.client().job("nightly-report", schedule="0 2 * * *", timezone="UTC", grace="15m"), and wrap the work: with nightly.run() as ctx: ... ctx.log("Report written"). Outside Django, cw.job(...) the same way. Async functions use async with nightly.run(). - A platform that calls a URL on a schedule: nightly.handler(fn) gives .django, .flask, .starlette, and .aws_lambda endpoints, which need Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: the Celery beat entry above, or a crontab line running python manage.py cronwatch_check (Django) or a script calling cw.check(), or cw.start_checking() once in a long-running process. One checker per store. 5. Mount the dashboard: in Django, path("cronwatch/", include("cronwatch.django.urls")) in urls.py; elsewhere cw.routes() is a WSGI app (.asgi for FastAPI and Starlette) to mount at /cronwatch. Outside development it needs CRONWATCH_TOKEN. Behind the app's own admin auth, pass token=None (TOKEN: None in Django) instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. PHP (PHP 8.2 or newer) 1. Pick the steps for the framework. Each records every run in the app's own database, and needs no other service. - WordPress: the plugin is CronWatch in the wordpress.org plugin directory (https://wordpress.org/plugins/cronwatch/). Install and activate it with wp plugin install cronwatch --activate, or search for CronWatch under Plugins, Add New Plugin in wp-admin. Every WP-Cron event is watched with no code. Set the alert channel under CronWatch, Settings. If the site is not visited every few minutes, add define('DISABLE_WP_CRON', true) to wp-config.php and crontab lines running wp cron event run --due-now and wp cronwatch check every 5 minutes. Stop there: skip steps 2 to 5. - Laravel: composer require cronwatch/cronwatch, then php artisan migrate. Every task in the schedule is watched, named after its command (give closures a ->name()), and cronwatch:check is scheduled for you. Set CRONWATCH_SLACK_WEBHOOK_URL or CRONWATCH_MAIL_TO in .env. - Symfony: composer require cronwatch/cronwatch, add Cronwatch\Symfony\CronwatchBundle::class => ['all' => true] to config/bundles.php, and config/packages/cronwatch.yaml with cronwatch: { alerts: { slack: '%env(CRONWATCH_SLACK_WEBHOOK_URL)%' } }. Every Scheduler message is watched, and the check is added to the default schedule, so the worker consuming scheduler_default runs it. - Drupal: composer require drupal/cronwatch, drush pm:install cronwatch, once the drupal.org release is out; until then, see https://cronwatch.dev/docs/drupal/ Every cron run and each module's hook_cron is watched. Set the alert channel and the cron schedule under Configuration, System, CronWatch. - Craft CMS: composer require cronwatch/craft, php craft plugin/install cronwatch. List the console commands the crontab runs, with their schedules, and the queue job classes to watch, in config/cronwatch.php ('commands' => ['resave/entries' => ['schedule' => '0 3 * * *']], 'queueJobs' => [...]). - Anything else (crontab scripts, Slim, a plain PHP app): composer require cronwatch/cronwatch. 2. Outside the frameworks, make one client in cronwatch.php at the project's root that requires vendor/autoload.php, declares every job and returns the client: $cw = new Cronwatch\Cronwatch( store: new Cronwatch\Store\MysqlStore(getenv('DATABASE_URL')), // MySQL or MariaDB; or SqliteStore('/var/lib/app/cronwatch.db'), PostgresStore(...) alerts: [new Cronwatch\Alerts\Slack(getenv('SLACK_WEBHOOK_URL'))], ); $cw->job('nightly-report', ['schedule' => '0 2 * * *', 'timezone' => 'UTC', 'grace' => '15m']); return $cw; Other channels under Cronwatch\Alerts: Discord, Webhook, Resend, Postmark, Sendgrid, Mailgun, Ses, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, with named arguments, and new Custom('name', fn (Alert $alert) => ...) for a channel of your own. Their options: https://cronwatch.dev/docs/php/ If the project uses pg_cron, add sources: [new Cronwatch\Sources\PgCron(getenv('DATABASE_URL'))]. Each check then reads pg_cron's own tables. Do not declare those jobs yourself. 3. Watch each scheduled job: - Laravel: nothing to do for scheduled tasks; options per task with ->cronwatch(['name' => 'nightly-report', 'grace' => '15m']), or ->cronwatch(false) to leave one out. Queued jobs opt in with #[Cronwatch\Watch(grace: '15m')] on the class. - Symfony: nothing to do for scheduled messages; options with #[Cronwatch\Watch(...)] on the message class. Messenger messages that workers handle opt in with the same attribute. - Drupal: queue workers opt in under the settings or with #[Cronwatch\Watch] on the worker class. - Craft CMS: the commands and queueJobs lists above. - A crontab script: $cw = require 'cronwatch.php'; then $cw->job('nightly-report', [...])->run(function ($job) { ... $job->log('Report written'); }). - A platform that calls a URL: $cw->job(...)->handler($fn), then ->serve() in a bare script, ->laravel() as a Laravel route action, or called with the request in a Symfony controller. It needs Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: Laravel schedules php artisan cronwatch:check itself, Symfony adds it to the default schedule, Drupal runs it after each cron run (add drush cronwatch:check to the crontab too), Craft CMS needs a crontab line running php craft cronwatch/check, and a plain app a crontab line running vendor/bin/cronwatch check (which loads cronwatch.php). One checker per store. 5. The dashboard: Laravel serves it at /cronwatch behind the viewCronwatch gate (define it for your admins); Symfony imports '@CronwatchBundle/config/routes.php' under /cronwatch in config/routes/cronwatch.yaml behind access_control; Drupal and Craft CMS put it in their admin. In a plain app, a public/cronwatch.php requiring cronwatch.php and calling $cw->routes()->serve(). Outside development it needs CRONWATCH_TOKEN; behind the app's own admin auth, pass routes(token: false) instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. Go (Go 1.25 or newer) 1. Install the module, and the integration for the scheduler the project uses, if any: go get cronwatch.dev/go go get cronwatch.dev/go/robfigcron # or cronwatch.dev/go/gocron, /river, /asynq The package is cronwatch: import cronwatch "cronwatch.dev/go". The module requires no database driver; the SQL store works over the project's own *sql.DB with the driver it already uses. 2. Make one client at startup and pass it to whatever needs it. Use a store every process shares (web, workers, and the check must see the same runs): store, err := sqlstore.New(db, sqlstore.Postgres) // "cronwatch.dev/go/sqlstore"; or sqlstore.SQLite, sqlstore.MySQL slack, err := alerts.Slack(alerts.SlackOptions{WebhookURL: os.Getenv("SLACK_WEBHOOK_URL")}) // "cronwatch.dev/go/alerts" cw, err := cronwatch.New(cronwatch.WithStore(store), cronwatch.WithAlerts(slack)) Other channels in cronwatch.dev/go/alerts, each made from an options struct: Discord, Webhook, Resend, Postmark, Sendgrid, Mailgun, SES, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, and cronwatch.ChannelFunc(name, fn) for a channel of your own. Their options: https://cronwatch.dev/docs/go/ If the project uses pg_cron, add cronwatch.WithSources(pgcron.New(db, pgcron.Options{})) from "cronwatch.dev/go/pgcron". Do not declare those jobs yourself. 3. Watch each scheduled job: - robfig/cron: cron.New(robfigcron.New(cw, robfigcron.Options{}).Option()), the cron's other options after it. Every entry is watched with its spec, named after its function; name a closure with robfigcron.Named("nightly-report", cron.FuncJob(fn)). - gocron v2: gocron.NewScheduler(cwgocron.New(cw, cwgocron.Options{}).Option()) with import cwgocron "cronwatch.dev/go/gocron". Name closures with gocron.WithName. - River: w := cwriver.New(cw, cwriver.Options{}) (import cwriver "cronwatch.dev/go/river"); w.PeriodicJob(...) in place of river.NewPeriodicJob(...), w.Middleware() in river.Config.Middleware, and river.AddWorker(workers, w.CheckWorker()) with w.CheckPeriodicJob(5 * time.Minute) among the periodic jobs. - Asynq: w := cwasynq.New(cw, cwasynq.Options{}) (import cwasynq "cronwatch.dev/go/asynq"); w.NewScheduler(...) in place of asynq.NewScheduler(...), mux.Use(w.Middleware()) on the server, scheduler.Register("*/5 * * * *", cwasynq.CheckTask()) and mux.Handle(cwasynq.CheckType, w.CheckHandler()). - A program a crontab runs: declare the job with nightly, err := cw.Job("nightly-report", cronwatch.Schedule("0 2 * * *"), cronwatch.Timezone("UTC"), cronwatch.Grace("15m")) and wrap the work in nightly.Run(ctx, func(ctx context.Context, job *cronwatch.JobContext) error { ... job.Log("Report written"); return err }), exiting non-zero on the error Run returns. - A platform that calls a URL: mux.Handle("POST /api/cron/nightly", nightly.Handler(fn)), or lambda.Start(cronwatch.Lambda(handler)) on AWS Lambda. It needs Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: cw.StartChecking(time.Minute) once in a long-running process (robfig/cron and gocron apps included), the River or Asynq check job above, or a second crontab line running a command that declares the same jobs and calls cw.Check(ctx). 5. Mount the dashboard: routes, err := cw.Routes() (or cw.MustRoutes() at package level), then mux.Handle("/cronwatch/", routes). Outside development it needs CRONWATCH_TOKEN; behind the app's own admin auth, pass cronwatch.WithoutToken() instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. Rust (Rust 1.85 or newer; 1.94 for the SQL store) 1. Add the crate with the features the project needs, the SQL store with the project's database, and the integration for its scheduler, if any: cargo add cronwatch --features alerts,axum # alerts: the channels; axum (or tower): the dashboard cargo add cronwatch-sqlx --features postgres # or sqlite, mysql cargo add cronwatch-tokio-cron-scheduler # or cronwatch-apalis cronwatch-sqlx works over the project's own sqlx 0.9 pool. Other features: triage (Claude triage), blocking (a client for a program with no tokio runtime), regex, serde. 2. Make one client at startup, inside the tokio runtime, and keep it in the app's state (it is a cheap Clone). Use a store every process shares (web, workers, and the check must see the same runs): let cw = cronwatch::Client::builder() .store(cronwatch_sqlx::SqlStore::postgres(pool.clone())) // or SqlStore::sqlite(pool), SqlStore::mysql(pool) .alert(cronwatch::alerts::slack( cronwatch::alerts::SlackOptions::new().webhook_url(std::env::var("SLACK_WEBHOOK_URL")?), )?) .build()?; Other channels in cronwatch::alerts, each made from an options struct: discord, webhook, resend, postmark, sendgrid, mailgun, ses, twilio, sentry, honeybadger, datadog, rollbar, bugsnag, newrelic, and cronwatch::channel_fn(name, |alert| async move { ... }) for a channel of your own. Their options: https://cronwatch.dev/docs/rust/ If the project uses pg_cron, add .source(Arc::new(PgCron::new(pool, PgCronOptions::default()))) from cronwatch_sqlx (its pgcron feature). Do not declare those jobs yourself. 3. Watch each scheduled job: - tokio-cron-scheduler: let watcher = Watcher::new(&cw, Options::default()) from cronwatch_tokio_cron_scheduler, then scheduler.add(watcher.job("nightly-report", "0 0 2 * * *", "UTC", |job| async move { ... }, JobOptions::new().grace("15m"))?).await? in place of Job::new_async_tz (and watcher.repeated(...) for Job::new_repeated_async). The closure returns a Result. Add scheduler.add(watcher.check_job(Duration::from_secs(60))?).await? and call watcher.follow(&scheduler). - apalis: let watcher = Watcher::new(&cw, Options::default()) from cronwatch_apalis; a cron worker's backend is watcher.cron("nightly-report", "0 2 * * *", "UTC", JobOptions::new())?, and every worker gets .layer(watcher.layer()) after its .retry(...). Run watcher.check_worker(Duration::from_secs(60))? beside the others. Pin apalis = "=1.0.0-rc.10" and apalis-cron = "=1.0.0-rc.9". - A program a crontab runs: declare the job with let nightly = cw.job("nightly-report", JobOptions::new() .schedule("0 2 * * *").timezone("UTC").grace("15m"))?; and wrap the work in nightly.run(|job| async move { ... job.log("Report written"); Ok::<(), String>(()) }).await, exiting non-zero on the error run returns. - A platform that calls a URL: nightly.handler(|job, request| async move { ... }, HandlerOptions::new()) is a tower service, for axum's route_service or lambda_http::run on AWS Lambda. It needs Authorization: Bearer $CRON_SECRET. Rust cannot infer these closures' error type, so each ends by naming it: Ok::<(), String>(()) rather than Ok(()), or the project's own error type in place of String when the closure uses ? on it. 4. Run the check every few minutes so missed and stuck runs are noticed: cw.start_checking(Duration::from_secs(60)) once in a long-running process, the check job or check worker above, or a second crontab line running a command that declares the same jobs and calls cw.check().await. 5. Mount the dashboard: let routes = cw.routes(RoutesOptions::new())? (cronwatch::web::RoutesOptions), then Router::new().nest_service("/cronwatch", routes) in axum. Outside development it needs CRONWATCH_TOKEN; behind the app's own admin auth, pass RoutesOptions::new().no_token() instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. Elixir (Elixir 1.18 or newer on OTP 27 or newer) 1. Add the package to the deps in mix.exs and run mix deps.get: {:cronwatch, "~> 0.14"} It needs only tz and telemetry. The SQL store uses the project's own Ecto repo and adapter (ecto_sqlite3, postgrex, or myxql); the dashboard and a job's handler use plug, which every Phoenix app has. 2. Start one instance in the application's supervision tree, after the repo, with a store every node shares (web, workers, and the check must see the same runs), and declare every job in its jobs: option: {Cronwatch, store: {Cronwatch.Store.Ecto, repo: MyApp.Repo}, alerts: [{Cronwatch.Alerts.Slack, webhook_url: System.fetch_env!("SLACK_WEBHOOK_URL")}], jobs: [{"nightly-report", schedule: "0 2 * * *", timezone: "UTC", grace: "15m"}]} Other channels, each {module, options} with the SDK's options in snake_case: Cronwatch.Alerts.Discord, Webhook, Resend, Postmark, SendGrid, Mailgun, SES, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, and Cronwatch.Alerts.fun(name, fn alert -> ... end) for a channel of your own. Their options: https://cronwatch.dev/docs/elixir/ If the project uses pg_cron, add sources: [{Cronwatch.Sources.PgCron, repo: MyApp.Repo}]. Do not declare those jobs yourself. 3. Watch each scheduled job: - Oban: add integrations: [{Cronwatch.Oban, oban: Oban}] to the instance. Every worker in the Cron plugin's crontab becomes a job with its schedule, and each attempt a run, with no change to the workers; Cronwatch.log/1 works inside perform/1. Name other workers to watch with workers: [MyApp.Workers.Import]. Add {"* * * * *", Cronwatch.Oban.CheckWorker} to the crontab. - Quantum: add integrations: [{Cronwatch.Quantum, scheduler: MyApp.Scheduler}] to the instance. Every active job becomes a job named after its name, with its schedule. Add a scheduler job cronwatch_check: [schedule: "* * * * *", task: {Cronwatch.Quantum, :check, [[scheduler: MyApp.Scheduler]]}]. - Anything else, or a release a crontab runs: wrap the work in Cronwatch.run("nightly-report", fn job -> ...; Cronwatch.log(job, "Report written"); :ok end). A raise, throw, exit, {:error, reason}, or :error fails the run and is handed back as it came; in a crontab script, exit non-zero on a failure. - A platform that calls a URL: in the Phoenix router, forward "/cron/nightly", Cronwatch.Handler, job: "nightly-report", run: {MyApp.Reports, :nightly, []}, which calls MyApp.Reports.nightly(job, conn). It needs Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: the Oban check worker or the Quantum check job above, check_every: :timer.minutes(1) on the instance in a long-running app without either, or, for a release a crontab runs, a second crontab line running bin/my_app eval "Cronwatch.Release.check(MyApp.Cronwatch)" with the instance's options, jobs included, under config :my_app, MyApp.Cronwatch in config/runtime.exs. 5. Mount the dashboard in the Phoenix router, outside the :browser pipeline: scope "/" do forward "/cronwatch", Cronwatch.Web end. Outside development it needs CRONWATCH_TOKEN; behind the app's own admin auth, pass token: false instead. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. Java (Java 21 or newer) 1. Add the library. In a Spring Boot app (3.5 or 4) add the starter, which brings the rest: dev.cronwatch cronwatch-spring-boot-starter 0.14.1 (implementation("dev.cronwatch:cronwatch-spring-boot-starter:0.14.1") in build.gradle.kts). Anywhere else add dev.cronwatch:cronwatch, and dev.cronwatch:cronwatch-servlet for a servlet container. Add dev.cronwatch:cronwatch-quartz for Quartz and dev.cronwatch:cronwatch-jobrunr for JobRunr, all at the same version. The core depends on nothing; the SQL store uses the project's own JDBC driver and DataSource. 2. Create one client for the whole app, with a store every instance shares (web, workers, and the check must see the same runs). Spring Boot: the starter makes the client a bean. It uses SqlStore over the app's one DataSource, every Channel bean as a channel, and cronwatch.* properties; add a channel as a bean: @Bean Channel slack(@Value("${SLACK_WEBHOOK_URL}") String url) { return Slack.webhook(url); } Anywhere else, build one and close it when the app stops: Cronwatch cw = Cronwatch.builder() .store(SqlStore.postgres(dataSource)) // or SqlStore.sqlite, SqlStore.mysql .alert(Slack.webhook(System.getenv("SLACK_WEBHOOK_URL"))) .build(); Other channels in dev.cronwatch.alerts, each made from its options: Discord, Webhook, Resend, Postmark, SendGrid, Mailgun, Ses, Twilio, Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, NewRelic, and Channel.of(name, (alert, ctx) -> { ... }) for a channel of your own. Their options: https://cronwatch.dev/docs/java/ If the project uses pg_cron, add .source(PgCron.source(dataSource)) (a Source bean in Spring Boot). Do not declare those jobs yourself. 3. Watch each scheduled job: - Spring @Scheduled: nothing to add. With the starter every @Scheduled method is a job named SimpleClassName.method, on its cron or rate; give it a name and options with @CronwatchJob(name = "nightly-report", grace = "15m") beside @Scheduled. Cronwatch.current().log(...) works inside it. Under ShedLock, only the instance that got the lock records the run. - Quartz: in Spring Boot, adding cronwatch-quartz watches every Scheduler bean. Elsewhere call CronwatchQuartz.watch(cw, scheduler, QuartzOptions.defaults()) and CronwatchQuartz.scheduleCheck(scheduler) before scheduler.start(). Every job with a trigger becomes a job, and each firing a run. - JobRunr: CronwatchJobRunr watcher = CronwatchJobRunr.watch(cw, storageProvider, JobRunrOptions.defaults()), then .withJobFilter(watcher) before .useBackgroundJobServer(), and CronwatchJobRunr.scheduleCheck(jobScheduler). Every recurring job becomes a job and each attempt a run. - Anything else, or a program a crontab runs: declare the job with Job nightly = cw.job("nightly-report", JobOptions.builder() .schedule("0 2 * * *").timezone("UTC").grace("15m")); and wrap the work in nightly.run(job -> { ...; job.log("Report written"); }). What the function throws fails the run and is thrown again; in a crontab program, exit non-zero on it. - A platform that calls a URL: nightly.handler((job, request) -> { ...; return null; }), served with WebServer.mount(server, "/cron/nightly", handler), new CronwatchServlet(handler), or in Spring Boot a FilterRegistrationBean of new CronwatchFilter(handler, "/cron/nightly"). It needs Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: in Spring Boot the starter does it (cronwatch.check-every, a minute by default, once per cluster under ShedLock or a clustered Quartz); the Quartz or JobRunr check job above; cw.startChecking() once in any other long-running process; or, for a program a crontab runs, a second crontab line running a main of the app's own that calls CronwatchCli.main(MyApp::cronwatch, args) with "check", where MyApp.cronwatch() builds the same client and declares the same jobs. 5. Mount the dashboard. In Spring Boot the starter serves it at /cronwatch on Spring MVC or WebFlux (cronwatch.web.path to move it). In a servlet container, add new CronwatchFilter(cw.routes(), "/cronwatch") mapped to /cronwatch and /cronwatch/*. On the JDK's HttpServer, WebServer.mount(server, "/cronwatch", cw.routes()). Outside development it needs CRONWATCH_TOKEN; behind the app's own admin auth, set cronwatch.web.open=true, or pass RoutesOptions.builder().noToken().build() to cw.routes(...). 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. .NET (.NET 10 or newer) 1. Add the library for the kind of app this is: dotnet add package Cronwatch.AspNetCore # ASP.NET Core: brings the other two dotnet add package Cronwatch.Hosting # a worker or other Generic Host app dotnet add package Cronwatch # a console program a crontab runs Add Cronwatch.Hangfire for Hangfire (1.8) and Cronwatch.Quartz for Quartz.NET (4), all at the same version. The core depends on nothing; the SQL store uses the project's own ADO.NET driver (Microsoft.Data.Sqlite, Npgsql, or MySqlConnector) through a DbDataSource. 2. Create one client for the whole app, with a store every instance shares (web, workers, and the check must see the same runs). In a Generic Host or ASP.NET Core app, register it: builder.Services.AddCronwatch(o => { o.Store = SqlStore.Postgres(dataSource); // or SqlStore.Sqlite, SqlStore.MySql o.Alerts.Add(SlackChannel.Webhook(builder.Configuration["SLACK_WEBHOOK_URL"]!)); }); It is a singleton that checks every minute as a hosted service and logs through ILogger; every IChannel registered in the container is a channel too. Anywhere else, make one and dispose it when the program ends: await using var cw = new CronwatchClient(new CronwatchOptions { Store = SqlStore.Sqlite(SqliteFactory.Instance.CreateDataSource("Data Source=cronwatch.db")), Alerts = { SlackChannel.Webhook(Environment.GetEnvironmentVariable("SLACK_WEBHOOK_URL")!) }, }); Other channels in Cronwatch.Alerts, each made from its options (new ResendChannel(new ResendOptions { ... })): DiscordChannel, WebhookChannel, ResendChannel, PostmarkChannel, SendGridChannel, MailgunChannel, SesChannel, TwilioChannel, SentryChannel, HoneybadgerChannel, DatadogChannel, RollbarChannel, BugsnagChannel, NewRelicChannel, and CustomChannel.Create(name, (alert, ctx, ct) => ...) for a channel of your own. Their options: https://cronwatch.dev/docs/dotnet/ If the project uses pg_cron, add new PgCronSource(dataSource) to the client's Sources. Do not declare those jobs yourself. 3. Watch each scheduled job: - Hangfire: add .UseCronwatch(services) to the configuration in services.AddHangfire((services, c) => c...), and schedule the check with CronwatchHangfire.ScheduleCheck(recurringJobManager). Every recurring job becomes a job named by its id, on its cron, and each attempt a run, with no change to the jobs; CronwatchClient.Current?.Log(...) works inside them. Mark another job to watch with [CronwatchJob("import")] on its method. - Quartz.NET: add q.UseCronwatch(o => o.ScheduleCheck = true) inside services.AddQuartz(q => ...). Every job with a trigger becomes a job named after its key, and each firing a run. - A BackgroundService or other loop of the app's own: declare the job with its real schedule (Job nightly = cw.Job("nightly-report", new JobOptions { Schedule = "0 2 * * *", Timezone = "UTC", Grace = "15m" })) and wrap each iteration's work in await nightly.RunAsync(async (job, ct) => { ...; job.Log("Report written"); }, stoppingToken). A new job that only needs a cron inside the host can be a class implementing ICronwatchJob (using Cronwatch.Hosting), registered with services.AddCronwatchJob(name, options), which runs it on that schedule. - Anything else, or a program a crontab runs: the same RunAsync. What the function throws fails the run and is thrown again; in a crontab program, return 1 on it. - A platform that calls a URL: declare the job, then app.MapCronwatchHandler("/cron/nightly", "nightly-report", async (JobContext job, HttpContext http, CancellationToken ct) => ...). It needs Authorization: Bearer $CRON_SECRET. 4. Run the check every few minutes so missed and stuck runs are noticed: AddCronwatch does it as a hosted service (CheckEvery, a minute by default); with Hangfire or Quartz.NET their check job above runs it once per cluster, so set o.NoCheck = true in AddCronwatch; cw.StartChecking() in any other long-running process; or, for a program a crontab runs, a second crontab line running the app with the arguments "cronwatch check". In a Generic Host app, end Main with return await app.RunCronwatchCommandAsync(args); elsewhere, for args ["cronwatch", .. var rest], return await CronwatchCli.RunAsync(MakeClient, rest, Console.Out, Console.Error), where MakeClient builds the same client and declares the same jobs. 5. Mount the dashboard in ASP.NET Core: app.MapCronwatch("/cronwatch"). Outside development it needs CRONWATCH_TOKEN (or Cronwatch:Token in configuration); behind the app's own admin auth, pass new RoutesOptions { Token = DashboardToken.None } as its options. 6. Set the environment variables you referenced: CRONWATCH_TOKEN, CRON_SECRET if you added a handler, and whichever alert channel you chose. Do not invent other names. Rules for you: - Declare every existing scheduled job in this project, not just one. Find them in cron config, workflow files, queue schedulers, and any crontab. - Use the real schedules already in use. Do not change when anything runs. - Keep the job names stable and human readable; they are the identity of the job across restarts and deploys. - Send alerts where I asked (see "Where alerts go" above). Do not finish with only the console unless I chose that. - Do not add a retry loop or an uptime ping. CronWatch records what happened; it does not re-run work. - If this project already has a dashboard route or auth, mount cw.routes() or Cronwatch::Web behind that auth with its token set to null (nil in Ruby, None in Python, false in PHP, cronwatch.WithoutToken() in Go, RoutesOptions::new().no_token() in Rust, token: false in Elixir, RoutesOptions.builder().noToken() or cronwatch.web.open=true in Java, new RoutesOptions { Token = DashboardToken.None } in .NET) rather than exposing it. - Make sure the process that runs the check knows every job it should expect, or a job that never starts at all goes unnoticed. If installs differ, declare a job there only where that install runs it. - Work that starts in one call and finishes in another (a webhook, a queue step, a workflow) uses await job.start({ id }) where it begins and (await job.resume(id)).finish() where it ends, with the same id both times (job.start(id: id) and job.resume(id).finish in Ruby; job.start(id=id) and job.resume(id).finish() in Python; $job->start(id: $id) and $job->resume($id)->finish() in PHP; job.Start(ctx, cronwatch.WithRunID(id)) and job.Resume(ctx, id), then Finish(ctx), in Go; job.start(StartOptions::new().id(id)).await? and job.resume(id).await?, then .finish().await, in Rust; Cronwatch.start(job, id: id) and Cronwatch.resume(job, id), then Cronwatch.finish(run), in Elixir; job.start(StartOptions.id(id)) and job.resume(id), then .finish(), in Java; job.StartAsync(new StartOptions { Id = id }) and job.ResumeAsync(id), then FinishAsync(), in .NET). - A job that runs where the network or the app's secrets are unavailable gets its own client with deliver: "check" (deliver = :check in Ruby, deliver="check" in Python, deliver: 'check' in PHP, cronwatch.WithDeliver(cronwatch.DeliverAtCheck) in Go, .deliver(Deliver::AtCheck) in Rust, deliver: :check in Elixir, .deliver(Deliver.AT_CHECK) in Java, Deliver = Deliver.AtCheck in .NET) and the same store; the next check sends its alerts. Docs: https://cronwatch.dev/docs/ Rails docs: https://cronwatch.dev/docs/rails/ Python docs: https://cronwatch.dev/docs/python/, https://cronwatch.dev/docs/django/, and https://cronwatch.dev/docs/celery/ PHP docs: https://cronwatch.dev/docs/php/, https://cronwatch.dev/docs/laravel/, https://cronwatch.dev/docs/symfony/, https://cronwatch.dev/docs/wordpress/, https://cronwatch.dev/docs/drupal/, and https://cronwatch.dev/docs/craft/ Go docs: https://cronwatch.dev/docs/go/ and https://cronwatch.dev/docs/go-schedulers/ Rust docs: https://cronwatch.dev/docs/rust/ and https://cronwatch.dev/docs/rust-schedulers/ Elixir docs: https://cronwatch.dev/docs/elixir/ and https://cronwatch.dev/docs/elixir-schedulers/ Java docs: https://cronwatch.dev/docs/java/ and https://cronwatch.dev/docs/java-schedulers/ .NET docs: https://cronwatch.dev/docs/dotnet/ and https://cronwatch.dev/docs/dotnet-schedulers/ MCP server for agents: npx -y @cronwatch/mcp, https://cronwatch.dev/docs/mcp/