Skip to content

Commit 4e815b0

Browse files
feat: stale process webhook alert (#74)
* feat: stale process webhook alert Add StaleProcessAlert service and alert_stale_process_threshold config. Fires a webhook (event: stale_process_detected) when the number of stale workers meets or exceeds the configured count. A process is stale when last_heartbeat_at has not been updated within SolidQueue.process_alive_threshold. Uses the same alert_webhook_url and alert_webhook_cooldown settings as other alert types. Triggered on every dashboard page load, subject to cooldown. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update CHANGELOG, README, and ROADMAP for stale process webhook alert Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Chuck Smith <eclectic-coding@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b2ef8c7 commit 4e815b0

7 files changed

Lines changed: 242 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Slow job webhook alert — set `alert_slow_job_count_threshold` (integer) to fire a webhook whenever the number of currently-running slow jobs meets or exceeds the configured count; requires `slow_job_threshold` to define what "slow" means; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings as other alert types; event name `slow_job_threshold_exceeded`
13+
- Stale process webhook alert — set `alert_stale_process_threshold` (integer) to fire a webhook whenever the number of stale workers meets or exceeds the configured count; a process is stale when its heartbeat has not been updated within `SolidQueue.process_alive_threshold`; uses the same `alert_webhook_url` and `alert_webhook_cooldown` settings; event name `stale_process_detected`
1314

1415
## [1.3.0] - 2026-05-27
1516

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
5353
- **Dashboard quick actions** — "Retry All Failed" and "Discard All Blocked" cards appear on the dashboard only when the respective count is non-zero; one-click bulk operations with confirm dialogs, keeping the dashboard clean when everything is healthy
5454
- **CSV export** — "Export CSV" button on the jobs, failed jobs, and history pages downloads all records matching the current filters; columns are tailored per view
5555
- **Slow job detection** — when `slow_job_threshold` is configured, claimed jobs running longer than the threshold are flagged with an orange row, a "slow" badge, and a "Running For" duration column on the Running tab; a "Slow Jobs" warning card appears on the dashboard with a link to the Running tab
56-
- **Webhook alerts** — set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold; set `alert_queue_thresholds` for per-queue depth alerts; set `alert_slow_job_count_threshold` (requires `slow_job_threshold`) for slow-job count alerts; all fire asynchronously with a configurable cooldown (default 1 h) to prevent repeated alerts
56+
- **Webhook alerts** — set `alert_webhook_url` and `alert_failure_threshold` to receive a POST request whenever the failed job count meets or exceeds the threshold; set `alert_queue_thresholds` for per-queue depth alerts; set `alert_slow_job_count_threshold` (requires `slow_job_threshold`) for slow-job count alerts; set `alert_stale_process_threshold` for stale-worker alerts; all fire asynchronously with a configurable cooldown (default 1 h) to prevent repeated alerts
5757
- **Performance analytics** — per-job-class statistics at `/jobs/performance` showing run count, average, p50, p95, p99, standard deviation, min, and max duration; sorted by p95 descending so the slowest classes surface first; high std dev surfaces inconsistent jobs worth investigating; period filter scopes to 1h / 24h / 7d or all time; each class name links to the filtered History view
5858
- **Failed job trend chart** — a "Failures — Last 12 Hours" bar chart on the dashboard shows failures per hour over the last 12 hours; bars are red, making failure spikes visible before clicking into the failed jobs list
5959
- **Error frequency report**`GET /jobs/failed_jobs/errors` groups all failed jobs by error class and message prefix, shows a count per group, and surfaces a sample backtrace in an expandable row; sorted by count descending so the most common errors appear first; accessible via the "Error Summary" button on the Failed Jobs page
@@ -107,7 +107,8 @@ SolidQueueWeb.configure do |config|
107107
config.alert_webhook_url = "https://hooks.example.com/solid-queue" # POST target — string or array (default: nil = disabled)
108108
config.alert_failure_threshold = 10 # fire when failed count >= this (default: nil = disabled)
109109
config.alert_queue_thresholds = { "critical" => 50, "default" => 200 } # fire when queue depth >= threshold (default: {})
110-
config.alert_slow_job_count_threshold = 5 # fire when slow job count >= this (default: nil = disabled)
110+
config.alert_slow_job_count_threshold = 5 # fire when slow job count >= this (default: nil = disabled)
111+
config.alert_stale_process_threshold = 1 # fire when stale process count >= this (default: nil = disabled)
111112
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
112113
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
113114
config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC)
@@ -209,6 +210,31 @@ The same `alert_webhook_url` endpoint(s) receive the payload with a distinct eve
209210

210211
The alert fires on every dashboard page load while the condition persists, subject to the cooldown window.
211212

213+
## Stale process alerts
214+
215+
Set `alert_stale_process_threshold` to fire a webhook when the number of stale workers meets or exceeds a count. A process is considered stale when its `last_heartbeat_at` has not been updated within `SolidQueue.process_alive_threshold` (default 5 minutes). A stale worker means jobs in its queues have silently stopped processing.
216+
217+
```ruby
218+
SolidQueueWeb.configure do |config|
219+
config.alert_stale_process_threshold = 1 # fire when any process goes stale
220+
config.alert_webhook_url = "https://hooks.example.com/solid-queue"
221+
config.alert_webhook_cooldown = 1800 # don't re-fire for 30 minutes (default: 3600)
222+
end
223+
```
224+
225+
The same `alert_webhook_url` endpoint(s) receive the payload with a distinct event type:
226+
227+
```json
228+
{
229+
"event": "stale_process_detected",
230+
"stale_process_count": 2,
231+
"threshold": 1,
232+
"fired_at": "2026-05-28T08:00:00Z"
233+
}
234+
```
235+
236+
The alert fires on every dashboard page load while the condition persists, subject to the cooldown window.
237+
212238
## Metrics endpoint
213239

214240
`GET /jobs/metrics.json` returns a machine-readable JSON document suitable for Prometheus scraping, uptime monitors, or external dashboards. No configuration is required — the endpoint is available as soon as the engine is mounted.

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib
1515
| Feature | Notes |
1616
|---|---|
1717
| ~~**Slow job webhook alert**~~ | ✅ Shipped in v1.4 — `alert_slow_job_count_threshold` fires when slow-job count meets or exceeds the threshold. |
18-
| **Process stale webhook alert** | Fire when a worker's `last_heartbeat_at` expires. A worker going silent means jobs stop processing silently. |
18+
| ~~**Process stale webhook alert**~~ | ✅ Shipped in v1.4 — `alert_stale_process_threshold` fires when stale worker count meets or exceeds the threshold. |
1919
| **Job wait time column** | Show time from `enqueued_at` to `created_at` on claimed executions — a direct measure of queue SLA. |
2020

2121
---

app/controllers/solid_queue_web/dashboard_controller.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ def index
55
AlertWebhook.call(failure_count: @stats.counts[:failed])
66
QueueDepthAlert.call
77
SlowJobAlert.call
8+
StaleProcessAlert.call
89
end
910
end
1011
end
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
require "net/http"
2+
require "json"
3+
require "uri"
4+
5+
module SolidQueueWeb
6+
class StaleProcessAlert
7+
MUTEX = Mutex.new
8+
9+
class << self
10+
def call
11+
return unless configured?
12+
13+
stale_count = SolidQueue::Process
14+
.where("last_heartbeat_at < ?", SolidQueue.process_alive_threshold.ago)
15+
.count
16+
17+
return if stale_count < SolidQueueWeb.alert_stale_process_threshold
18+
return unless should_fire?
19+
20+
urls = webhook_urls
21+
Thread.new { urls.each { |url| post(url, stale_count) } }
22+
end
23+
24+
def reset!
25+
MUTEX.synchronize { @last_fired_at = nil }
26+
end
27+
28+
private
29+
30+
def configured?
31+
SolidQueueWeb.alert_stale_process_threshold.present? && webhook_urls.any?
32+
end
33+
34+
def webhook_urls
35+
Array(SolidQueueWeb.alert_webhook_url).flatten.compact.select(&:present?)
36+
end
37+
38+
def should_fire?
39+
MUTEX.synchronize do
40+
cooldown = SolidQueueWeb.alert_webhook_cooldown
41+
return false if @last_fired_at && Time.current - @last_fired_at < cooldown
42+
43+
@last_fired_at = Time.current
44+
true
45+
end
46+
end
47+
48+
def post(url_string, stale_count)
49+
uri = URI.parse(url_string)
50+
payload = JSON.generate(
51+
event: "stale_process_detected",
52+
stale_process_count: stale_count,
53+
threshold: SolidQueueWeb.alert_stale_process_threshold,
54+
fired_at: Time.current.iso8601
55+
)
56+
http = Net::HTTP.new(uri.host, uri.port)
57+
http.use_ssl = uri.scheme == "https"
58+
http.open_timeout = 5
59+
http.read_timeout = 10
60+
request = Net::HTTP::Post.new(uri.path.presence || "/", "Content-Type" => "application/json")
61+
request.body = payload
62+
http.request(request)
63+
rescue => e
64+
Rails.logger.error("[SolidQueueWeb] Stale process alert webhook failed: #{e.message}")
65+
end
66+
end
67+
end
68+
end

lib/solid_queue_web.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ module SolidQueueWeb
66
class << self
77
attr_writer :page_size, :dashboard_refresh_interval, :default_refresh_interval, :search_results_limit,
88
:slow_job_threshold, :alert_webhook_url, :alert_failure_threshold, :alert_webhook_cooldown,
9-
:alert_queue_thresholds, :alert_slow_job_count_threshold, :connects_to, :time_zone
9+
:alert_queue_thresholds, :alert_slow_job_count_threshold, :alert_stale_process_threshold,
10+
:connects_to, :time_zone
1011

1112
def page_size
1213
@page_size || 25
@@ -48,6 +49,10 @@ def alert_slow_job_count_threshold
4849
@alert_slow_job_count_threshold
4950
end
5051

52+
def alert_stale_process_threshold
53+
@alert_stale_process_threshold
54+
end
55+
5156
def connects_to
5257
@connects_to
5358
end
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
require "rails_helper"
2+
3+
RSpec.describe SolidQueueWeb::StaleProcessAlert do
4+
let(:webhook_url) { "http://example.com/webhook" }
5+
6+
before do
7+
SolidQueueWeb.alert_webhook_url = webhook_url
8+
SolidQueueWeb.alert_stale_process_threshold = 1
9+
SolidQueueWeb.alert_webhook_cooldown = 3600
10+
allow(Thread).to receive(:new).and_yield
11+
allow_any_instance_of(Net::HTTP).to receive(:request).and_return(Net::HTTPSuccess.new("1.1", "200", "OK"))
12+
end
13+
14+
after do
15+
SolidQueueWeb.alert_webhook_url = nil
16+
SolidQueueWeb.alert_stale_process_threshold = nil
17+
SolidQueueWeb.alert_webhook_cooldown = nil
18+
described_class.reset!
19+
end
20+
21+
def create_process(stale: false)
22+
heartbeat = stale ? (SolidQueue.process_alive_threshold + 1.minute).ago : 30.seconds.ago
23+
SolidQueue::Process.create!(
24+
kind: "Worker", pid: rand(10_000..99_999), hostname: "test-host",
25+
name: "worker-#{SecureRandom.hex(4)}", last_heartbeat_at: heartbeat
26+
)
27+
end
28+
29+
describe ".call" do
30+
it "fires when stale process count meets the threshold" do
31+
create_process(stale: true)
32+
expect_any_instance_of(Net::HTTP).to receive(:request)
33+
described_class.call
34+
end
35+
36+
it "fires when stale process count exceeds the threshold" do
37+
3.times { create_process(stale: true) }
38+
expect_any_instance_of(Net::HTTP).to receive(:request)
39+
described_class.call
40+
end
41+
42+
it "does not fire when stale process count is below the threshold" do
43+
SolidQueueWeb.alert_stale_process_threshold = 2
44+
create_process(stale: true)
45+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
46+
described_class.call
47+
end
48+
49+
it "does not count healthy processes as stale" do
50+
create_process(stale: false)
51+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
52+
described_class.call
53+
end
54+
55+
it "does not fire when alert_stale_process_threshold is not configured" do
56+
SolidQueueWeb.alert_stale_process_threshold = nil
57+
create_process(stale: true)
58+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
59+
described_class.call
60+
end
61+
62+
it "does not fire when webhook url is not configured" do
63+
SolidQueueWeb.alert_webhook_url = nil
64+
create_process(stale: true)
65+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
66+
described_class.call
67+
end
68+
69+
it "does not fire when no processes exist" do
70+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
71+
described_class.call
72+
end
73+
74+
it "does not fire again within the cooldown window" do
75+
create_process(stale: true)
76+
described_class.call
77+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
78+
described_class.call
79+
end
80+
81+
it "fires again after the cooldown window expires" do
82+
create_process(stale: true)
83+
described_class.call
84+
described_class.instance_variable_set(:@last_fired_at, 2.hours.ago)
85+
expect_any_instance_of(Net::HTTP).to receive(:request)
86+
described_class.call
87+
end
88+
89+
it "sends a JSON payload with the correct fields" do
90+
2.times { create_process(stale: true) }
91+
posted_body = nil
92+
allow_any_instance_of(Net::HTTP).to receive(:request) do |_, req|
93+
posted_body = JSON.parse(req.body)
94+
Net::HTTPSuccess.new("1.1", "200", "OK")
95+
end
96+
97+
described_class.call
98+
99+
expect(posted_body["event"]).to eq("stale_process_detected")
100+
expect(posted_body["stale_process_count"]).to eq(2)
101+
expect(posted_body["threshold"]).to eq(1)
102+
expect(posted_body["fired_at"]).to be_present
103+
end
104+
105+
it "sets Content-Type to application/json" do
106+
create_process(stale: true)
107+
sent_request = nil
108+
allow_any_instance_of(Net::HTTP).to receive(:request) do |_, req|
109+
sent_request = req
110+
Net::HTTPSuccess.new("1.1", "200", "OK")
111+
end
112+
113+
described_class.call
114+
115+
expect(sent_request["Content-Type"]).to eq("application/json")
116+
end
117+
118+
it "logs an error and does not raise when the HTTP request fails" do
119+
create_process(stale: true)
120+
allow_any_instance_of(Net::HTTP).to receive(:request).and_raise(RuntimeError, "connection refused")
121+
expect(Rails.logger).to receive(:error).with(/Stale process alert webhook failed/)
122+
expect { described_class.call }.not_to raise_error
123+
end
124+
125+
context "when alert_webhook_url is an array" do
126+
let(:second_url) { "http://example.com/second-webhook" }
127+
128+
before { SolidQueueWeb.alert_webhook_url = [webhook_url, second_url] }
129+
130+
it "posts to all configured URLs" do
131+
create_process(stale: true)
132+
expect(Net::HTTP).to receive(:new).twice.and_call_original
133+
described_class.call
134+
end
135+
end
136+
end
137+
end

0 commit comments

Comments
 (0)