Skip to content

Commit eec379f

Browse files
feat: slow job webhook alert (#73)
* feat: slow job webhook alert Add SlowJobAlert service and alert_slow_job_count_threshold config option. Fires a webhook (event: slow_job_threshold_exceeded) when the number of currently-running slow claimed jobs meets or exceeds the configured count. Requires slow_job_threshold to be set (defines what "slow" means). 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 and README for slow job 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 2160be3 commit eec379f

6 files changed

Lines changed: 270 additions & 4 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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+
1014
## [1.3.0] - 2026-05-27
1115

1216
### Added

README.md

Lines changed: 30 additions & 3 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; fires asynchronously so dashboard performance is unaffected; a configurable cooldown (default 1 h) prevents repeated alerts while the count stays elevated
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
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
@@ -106,8 +106,9 @@ SolidQueueWeb.configure do |config|
106106
config.slow_job_threshold = 5.minutes # flag claimed jobs running longer than this (default: nil = disabled)
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)
109-
config.alert_queue_thresholds = { "critical" => 50, "default" => 200 } # fire when queue depth >= threshold (default: {})
110-
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
109+
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)
111+
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
111112
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
112113
config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC)
113114
end
@@ -182,6 +183,32 @@ The same `alert_webhook_url` endpoint(s) receive the payload, with a distinct ev
182183

183184
Cooldown is tracked independently per queue, so a persistently deep "critical" queue does not suppress alerts for "default". The shared `alert_webhook_cooldown` setting applies to each queue separately.
184185

186+
## Slow job alerts
187+
188+
Set `alert_slow_job_count_threshold` to fire a webhook when the number of currently-running slow jobs meets or exceeds a count. This requires `slow_job_threshold` to also be configured — it defines what "slow" means.
189+
190+
```ruby
191+
SolidQueueWeb.configure do |config|
192+
config.slow_job_threshold = 5.minutes # a job is "slow" if it has been claimed longer than this
193+
config.alert_slow_job_count_threshold = 3 # fire when >= 3 jobs are slow
194+
config.alert_webhook_url = "https://hooks.example.com/solid-queue"
195+
config.alert_webhook_cooldown = 1800 # don't re-fire for 30 minutes (default: 3600)
196+
end
197+
```
198+
199+
The same `alert_webhook_url` endpoint(s) receive the payload with a distinct event type:
200+
201+
```json
202+
{
203+
"event": "slow_job_threshold_exceeded",
204+
"slow_job_count": 5,
205+
"threshold": 3,
206+
"fired_at": "2026-05-28T08:00:00Z"
207+
}
208+
```
209+
210+
The alert fires on every dashboard page load while the condition persists, subject to the cooldown window.
211+
185212
## Metrics endpoint
186213

187214
`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.

app/controllers/solid_queue_web/dashboard_controller.rb

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

lib/solid_queue_web.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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, :connects_to, :time_zone
9+
:alert_queue_thresholds, :alert_slow_job_count_threshold, :connects_to, :time_zone
1010

1111
def page_size
1212
@page_size || 25
@@ -44,6 +44,10 @@ def alert_queue_thresholds
4444
@alert_queue_thresholds || {}
4545
end
4646

47+
def alert_slow_job_count_threshold
48+
@alert_slow_job_count_threshold
49+
end
50+
4751
def connects_to
4852
@connects_to
4953
end
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
require "rails_helper"
2+
3+
RSpec.describe SolidQueueWeb::SlowJobAlert do
4+
let(:webhook_url) { "http://example.com/webhook" }
5+
6+
before do
7+
SolidQueueWeb.alert_webhook_url = webhook_url
8+
SolidQueueWeb.slow_job_threshold = 5.minutes
9+
SolidQueueWeb.alert_slow_job_count_threshold = 3
10+
SolidQueueWeb.alert_webhook_cooldown = 3600
11+
allow(Thread).to receive(:new).and_yield
12+
allow_any_instance_of(Net::HTTP).to receive(:request).and_return(Net::HTTPSuccess.new("1.1", "200", "OK"))
13+
end
14+
15+
after do
16+
SolidQueueWeb.alert_webhook_url = nil
17+
SolidQueueWeb.slow_job_threshold = nil
18+
SolidQueueWeb.alert_slow_job_count_threshold = nil
19+
SolidQueueWeb.alert_webhook_cooldown = nil
20+
described_class.reset!
21+
end
22+
23+
let(:worker_process) do
24+
SolidQueue::Process.create!(
25+
kind: "Worker", pid: 99_999, hostname: "test-host",
26+
name: "worker-slow-alert-test", last_heartbeat_at: Time.current
27+
)
28+
end
29+
30+
def create_slow_claimed_job(count: 1)
31+
count.times do |i|
32+
job = SolidQueue::Job.create!(
33+
queue_name: "default",
34+
class_name: "SlowTestJob",
35+
arguments: {},
36+
active_job_id: SecureRandom.uuid
37+
)
38+
execution = SolidQueue::ClaimedExecution.create!(job: job, process: worker_process)
39+
execution.update_columns(created_at: 10.minutes.ago)
40+
end
41+
end
42+
43+
describe ".call" do
44+
it "fires when slow job count meets the threshold" do
45+
create_slow_claimed_job(count: 3)
46+
expect_any_instance_of(Net::HTTP).to receive(:request)
47+
described_class.call
48+
end
49+
50+
it "fires when slow job count exceeds the threshold" do
51+
create_slow_claimed_job(count: 5)
52+
expect_any_instance_of(Net::HTTP).to receive(:request)
53+
described_class.call
54+
end
55+
56+
it "does not fire when slow job count is below the threshold" do
57+
create_slow_claimed_job(count: 2)
58+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
59+
described_class.call
60+
end
61+
62+
it "does not count jobs that are not yet slow" do
63+
job = SolidQueue::Job.create!(
64+
queue_name: "default",
65+
class_name: "FastTestJob",
66+
arguments: {},
67+
active_job_id: SecureRandom.uuid
68+
)
69+
execution = SolidQueue::ClaimedExecution.create!(job: job, process: worker_process)
70+
execution.update_columns(created_at: 1.minute.ago)
71+
72+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
73+
described_class.call
74+
end
75+
76+
it "does not fire when slow_job_threshold is not configured" do
77+
SolidQueueWeb.slow_job_threshold = nil
78+
create_slow_claimed_job(count: 5)
79+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
80+
described_class.call
81+
end
82+
83+
it "does not fire when alert_slow_job_count_threshold is not configured" do
84+
SolidQueueWeb.alert_slow_job_count_threshold = nil
85+
create_slow_claimed_job(count: 5)
86+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
87+
described_class.call
88+
end
89+
90+
it "does not fire when webhook url is not configured" do
91+
SolidQueueWeb.alert_webhook_url = nil
92+
create_slow_claimed_job(count: 5)
93+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
94+
described_class.call
95+
end
96+
97+
it "does not fire again within the cooldown window" do
98+
create_slow_claimed_job(count: 3)
99+
described_class.call
100+
expect_any_instance_of(Net::HTTP).not_to receive(:request)
101+
described_class.call
102+
end
103+
104+
it "fires again after the cooldown window expires" do
105+
create_slow_claimed_job(count: 3)
106+
described_class.call
107+
described_class.instance_variable_set(:@last_fired_at, 2.hours.ago)
108+
expect_any_instance_of(Net::HTTP).to receive(:request)
109+
described_class.call
110+
end
111+
112+
it "sends a JSON payload with the correct fields" do
113+
create_slow_claimed_job(count: 4)
114+
posted_body = nil
115+
allow_any_instance_of(Net::HTTP).to receive(:request) do |_, req|
116+
posted_body = JSON.parse(req.body)
117+
Net::HTTPSuccess.new("1.1", "200", "OK")
118+
end
119+
120+
described_class.call
121+
122+
expect(posted_body["event"]).to eq("slow_job_threshold_exceeded")
123+
expect(posted_body["slow_job_count"]).to eq(4)
124+
expect(posted_body["threshold"]).to eq(3)
125+
expect(posted_body["fired_at"]).to be_present
126+
end
127+
128+
it "sets Content-Type to application/json" do
129+
create_slow_claimed_job(count: 3)
130+
sent_request = nil
131+
allow_any_instance_of(Net::HTTP).to receive(:request) do |_, req|
132+
sent_request = req
133+
Net::HTTPSuccess.new("1.1", "200", "OK")
134+
end
135+
136+
described_class.call
137+
138+
expect(sent_request["Content-Type"]).to eq("application/json")
139+
end
140+
141+
it "logs an error and does not raise when the HTTP request fails" do
142+
create_slow_claimed_job(count: 3)
143+
allow_any_instance_of(Net::HTTP).to receive(:request).and_raise(RuntimeError, "connection refused")
144+
expect(Rails.logger).to receive(:error).with(/Slow job alert webhook failed/)
145+
expect { described_class.call }.not_to raise_error
146+
end
147+
148+
context "when alert_webhook_url is an array" do
149+
let(:second_url) { "http://example.com/second-webhook" }
150+
151+
before { SolidQueueWeb.alert_webhook_url = [webhook_url, second_url] }
152+
153+
it "posts to all configured URLs" do
154+
create_slow_claimed_job(count: 3)
155+
expect(Net::HTTP).to receive(:new).twice.and_call_original
156+
described_class.call
157+
end
158+
end
159+
end
160+
end

0 commit comments

Comments
 (0)