An Error groups tab for the Sidekiq Web UI: retrying and dead jobs grouped by error class, by job class, or by the pair of them — with retry, kill and delete scoped to one group.
Sidekiq's Retries and Dead pages are flat lists sorted by time. With 20 000 failed jobs they cannot answer the only question that matters — what broke? — and the bulk actions are all-or-nothing.
And grouping by error class alone is not enough either. Net::OpenTimeout in
FetchInventoryJob is a flaky warehouse API; the same Net::OpenTimeout in
DeliverWebhookJob is a flaky customer endpoint. Two problems, two decisions. Pairing the
error class with the job class keeps them apart, so you can retry one and leave the other
alone. That is the default grouping; the other two are one click away.
Nothing is stored and nothing is instrumented: the tab reads the very same retry and dead sets Sidekiq already keeps, so it works on failures that happened before you installed it.
# Gemfile
gem "sidekiq-error-groups"Require the Web extension where the Web UI is mounted — config/routes.rb in Rails, or
your rackup file. Not in an initializer: Sidekiq worker processes should not load the
Web UI.
# config/routes.rb
require "sidekiq/web"
require "sidekiq/error_groups/web"
Rails.application.routes.draw do
mount Sidekiq::Web => "/sidekiq"
endThat is the whole setup. A new "Error groups" tab appears next to Retries and Dead.
Index — one row per group, with a Group by: error class · job · error class + job
switch. Each row carries the totals per set, when the group first and last failed, and the
message of its most recent failure. When the grouping leaves something out, it comes back
as a breakdown column: job classes when grouping by error, error classes when grouping by
job.
Group page — the numbers for that group, the message and backtrace of the most recent failure, the three bulk actions, and a paginated list of the jobs linking back to their regular Sidekiq pages.
| Action | Effect |
|---|---|
| Retry all in group | pushes every job of this group back to its queue (retries and dead) |
| Kill all in group | moves this group's retrying jobs to the dead set |
| Delete all in group | deletes every job of this group (retries and dead) |
Each action asks for confirmation, stays inside the group it was fired from, and reports how many jobs it touched.
summary = Sidekiq::ErrorGroups.summary # grouped by error class + job class
summary.total # => 72 failed jobs in the scanned sets
summary.group_by # => :error_job
summary.truncated? # => false (true when max_scan stopped the scan early)
summary.groups.each do |group|
group.error_class # => "Net::OpenTimeout"
group.job_class # => "FetchInventoryJob"
group.count # => 8
group.retry_count # => 8
group.dead_count # => 0
group.first_failed_at
group.last_failed_at
group.sample # => the group's most recent failed job
endOther groupings, and the breakdown of whatever is not in the key:
Sidekiq::ErrorGroups.summary(group_by: :error) # one row per error class
Sidekiq::ErrorGroups.summary(group_by: :job) # one row per job class
Sidekiq::ErrorGroups.summary(sets: [:dead]) # dead jobs only
group = Sidekiq::ErrorGroups.groups(group_by: :job).first
group.error_classes # => {"Net::OpenTimeout" => 5, "Net::ReadTimeout" => 5}
group.breakdown # => [["Net::OpenTimeout", 5], ["Net::ReadTimeout", 5]]One group's jobs, paginated (page.group carries the whole-group numbers, not just the
page's):
page = Sidekiq::ErrorGroups.jobs("Net::OpenTimeout", job_class: "FetchInventoryJob", page: 1)
page.jobs.first.jidBulk actions, scoped to whichever half of the key you pass:
Sidekiq::ErrorGroups.retry_all("Net::OpenTimeout", job_class: "FetchInventoryJob")
Sidekiq::ErrorGroups.delete_all("ActiveRecord::RecordNotFound", sets: [:dead])
Sidekiq::ErrorGroups.kill_all(job_class: "WarmCacheJob", limit: 100)An action with neither half raises ArgumentError rather than quietly touching every
failed job. Jobs carrying no error_class are grouped under
Sidekiq::ErrorGroups::Selector::UNKNOWN and displayed as "(no error class)".
bundle exec rake sidekiq:error_groups # GROUP_BY=error_job SETS=retry,dead LIMIT=20 MAX_SCAN=10000$ sidekiq-error-groups --group-by job
Failed jobs grouped by job class — 72 job(s) (retry: 62, dead: 10), 7 group(s)
JOB TOTAL RETRY DEAD LAST FAILURE ERRORS
----------------- ----- ----- ---- ----------------------- ----------------------------------------------------------
ChargeCardJob 30 30 0 2026-10-06 08:48:05 UTC PaymentGateway::DeclinedError (26), …RateLimitedError (4)
DeliverWebhookJob 10 10 0 2026-10-06 08:46:12 UTC Net::OpenTimeout (5), Net::ReadTimeout (5)
SyncUserJob 9 9 0 2026-10-06 08:30:41 UTC ActiveRecord::RecordNotFound (9)
sidekiq-error-groups # the default table
sidekiq-error-groups --group-by error # or job, or error_job
sidekiq-error-groups --json | jq . # machine readable
sidekiq-error-groups --error "Net::OpenTimeout" # the jobs of one error class
sidekiq-error-groups --job FetchInventoryJob # the jobs of one worker
sidekiq-error-groups --error "Net::OpenTimeout" --job FetchInventoryJob
sidekiq-error-groups -r ./config/environment # load the app first
sidekiq-error-groups --redis-url redis://localhost:6379/1The CLI is read-only on purpose: bulk retry/delete/kill live in the Ruby API and in the Web UI, where there is a confirmation step.
Everything is optional.
Sidekiq::ErrorGroups.configure do |config|
config.group_by = :error_job # :error, :job or :error_job (default)
config.sets = %i[retry dead] # which sets to scan, default both
config.max_scan = 10_000 # max jobs read per set, the safety valve for huge sets
config.action_batch_size = 1_000
config.top_breakdown = 3 # entries listed in the breakdown column
config.per_page = 25 # jobs per page on the group page
config.backtrace_lines = 10
config.cache_ttl = 0 # seconds to keep a summary in process memory, 0 = off
endGrouping means reading the sets, so the work is linear in their size. Iteration goes
through Sidekiq::JobSet#each, which paginates the sorted set inside Redis, and stops
after max_scan jobs per set. When that limit kicks in, the UI, the CLI and
Summary#truncated? all say so — the numbers are then a lower bound. Raise max_scan for
exact numbers on large installations, or set cache_ttl to avoid rescanning on every page
load.
Bulk actions deliberately ignore max_scan: a "retry all in group" must not silently skip
jobs. They work in batches of action_batch_size, re-scanning the set between batches,
because mutating a sorted set while iterating it is not safe.
Ruby 2.7+ and Sidekiq 6.0 through 8.x. The Web UI extension API changed three times in that range; the gem shims all of it and CI runs the full matrix.
| Sidekiq | Registration used | Views |
|---|---|---|
| 8.x | Sidekiq::Web.configure { |c| c.register(...) } |
web/views (current UI markup) |
| 7.3–7.x | Sidekiq::Web.register(ext, name:, tab:, index:) |
web/views/legacy |
| 6.0–7.2 | Sidekiq::Web.register(ext) + Sidekiq::Web.tabs[...] |
web/views/legacy |
Only Sidekiq APIs present in every supported version are used (Sidekiq::RetrySet,
Sidekiq::DeadSet, JobSet#each, SortedEntry#item/score/retry/delete/kill), and Redis is
never addressed directly — which keeps both redis-rb (Sidekiq 6) and redis-client
(Sidekiq 7+) happy. Groups are addressed with error_class/job_class query parameters
rather than path segments, which every Sidekiq router version handles unchanged.
Locales: English and Russian.
bin/setup
bundle exec rake # tests + rubocopThe core is tested against in-memory fakes and needs nothing running. The Web UI tests
drive a real Sidekiq::Web through rack-test and need Redis; they skip themselves when
REDIS_URL (default redis://localhost:6379/15) is unreachable.
The whole version matrix:
for f in gemfiles/*.gemfile; do BUNDLE_GEMFILE=$f bundle install && BUNDLE_GEMFILE=$f bundle exec rake test; doneLooking at the UI by hand:
bundle exec ruby test/dummy/seed.rb # create failed jobs
bundle exec rackup test/dummy/config.ru -p 9292 # current UI
BUNDLE_GEMFILE=gemfiles/sidekiq_6.5.gemfile bundle exec rackup test/dummy/config.ru -p 9293 # legacy UIBug reports and pull requests are welcome — see CONTRIBUTING.md.


