Observability
See locks working: fan every lock event out to your metrics and logs with reflections, add per-lock debug metadata, and browse live locks in the Web UI.
Reflections#
Fan every lock event out to StatsD, your logger, or an error tracker.
A lock is invisible by default — it acquires, holds, and releases without telling you anything. Reflections are the observability seam: the gem emits a named event at every interesting moment, and you attach whatever you want to it. Metrics, structured logs, error-tracker breadcrumbs — all of it hangs off one registration block.
Register handlers with SidekiqUniqueJobs.reflect. It yields an on object; you call one method per event and pass a block that runs when that event fires. The block is required — registering an event without one raises NoBlockGiven.
# config/initializers/sidekiq_unique_jobs.rb
SidekiqUniqueJobs.reflect do |on|
on.locked do |job_hash|
StatsD.increment("uniquejobs.locked")
end
endEach handler receives the Sidekiq job hash (job_hash["class"], job_hash["args"], job_hash["jid"], and so on). A few events pass a second argument — execution_failed, for instance, also hands you the exception that was raised.
There are fourteen events in all: locked, lock_failed, unlocked, unlock_failed, timeout, duplicate, execution_failed, after_unlock_callback_failed, rescheduled, reschedule_failed, uniqueness_lapsed, unknown_sidekiq_worker, debug, and error. The full list with every signature lives in the Reflections reference.
A worked example#
Wire the four events you'll actually watch.
In practice you care about a handful of events: did the lock succeed, did it fail, did it release, and did the job blow up while holding it. Here's a complete initializer wiring those to StatsD counters and a logger.
# config/initializers/sidekiq_unique_jobs.rb
SidekiqUniqueJobs.reflect do |on|
on.locked do |job_hash|
StatsD.increment("uniquejobs.locked", tags: ["worker:#{job_hash["class"]}"])
end
on.lock_failed do |job_hash|
StatsD.increment("uniquejobs.lock_failed", tags: ["worker:#{job_hash["class"]}"])
Sidekiq.logger.warn("lock failed for #{job_hash["class"]} (#{job_hash["jid"]})")
end
on.unlocked do |job_hash|
StatsD.increment("uniquejobs.unlocked", tags: ["worker:#{job_hash["class"]}"])
end
on.execution_failed do |job_hash, exception|
StatsD.increment("uniquejobs.execution_failed", tags: ["worker:#{job_hash["class"]}"])
Sidekiq.logger.error("#{job_hash["class"]} raised #{exception.class}: #{exception.message}")
end
endYou don't have to register every event — only the ones you register run. Everything else stays a no-op, so there's no cost to watching just four.
A lock_failed counter climbing steadily is your signal that real duplicates are being caught. A single spike after a deploy usually means something is enqueuing the same job in a loop.
lock_info: debug metadata#
Store the why-and-when alongside each lock.
Reflections tell you what happened over time. When you need to inspect a single lock — why does this digest still exist, who took it, when — turn on lock_info for the worker.
class ChargeCustomerJob
include Sidekiq::Job
sidekiq_options lock: :until_executed, lock_info: true
def perform(customer_id)
# ...
end
endWith lock_info: true, the gem stores metadata about the lock in Redis — the worker, the queue, the arguments, the lock type, timeouts, and the timestamp it was taken. That metadata is what the Web UI renders when you click into a lock, and it's what makes an orphaned lock legible instead of an opaque digest.
It costs an extra write per lock, so it's off by default (config.lock_info is false). Turn it on per worker while you're debugging, or globally in your configuration block:
SidekiqUniqueJobs.configure do |config|
config.lock_info = true
endThe Web UI Locks tab#
Browse, filter, and delete live locks.
For an at-a-glance view of every lock currently held, mount the Web UI extension. Requiring it adds a Locks tab to the standard Sidekiq Web UI.
# config/routes.rb (or wherever you mount Sidekiq::Web)
require "sidekiq/web"
require "sidekiq_unique_jobs/web"
Rails.application.routes.draw do
mount Sidekiq::Web, at: "/sidekiq"
endThe Locks tab lists every active digest. You can filter to find a specific lock, click through to see its details (richest when lock_info is on), and delete a lock by hand — the escape hatch for a lock that's genuinely stuck and shouldn't wait for the reaper.
Reaching for the delete button often means a process crashed without cleaning up. That's exactly what the reaper handles automatically — see Orphaned locks and recovery.