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 +4 -4
- data/README.md +18 -61
- data/lib/cronwatch/alerts/datadog.rb +1 -1
- data/lib/cronwatch/alerts/discord.rb +1 -1
- data/lib/cronwatch/alerts/email.rb +2 -2
- data/lib/cronwatch/alerts/provider.rb +21 -3
- data/lib/cronwatch/alerts/slack.rb +1 -1
- data/lib/cronwatch/alerts/webhook.rb +8 -1
- data/lib/cronwatch/client.rb +153 -46
- data/lib/cronwatch/duration.rb +2 -2
- data/lib/cronwatch/environment.rb +34 -23
- data/lib/cronwatch/evaluate.rb +72 -11
- data/lib/cronwatch/format.rb +9 -1
- data/lib/cronwatch/http.rb +18 -7
- data/lib/cronwatch/js.rb +3 -3
- data/lib/cronwatch/monitored.rb +3 -1
- data/lib/cronwatch/output.rb +6 -6
- data/lib/cronwatch/pg_cron.rb +7 -7
- data/lib/cronwatch/rails/active_job.rb +1 -1
- data/lib/cronwatch/rails/check_job.rb +1 -1
- data/lib/cronwatch/rails.rb +1 -1
- data/lib/cronwatch/run_handle.rb +2 -2
- data/lib/cronwatch/scheduler.rb +6 -6
- data/lib/cronwatch/sidekiq.rb +5 -3
- data/lib/cronwatch/stores/active_record.rb +5 -5
- data/lib/cronwatch/stores/memory.rb +1 -1
- data/lib/cronwatch/triage/anthropic.rb +3 -3
- data/lib/cronwatch/types.rb +11 -7
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +149 -34
- data/lib/cronwatch/web/html.rb +2 -2
- data/lib/cronwatch/web/origin.rb +2 -2
- data/lib/cronwatch/web/pwa.rb +4 -4
- data/lib/cronwatch/web/timeline.rb +3 -3
- data/lib/cronwatch.rb +1 -1
- data/lib/generators/cronwatch/install/install_generator.rb +5 -5
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e036977146b656ead881d9668a920e8de3a42a9011284a20e21f839a46590ed
|
|
4
|
+
data.tar.gz: d0647d96854893d1bf7932d36ba5732ec50cec4a9b8427dc107e14fb8f11fcc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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}: #{
|
|
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}: #{
|
|
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
|
-
|
|
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 }
|