cronwatch 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d39b4e146c8c67dc490eefa94ea08be381f5851367f5827e4afb4f6a65b610f4
4
- data.tar.gz: aafe200c2ae1f93ec641775edbd58416ddfa7f1508cd1f1b93929877e0e6aaf8
3
+ metadata.gz: 5e036977146b656ead881d9668a920e8de3a42a9011284a20e21f839a46590ed
4
+ data.tar.gz: d0647d96854893d1bf7932d36ba5732ec50cec4a9b8427dc107e14fb8f11fcc8
5
5
  SHA512:
6
- metadata.gz: 486579780d8c1a48e223c7bd321ff91a592db213d364db4e80167c5edd0652005ef7ecb76a7f0922e4fcab30466f0938cb73dc1b7d8dc42575e0eca69057d4b7
7
- data.tar.gz: 79a910d5c1acd953fdeaf778007857c1ed962a53107474d2a6a0d655d876be5e6146423d78d69e0fc78ee2f490f8682f210782e19582bc97275ca28e9e001cc9
6
+ metadata.gz: 34ea25633ee722da6ef7949f96b9e26856a4e53d800728308e47c5a07bc5cbef16dacc3a8ba3e771439339b77eed2bdec1af2a7e1b4a54361f8ca17f7d31b9d3
7
+ data.tar.gz: 37b1b660cf399b07f9dfdf3366cc5b43b2782b22ff70284cbd642463916f0de278c88ecb740c1ab4632547d609a6e05f56e2dcaaee6ef733bd55bf23bff107b1
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # cronwatch
2
2
 
3
- Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow, goes over budget or quietly does nothing. No server to run, no account to make.
3
+ Cron and scheduled-job monitoring that lives inside your Ruby or Rails app. Wrap a job once; every run is recorded in a database you already have, and you are told when a run is missed, fails, gets stuck, runs slow, goes over budget, or quietly does nothing. No server to run, no account to make.
4
4
 
5
- This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text and the same stored rows, so a Ruby process can share one database with a Node, Python, PHP, Go or Rust process, and [`@cronwatch/mcp`](https://www.npmjs.com/package/@cronwatch/mcp) works against any of them.
5
+ This is the Ruby port of [`@cronwatch/sdk`](https://www.npmjs.com/package/@cronwatch/sdk): the same rules, the same alert text, the same dashboard, and the same stored rows, so a Ruby process can share one database with a Node, Python, PHP, Go, Rust, Elixir, Java, or .NET process, and [`@cronwatch/mcp`](https://www.npmjs.com/package/@cronwatch/mcp) works against any of them. Behaviour, alert text, and the dashboard match the SDK exactly: the test suite replays the SDK's own cases.
6
6
 
7
7
  Docs: [cronwatch.dev/docs/rails](https://cronwatch.dev/docs/rails/) and [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/)
8
8
 
@@ -13,11 +13,11 @@ Docs: [cronwatch.dev/docs/rails](https://cronwatch.dev/docs/rails/) and [cronwat
13
13
  gem "cronwatch"
14
14
  ```
15
15
 
16
- Ruby 3.2 or newer; the Rails integration is tested on Rails 7.2, 8.0 and 8.1. The only dependency is `fugit`, the cron parser Solid Queue and sidekiq-cron already bring. Everything else loads only from its own file:
16
+ Ruby 3.2 or newer; the Rails integration is tested on Rails 7.2, 8.0, and 8.1. The only dependency is `fugit`, the cron parser Solid Queue and sidekiq-cron already bring. Everything else loads only from its own file:
17
17
 
18
18
  | Require | For | Needs |
19
19
  |---|---|---|
20
- | `cronwatch` | the client, the memory store, and the Slack, Discord, webhook, console, email (Resend, Postmark, SendGrid, Mailgun, SES), Twilio and error tracker (Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, New Relic) channels | |
20
+ | `cronwatch` | the client, the memory store, and the Slack, Discord, webhook, console, email (Resend, Postmark, SendGrid, Mailgun, SES), Twilio, and error tracker (Sentry, Honeybadger, Datadog, Rollbar, Bugsnag, New Relic) channels | |
21
21
  | `cronwatch/pg_cron` | `Cronwatch::Sources::PgCron`, which watches pg_cron jobs through an ActiveRecord or pg connection | |
22
22
  | `cronwatch/active_record` | the ActiveRecord store | `activerecord` |
23
23
  | `cronwatch/rails` | the Railtie, `Cronwatch::ActiveJob`, `Cronwatch::CheckJob`, `Cronwatch::Web`, the `cronwatch:check` task, the install generator | `railties`, `activejob` |
@@ -26,7 +26,7 @@ Ruby 3.2 or newer; the Rails integration is tested on Rails 7.2, 8.0 and 8.1. Th
26
26
  | `cronwatch/web` | the dashboard and JSON API as a Rack app | `rack` |
27
27
  | `cronwatch/triage/anthropic` | Claude triage | `anthropic` |
28
28
 
29
- In a Rails app, `gem "cronwatch"` also loads `cronwatch/rails`, `cronwatch/web` and `cronwatch/scheduler` (Bundler requires it after Rails), `cronwatch/sidekiq` when Sidekiq is in the bundle, and the ActiveRecord store on first use. `cronwatch/triage/anthropic` is always required by hand, and so is `cronwatch/web` outside Rails.
29
+ In a Rails app, `gem "cronwatch"` also loads `cronwatch/rails`, `cronwatch/web`, and `cronwatch/scheduler` (Bundler requires it after Rails), plus `cronwatch/sidekiq` when Sidekiq is in the bundle and the ActiveRecord store on first use. `cronwatch/triage/anthropic` is always required by hand, and so is `cronwatch/web` outside Rails.
30
30
 
31
31
  ## Plain Ruby
32
32
 
@@ -44,7 +44,7 @@ NIGHTLY = CW.job("nightly-report",
44
44
  NIGHTLY.run do |job|
45
45
  path = build_report
46
46
  job.log("Report written:", path) # kept with the run, shown in alerts
47
- job.metric(:cost, 1.2) # watched against budgets, floors and baselines
47
+ job.metric(:cost, 1.2) # watched against budgets, floors, and baselines
48
48
  job.metric(:pages, 12) # fewer than 1 page is under the floor
49
49
  end
50
50
 
@@ -65,7 +65,7 @@ run.finish # or run.fail(error); finish("text") and fin
65
65
 
66
66
  Neither raises for the store: failures go to `on_error`, and a finish of a run already finished, not found, or of another job records nothing and returns nil. A run is judged once, however many processes finish it at once, and a store that fails during `finish` leaves the handle active to finish again. A run never finished is marked stuck after the job's `timeout`.
67
67
 
68
- Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `sources` (where runs the process does not wrap come from, such as pg_cron), `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error` and `now`. Methods: `job`, `run`, `resume_run`, `record_run`, `check`, `start_checking`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
68
+ Client options: `store`, `alerts`, `triage`, `cron_secret`, `retention` (default `"30d"`), `defaults`, `sources` (where runs the process does not wrap come from, such as pg_cron), `redact` (default: blank values that look like secrets; `false` keeps output as logged, or pass a callable), `deliver` (`:now` by default; `:check` queues alerts for another process's check to send, for a worker that cannot reach Slack), `on_error`, and `now`. Methods: `job`, `run`, `resume_run`, `record_run`, `check`, `start_checking`/`stop`, `silence(name, for: "2h")`/`unsilence`, `forget`, `jobs`, `jobs_with_runs`, `job_summary`, `runs`, `get_run`, `defined_jobs`, `close`. [cronwatch.dev/docs/ruby](https://cronwatch.dev/docs/ruby/#api) has each one.
69
69
 
70
70
  ## Rails
71
71
 
@@ -75,7 +75,7 @@ bin/rails generate cronwatch:install # a migration and config/initializers/cr
75
75
  bin/rails db:migrate
76
76
  ```
77
77
 
78
- The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database, from `config/database.yml`, whose migrations directory gets the migration; it also writes `app/models/cronwatch_record.rb`, an abstract class that `connects_to` that database, and names it as the store's `connection_class`, so the store reads the tables where the migration made them). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker and check processes see the same runs:
78
+ The generator takes `--prefix` (table prefix, default `cronwatch_`) and `--database` (the database, from `config/database.yml`, whose migrations directory gets the migration; it also writes `app/models/cronwatch_record.rb`, an abstract class that `connects_to` that database, and names it as the store's `connection_class`, so the store reads the tables where the migration made them). The initializer it writes sets the ActiveRecord store, which Rails needs so web, worker, and check processes see the same runs:
79
79
 
80
80
  ```ruby
81
81
  # config/initializers/cronwatch.rb
@@ -101,7 +101,7 @@ class NightlyReportJob < ApplicationJob
101
101
  end
102
102
  ```
103
103
 
104
- Every `perform` is recorded as a run with the trigger `"active-job"` (runs recorded before 1.0 carry `"active_job"`). The name defaults to the class name without `Job`, dasherized, with `::` as `:` (`Reports::NightlyJob` is `reports:nightly`); pass `name:` to choose another. Jobs are declared once the app has booted, so a check knows a job that has never run. A job that raises still raises after the run is recorded, so ActiveJob retries and your error reporter see it as before.
104
+ Every `perform` is recorded as a run with the trigger `"active-job"` (runs recorded before 0.11 carry `"active_job"`). The name defaults to the class name without `Job`, dasherized, with `::` as `:` (`Reports::NightlyJob` is `reports:nightly`); pass `name:` to choose another. Jobs are declared once the app has booted, so a check knows a job that has never run. A job that raises still raises after the run is recorded, so ActiveJob retries and your error reporter see it as before.
105
105
 
106
106
  ### Sidekiq
107
107
 
@@ -117,7 +117,7 @@ class HardWorker
117
117
  end
118
118
  ```
119
119
 
120
- The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (or `Sidekiq::Worker`) directly. `Cronwatch::Sidekiq::ServerMiddleware` records each `perform` with the trigger `"sidekiq"` and raises the job's error on to Sidekiq, so retries and error handlers behave as before. In Rails the Railtie adds it to the Sidekiq server; elsewhere add it in `Sidekiq.configure_server` and call `Cronwatch::Sidekiq.ready!` once `Cronwatch.configure` has run. An ActiveJob class on Sidekiq's adapter keeps `Cronwatch::ActiveJob`; the middleware passes ActiveJob's wrapper through, so it is recorded once.
120
+ The same `cronwatch` as for ActiveJob, for classes that include `Sidekiq::Job` (or `Sidekiq::Worker`) directly. `Cronwatch::Sidekiq::ServerMiddleware` records each `perform` with the trigger `"sidekiq"` and raises the job's error on to Sidekiq, so retries and error handlers behave as before. In Rails the Railtie adds it to the Sidekiq server; elsewhere add it in `Sidekiq.configure_server` and call `Cronwatch::Sidekiq.ready!` once `Cronwatch.configure` has run. An ActiveJob class on Sidekiq's adapter keeps `Cronwatch::ActiveJob`; the middleware passes ActiveJob's wrapper through, so it is recorded once. A job stopped by Sidekiq's shutdown (`Sidekiq::Shutdown`, raised into a job still running when a deploy outlasts Sidekiq's timeout) goes back on the queue and runs again after the restart, so its run is recorded as failed (`Interrupted: Sidekiq::Shutdown`) but not judged: it opens no failed alert and does not count toward `failures_before_alert`. The same holds for an ActiveJob class on Sidekiq's adapter.
121
121
 
122
122
  ### Schedules from the scheduler's config
123
123
 
@@ -131,7 +131,7 @@ reads the class's one entry in Solid Queue's `config/recurring.yml` (the section
131
131
 
132
132
  ### Scheduling the check
133
133
 
134
- Failures are caught as they happen, but a run that never started or never finished can only be noticed by looking. `Cronwatch::CheckJob` looks: it loads `app/jobs` (and `app/workers` and `app/sidekiq`) when the app does not eager load, declares every monitored job and runs the check. Run it every five minutes.
134
+ Failures are caught as they happen, but a run that never started or never finished can only be noticed by looking. `Cronwatch::CheckJob` looks: it loads `app/jobs` (and `app/workers` and `app/sidekiq`) when the app does not eager load, declares every monitored job, and runs the check. Run it every five minutes.
135
135
 
136
136
  Solid Queue:
137
137
 
@@ -169,7 +169,7 @@ Rails.application.routes.draw do
169
169
  end
170
170
  ```
171
171
 
172
- Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. Without a token, while the environment (the first of `CRONWATCH_ENV`, `APP_ENV`, then `Rails.env` (when Rails is loaded), `RAILS_ENV` or `RACK_ENV` that holds more than spaces, trimmed and lowercased) is `development`, `dev`, `local`, `test` or `testing`, it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. Puma, Unicorn, Thin and rackup set `RACK_ENV` to `development` when nothing names an environment, production included, so under one of them `RACK_ENV=development` alone does not count: `CRONWATCH_ENV`, `APP_ENV`, `Rails.env` or `RAILS_ENV` must say development (or `RACK_ENV` say `test`, `dev` or `local`). To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is what Rack reports and so already follows `X-Forwarded-Host` and `X-Forwarded-Proto`; pass `origin: "https://app.example.com"` to pin it instead. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
172
+ Set `CRONWATCH_TOKEN` to a long random string and open `/cronwatch?token=<it>` once; the browser keeps a cookie. The token and `CRON_SECRET` are trimmed of the whitespace around them, so one read from a file with a trailing newline still works. Without a token, while the environment (the first of `CRONWATCH_ENV`, `APP_ENV`, then `Rails.env` (when Rails is loaded), `RAILS_ENV`, or `RACK_ENV` that holds more than spaces, trimmed and lowercased) is `development`, `dev`, or `local` (not `test` or `testing`: a test run or CI needs a token, as production does), it makes a token of its own and prints a sign-in link to standard output on its first request (open it once; unless `origin:` is set or the request's host is loopback, the link leaves the host out, since a client chooses it); anywhere else it answers 503. Puma, Unicorn, Thin, and rackup set `RACK_ENV` to `development` when nothing names an environment, production included, so under one of them `RACK_ENV=development` alone does not count: `CRONWATCH_ENV`, `APP_ENV`, `Rails.env`, or `RAILS_ENV` must say development (or `RACK_ENV` say `dev` or `local`). To rely on the app's own sign in, mount it behind that (Devise's `authenticate` block, or a routing constraint) and pass `token: nil`. The URLs, JSON shapes, headers, and CSRF rules are the SDK's, so the MCP server reads it unchanged. The request's origin, which the CSRF check compares against, is the request's own scheme and `Host`. Behind a proxy, pass `trust_proxy: true` to read the first `X-Forwarded-Proto` and `X-Forwarded-Host` instead (only when the proxy sets or overwrites both, since a client can send them too), or `origin: "https://app.example.com"` to pin it. Under Rails `trust_proxy` defaults to true, as Rails itself trusts those headers; anywhere else it defaults to false, as in the SDK. `GET /cronwatch/api/check` with a bearer (the token or `CRON_SECRET`) runs the check, for an outside cron.
173
173
 
174
174
  There is no `handler()` as in the TypeScript SDK: for a job triggered over HTTP, wrap the controller action's body in `CW.job(...).run` (declared once, at boot) and check the bearer in the controller.
175
175
 
@@ -180,20 +180,20 @@ In any other Rack app:
180
180
  map("/cronwatch") { run CW.routes }
181
181
  ```
182
182
 
183
- `client.routes(**options)` (`token:`, `base_path:`, `origin:`) is the one way to build the dashboard, as `routes()` is in every language.
183
+ `client.routes(**options)` (`token:`, `base_path:`, `origin:`, `trust_proxy:`) is the one way to build the dashboard, as `routes()` is in every language.
184
184
 
185
185
  ### Public and internal
186
186
 
187
- Public means what this README and the docs at cronwatch.dev name. Everything else is internal and may change in any release: anything marked `@api private` in the source (`Evaluate`, `Schedule`, `Format`, `JS`, `HTTP` and the dashboard's helpers among them), and the constants made private with `private_constant`.
187
+ Public means what this README and the docs at cronwatch.dev name. Everything else is internal and may change in any release: anything marked `@api private` in the source (`Evaluate`, `Schedule`, `Format`, `JS`, `HTTP`, and the dashboard's helpers among them), and the constants made private with `private_constant`.
188
188
 
189
189
  ### Deprecated
190
190
 
191
- These still work through every 1.x release and go in 2.0, each with a warning in Ruby's deprecation category (shown under `ruby -w` or `-W:deprecated`): `Cronwatch::Web.new(client, **options)` (use `client.routes(**options)`), `client.start(every)` (use `client.start_checking(every)`), `client.run(id)` without a block (use `client.get_run(id)`) and `client.silence(name, "2h")` (use `client.silence(name, for: "2h")`). The ActiveJob trigger is `active-job` from 1.0; runs recorded before keep `active_job`.
191
+ These still work through every 1.x release and go in 2.0, each with a warning in Ruby's deprecation category (shown under `ruby -w` or `-W:deprecated`): `Cronwatch::Web.new(client, **options)` (use `client.routes(**options)`), `client.start(every)` (use `client.start_checking(every)`), `client.run(id)` without a block (use `client.get_run(id)`), and `client.silence(name, "2h")` (use `client.silence(name, for: "2h")`). The ActiveJob trigger has been `active-job` since 0.11; runs recorded before keep `active_job`.
192
192
 
193
193
  ## Stores
194
194
 
195
195
  - `Cronwatch::Stores::Memory.new`: the default. Nothing survives a restart.
196
- - `Cronwatch::Stores::ActiveRecord.new(prefix: "cronwatch_", connection_class: nil)`: Postgres or SQLite through ActiveRecord, in `cronwatch_jobs`, `cronwatch_runs` and `cronwatch_state`. `connection_class` picks the pool (a class, or its name). In Rails the generator's migration creates the tables; elsewhere call `Cronwatch::Stores::ActiveRecord.create_tables!` (and `drop_tables!`). The store never creates them itself, and raises `MissingTables` when they are not there. On Postgres it writes through a connection pool of its own (up to the database config's `pool` more connections per process), so a run is recorded outside any transaction the app has open and stays recorded when that transaction rolls back; on SQLite it uses the app's pool, and inside an open transaction each call runs in a savepoint. It always writes on the writing role, even inside `connected_to(role: :reading)`. Other adapters, MySQL among them, are refused with `UnsupportedAdapter`.
196
+ - `Cronwatch::Stores::ActiveRecord.new(prefix: "cronwatch_", connection_class: nil)`: Postgres or SQLite through ActiveRecord, in `cronwatch_jobs`, `cronwatch_runs`, and `cronwatch_state`. `connection_class` picks the pool (a class, or its name). In Rails the generator's migration creates the tables; elsewhere call `Cronwatch::Stores::ActiveRecord.create_tables!` (and `drop_tables!`). The store never creates them itself, and raises `MissingTables` when they are not there. On Postgres it writes through a connection pool of its own (up to the database config's `pool` more connections per process), so a run is recorded outside any transaction the app has open and stays recorded when that transaction rolls back; on SQLite it uses the app's pool, and inside an open transaction each call runs in a savepoint. It always writes on the writing role, even inside `connected_to(role: :reading)`. Other adapters, MySQL among them, are refused with `UnsupportedAdapter`.
197
197
 
198
198
  Finished runs older than `retention` (default `"30d"`) are pruned by the check; each job's newest run is kept.
199
199
 
@@ -235,15 +235,7 @@ Adds two to four sentences from Claude (likely cause, first thing to check) to e
235
235
 
236
236
  ## Sharing a database with a Node app
237
237
 
238
- The ActiveRecord store writes the same three tables as `@cronwatch/sdk/postgres` and `@cronwatch/sdk/sqlite`: same names, columns and indexes, epoch milliseconds in the time columns, the SDK's camelCase JSON in the JSON columns. [`test/active_record/node_compat_test.rb`](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/test/active_record/node_compat_test.rb) runs the SDK's stores in Node beside this one, on SQLite and Postgres, and checks that each reads what the other wrote, that the tables are the same whoever creates them, and that the rows are the same bytes. Use the same prefix on both sides and give each job a name only one side uses. Then one dashboard, Rails or Node, shows every job, and one MCP server reads them all. The Python, PHP, Go and Rust ports keep the same tables too, so a process in any of them can share the database the same way.
239
-
240
- ## Kept in step with the TypeScript SDK
241
-
242
- The TypeScript SDK is the source of truth. `npm run conformance` at the repository root runs it and writes JSON cases to `conformance/`: duration parsing and formatting, schedules including daylight saving, sequences of runs and checks with the alerts and state they must produce, alert titles and messages, stats and health. This gem's tests replay every case, and the SDK's own check fails when the files are stale. A change of behaviour lands in TypeScript first, the cases are regenerated, and the gem is fixed until its tests pass. When the two disagree, the Ruby side is wrong.
243
-
244
- The dashboard is held to the SDK the same way: [`test/web/golden.json`](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/test/web/golden.json) records what the SDK's routes answer to a fixed set of requests, and [`test/web_golden_test.rb`](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/test/web_golden_test.rb) makes `Cronwatch::Web` answer them byte for byte.
245
-
246
- The design of the port is in [DESIGN.md](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/DESIGN.md).
238
+ The ActiveRecord store writes the same three tables as `@cronwatch/sdk/postgres` and `@cronwatch/sdk/sqlite`: same names, columns, and indexes, epoch milliseconds in the time columns, the SDK's camelCase JSON in the JSON columns. [`test/active_record/node_compat_test.rb`](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/test/active_record/node_compat_test.rb) runs the SDK's stores in Node beside this one, on SQLite and Postgres, and checks that each reads what the other wrote, that the tables are the same whoever creates them, and that the rows are the same bytes. Use the same prefix on both sides and give each job a name only one side uses. Then one dashboard, Rails or Node, shows every job, and one MCP server reads them all. The other ports (Python, PHP, Go, Rust, Elixir, Java, and .NET) keep the same tables too, so a process in any of them can share the database the same way.
247
239
 
248
240
  ## Testing
249
241
 
@@ -252,42 +244,7 @@ bundle install
252
244
  bundle exec rake test
253
245
  ```
254
246
 
255
- `rake test` runs four suites, each in its own process: `rake test:core` (the client, channels, conformance, the Rack app, Sidekiq without Rails, schedule conversion checked against Fugit), `rake test:slow` (every schedule conversion walked against Fugit across a year, some ten seconds), `rake test:active_record` (the store, on SQLite, and on Postgres when `CRONWATCH_TEST_PG` is set) and `rake test:rails` (a small Rails app: ActiveJob, Sidekiq, schedules from `recurring.yml` and `schedule.yml`, `CheckJob`, the rake task, the generator).
256
-
257
- ```sh
258
- CRONWATCH_TEST_PG=postgres://postgres:pw@127.0.0.1:5432/cw bundle exec rake test
259
- ```
260
-
261
- The node compatibility tests run the built SDK and its drivers, and skip themselves otherwise, so build it first at the repository root:
262
-
263
- ```sh
264
- npm ci && npm run build
265
- ```
266
-
267
- The default Gemfile tests Rails 8.1. Each supported Rails series has its own Gemfile, with its own lockfile, in [`test/rails/gemfiles`](https://github.com/cronwatchdev/cronwatch/tree/main/packages/ruby/test/rails/gemfiles):
268
-
269
- ```sh
270
- BUNDLE_GEMFILE=test/rails/gemfiles/rails_7_2.gemfile bundle install
271
- BUNDLE_GEMFILE=test/rails/gemfiles/rails_7_2.gemfile bundle exec rake test
272
- ```
273
-
274
- `rails_8_0.gemfile` and `rails_8_1.gemfile` work the same way. Sidekiq and sidekiq-cron are test gems too: Rails 8.0 and 8.1 resolve Sidekiq 8, and `rails_7_2.gemfile` pins Sidekiq 7 (`CRONWATCH_SIDEKIQ=7`), so both majors are tested. CI runs Ruby 3.2 with Rails 7.2, Ruby 3.4 with Rails 8.0 and 8.1, and Ruby 4.0 with Rails 8.1, all against Postgres.
275
-
276
- When the SDK's routes or pages change, regenerate the dashboard fixture from the repository root:
277
-
278
- ```sh
279
- npm run build --workspace packages/sdk && TZ=UTC node packages/ruby/test/web/golden.mjs
280
- ```
281
-
282
- `npm run check` fails while `test/web/golden.json` or `conformance/` is stale.
283
-
284
- The MCP server's tests can also drive this gem's `Cronwatch::Web` over HTTP ([`test/web/server.rb`](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/test/web/server.rb)). They need `fugit`, `rack` and a server rackup can start (puma, or webrick, which the Gemfile's test group has) in the Ruby they run, and only run when asked, from `packages/mcp`:
285
-
286
- ```sh
287
- CRONWATCH_TEST_RUBY=1 CRONWATCH_RUBY="rbenv exec ruby" npm test
288
- ```
289
-
290
- `CRONWATCH_RUBY` is the command that runs Ruby; it defaults to `ruby`.
247
+ The Postgres tests run when `CRONWATCH_TEST_PG` names a database (`CRONWATCH_TEST_PG=postgres://postgres:pw@127.0.0.1:5432/cw bundle exec rake test`), and the pg_cron tests when `CRONWATCH_TEST_PGCRON` names a Postgres with pg_cron; without them those tests skip. Each supported Rails series has its own Gemfile in [`test/rails/gemfiles`](https://github.com/cronwatchdev/cronwatch/tree/main/packages/ruby/test/rails/gemfiles) (`BUNDLE_GEMFILE=test/rails/gemfiles/rails_7_2.gemfile bundle exec rake test`). The rest of the testing notes, and the design of the port, are in [DESIGN.md](https://github.com/cronwatchdev/cronwatch/blob/main/packages/ruby/DESIGN.md#testing).
291
248
 
292
249
  ## License
293
250
 
@@ -6,7 +6,7 @@ module Cronwatch
6
6
  # job and alert type. `site` is your Datadog site: "datadoghq.com" (the
7
7
  # default), "datadoghq.eu", "us3.datadoghq.com", "us5.datadoghq.com",
8
8
  # "ap1.datadoghq.com", "ddog-gov.com". Every event is tagged cronwatch,
9
- # job:<name> and alert:<type>, then `tags`.
9
+ # job:<name>, and alert:<type>, then `tags`.
10
10
  class Datadog
11
11
  ALERT_TYPE = {
12
12
  missed: "error", failed: "error", stuck: "error", slow: "warning", over_budget: "warning", under_floor: "warning",
@@ -40,7 +40,7 @@ module Cronwatch
40
40
  "embeds" => [embed],
41
41
  }
42
42
  response = @http.post(@webhook_url, JS.json(payload), { "content-type" => "application/json" })
43
- raise "Discord webhook answered #{response.status}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
43
+ raise "Discord webhook answered #{response.status}: #{Provider.webhook_error_body(response, @webhook_url)}" unless response.ok?
44
44
  end
45
45
 
46
46
  # The message in a code block, then the triage. Each part has its own
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Cronwatch
4
4
  module Alerts
5
- # What every email channel sends: one subject, a plain text body and a
5
+ # What every email channel sends: one subject, a plain text body, and a
6
6
  # small HTML body, so an alert reads the same whichever provider carries
7
- # it (the SDK's alerts/email.ts). Resend, Postmark, SendGrid, Mailgun and
7
+ # it (the SDK's alerts/email.ts). Resend, Postmark, SendGrid, Mailgun, and
8
8
  # SES each take the same options:
9
9
  #
10
10
  # from: the sender, "alerts@example.com" or "CronWatch <alerts@example.com>"
@@ -24,7 +24,7 @@ module Cronwatch
24
24
  end
25
25
  end
26
26
 
27
- # The scheme, host and port only. A URL's path or query can hold a credential.
27
+ # The scheme, host, and port only. A URL's path or query can hold a credential.
28
28
  def origin(url)
29
29
  Webhook.origin(url)
30
30
  end
@@ -59,6 +59,24 @@ module Cronwatch
59
59
  JS.head16(head, ERROR_BODY_MAX)
60
60
  end
61
61
 
62
+ # The parts of a webhook URL that are its credential, to cut from an
63
+ # error body that echoes the request: its path and query together, then
64
+ # the last segment of its path (Slack's and Discord's token) on its own.
65
+ def url_secrets(url)
66
+ uri = URI.parse(url.to_s)
67
+ path = uri.path.nil? || uri.path.empty? ? "/" : uri.path
68
+ last = path.split("/", -1).last.to_s
69
+ ["#{path}#{"?#{uri.query}" if uri.query && !uri.query.empty?}", last].reject { |part| part.empty? || part == "/" }
70
+ rescue URI::Error
71
+ []
72
+ end
73
+
74
+ # The start of a webhook's error answer, with the URL's credential cut
75
+ # out in case a proxy echoes the request: Slack's and Discord's errors.
76
+ def webhook_error_body(response, url)
77
+ error_body(Output.utf8(response.body.to_s).delete_prefix("\u{FEFF}"), url_secrets(url))
78
+ end
79
+
62
80
  # A credential with the spaces and newlines a paste leaves around it
63
81
  # taken off, as it goes in a header. Anything not a String is "".
64
82
  def trimmed(value)
@@ -78,7 +96,7 @@ module Cronwatch
78
96
  Digest::SHA256.hexdigest(text.to_s)
79
97
  end
80
98
 
81
- # A stable 32 hex character id for one alert: the same job, type and
99
+ # A stable 32 hex character id for one alert: the same job, type, and
82
100
  # time always give the same id, so a provider that deduplicates on it
83
101
  # drops a resend of an alert it already took.
84
102
  def alert_id(alert)
@@ -107,7 +125,7 @@ module Cronwatch
107
125
  }
108
126
  end
109
127
 
110
- # Title, message, triage and link as one plain text block, the way every channel reads.
128
+ # Title, message, triage, and link as one plain text block, the way every channel reads.
111
129
  def plain_text(alert, link)
112
130
  lines = [alert.title, "", alert.message]
113
131
  lines.push("", "Triage: #{alert.triage}") if present?(alert.triage)
@@ -42,7 +42,7 @@ module Cronwatch
42
42
  "blocks" => blocks,
43
43
  }
44
44
  response = @http.post(@webhook_url, JS.json(payload), { "content-type" => "application/json" })
45
- raise "Slack webhook answered #{response.status}: #{JS.head16(response.body.to_s, 200)}" unless response.ok?
45
+ raise "Slack webhook answered #{response.status}: #{Provider.webhook_error_body(response, @webhook_url)}" unless response.ok?
46
46
  end
47
47
 
48
48
  # Slack's three control characters. Escaping < and > also stops <!channel> and <url|links>.
@@ -37,8 +37,15 @@ module Cronwatch
37
37
  OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, body.to_s)
38
38
  end
39
39
 
40
- def call(alert)
40
+ # `context` is the client's channel context: an empty-string secret (an
41
+ # unset variable read as "", say) signs nothing, and each send reports
42
+ # that through its on_error.
43
+ def call(alert, context = nil)
41
44
  body = JS.json({ "schema" => SCHEMA }.merge(alert.to_h))
45
+ if @secret == "" && context.respond_to?(:on_error)
46
+ context.on_error(RuntimeError.new("webhook: secret is an empty string, so this request is sent unsigned; " \
47
+ "set the secret, or leave it out to send unsigned on purpose"))
48
+ end
42
49
  headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
43
50
  # A pasted Authorization value often carries a stray space or newline, which a header would refuse.
44
51
  @headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }