Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Admin audit log — every discard, retry, pause, and resume action is recorded to a `solid_queue_web_audit_events` table; viewable at `/jobs/audit` with action/actor/queue filters and CSV export; identity captured via the optional `SolidQueueWeb.current_actor` config block; table created via `rails generate solid_queue_web:install:migrations`

## [1.4.0] - 2026-05-28

### Added
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ SolidQueueWeb surfaces all of this in a browser UI available at any route you ch
- **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
- **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
- **Job wait time** — the Running tab shows a "Wait Time" column with how long each job waited in the queue from enqueue to pickup; also exported as `wait_time_seconds` in the claimed-status CSV
- **Admin audit log** — every discard, retry, queue pause, and resume is recorded to a `solid_queue_web_audit_events` table and viewable at `/jobs/audit` with action/actor/queue filters and CSV export; actor identity captured via the optional `current_actor` config block; requires running the install generator to create the table
- **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
- **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
- **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
Expand Down Expand Up @@ -111,6 +112,7 @@ SolidQueueWeb.configure do |config|
config.alert_slow_job_count_threshold = 5 # fire when slow job count >= this (default: nil = disabled)
config.alert_stale_process_threshold = 1 # fire when stale process count >= this (default: nil = disabled)
config.alert_webhook_cooldown = 1800 # seconds between repeated alerts per alert type (default: 3600)
config.current_actor = -> { current_user&.email } # identity for audit log (default: nil)
config.connects_to = { reading: :reading, writing: :writing } # read replica (default: nil)
config.time_zone = "America/New_York" # display timezone for all timestamps (default: nil = UTC)
end
Expand Down Expand Up @@ -236,6 +238,46 @@ The same `alert_webhook_url` endpoint(s) receive the payload with a distinct eve

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

## Admin audit log

Every discard, retry, queue pause, and resume action is recorded to a `solid_queue_web_audit_events` table and viewable at `/jobs/audit`.

### Installation

The audit log requires an opt-in migration. Run the install generator to copy it to your application:

```bash
rails generate solid_queue_web:install:migrations
rails db:migrate
```

### Identity

Set `SolidQueueWeb.current_actor` to a block that returns the current user's identity as a string. The block is evaluated in controller context, so you have access to helpers like `current_user`:

```ruby
SolidQueueWeb.configure do |config|
config.current_actor = -> { current_user&.email }
end
```

If not configured, the actor column is left `nil`.

### Audited actions

| Action | Trigger |
|---|---|
| `job_discarded` | Single job discarded from the jobs list |
| `jobs_discarded` | Bulk or selection discard from the jobs list |
| `failed_job_retried` | Single failed job retried |
| `failed_jobs_retried` | Bulk or selection retry of failed jobs |
| `failed_job_discarded` | Single failed job discarded |
| `failed_jobs_discarded` | Bulk or selection discard of failed jobs |
| `queue_paused` | Queue paused |
| `queue_resumed` | Queue resumed |

The audit log page at `/jobs/audit` supports filtering by action, actor, and queue name. All records can be exported as CSV.

## Metrics endpoint

`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.
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Pull requests for any of these are welcome. See [Contributing](README.md#contrib

| Feature | Notes |
|---|---|
| **Admin audit log** | Record who retried, discarded, or paused what and when. Needs a `solid_queue_web_audit_events` table via an engine-provided migration (`rails solid_queue_web:install:migrations`). Identity comes from the `authenticate` block. CSV export included. |
| ~~**Admin audit log**~~ | ✅ Shipped in v1.5 — `solid_queue_web_audit_events` table via `rails generate solid_queue_web:install:migrations`; `/jobs/audit` page with action/actor/queue filters and CSV export; identity from the `current_actor` config block. |

---

Expand Down
20 changes: 20 additions & 0 deletions app/controllers/solid_queue_web/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,25 @@ def authenticate!
def request_basic_auth
request_http_basic_authentication("Solid Queue Dashboard")
end

def record_audit(action, job_class: nil, queue_name: nil, item_count: 1)
AuditEvent.create!(
action: action,
actor: resolve_current_actor,
job_class: job_class,
queue_name: queue_name,
item_count: item_count
)
rescue => e
Rails.logger.error("[SolidQueueWeb] Audit log failed: #{e.message}")
end

def resolve_current_actor
block = SolidQueueWeb.current_actor
instance_exec(&block) if block
rescue => e
Rails.logger.error("[SolidQueueWeb] current_actor block failed: #{e.message}")
nil
end
end
end
43 changes: 43 additions & 0 deletions app/controllers/solid_queue_web/audit_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module SolidQueueWeb
class AuditController < ApplicationController
before_action :set_filters

def index
scope = audit_scope
respond_to do |format|
format.html { @pagy, @audit_events = pagy(scope) }
format.csv do
send_data audit_csv(scope),
filename: "audit-log-#{Date.today}.csv",
type: "text/csv", disposition: "attachment"
end
end
end

private

def set_filters
@action_filter = params[:action_filter].presence_in(AuditEvent::ACTIONS)
@actor_filter = params[:actor].presence
@queue_filter = params[:queue].presence
end

def audit_scope
scope = AuditEvent.recent
scope = scope.where(action: @action_filter) if @action_filter
scope = scope.where(actor: @actor_filter) if @actor_filter
scope = scope.where(queue_name: @queue_filter) if @queue_filter
scope
end

def audit_csv(scope)
CSV.generate(headers: true) do |csv|
csv << %w[id action actor job_class queue_name item_count created_at]
scope.each do |event|
csv << [event.id, event.action, event.actor, event.job_class,
event.queue_name, event.item_count, event.created_at.iso8601]
end
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ def create
executions = SolidQueue::FailedExecution.where(id: ids)
jobs = executions.includes(:job).map(&:job)
SolidQueue::FailedExecution.retry_all(jobs)
record_audit("failed_jobs_retried", item_count: jobs.size)
redirect_to failed_jobs_path,
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} queued for retry."
rescue => e
Expand All @@ -17,6 +18,7 @@ def destroy
executions = SolidQueue::FailedExecution.where(id: ids)
jobs = executions.includes(:job).map(&:job)
SolidQueue::FailedExecution.discard_all_from_jobs(jobs)
record_audit("failed_jobs_discarded", item_count: jobs.size)
redirect_to failed_jobs_path,
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue => e
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/solid_queue_web/failed_jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ def failed_jobs_csv

def perform_discard(executions)
jobs = executions.map(&:job)
action = params[:id] ? "failed_job_discarded" : "failed_jobs_discarded"
SolidQueue::FailedExecution.discard_all_from_jobs(jobs)
record_audit(action, job_class: jobs.first&.class_name, queue_name: jobs.first&.queue_name, item_count: jobs.size)
redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def destroy
ids = Array(params[:ids]).map(&:to_i).reject(&:zero?)
jobs = model.where(id: ids).includes(:job).map(&:job)
model.discard_all_from_jobs(jobs)
record_audit("jobs_discarded", item_count: jobs.size)
redirect_to jobs_path(status: status, period: period),
notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
rescue ArgumentError => e
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/solid_queue_web/jobs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def destroy
model = Job.execution_model_for!(@status)
if params[:id]
@execution = model.find(params[:id])
discarded_job = @execution.job
@execution.discard
record_audit("job_discarded", job_class: discarded_job&.class_name, queue_name: discarded_job&.queue_name)
@remaining_count = filtered_scope(model).count
respond_to do |format|
format.turbo_stream
Expand All @@ -39,6 +41,7 @@ def destroy
else
jobs = filtered_scope(model).map(&:job)
model.discard_all_from_jobs(jobs)
record_audit("jobs_discarded", item_count: jobs.size)
redirect_to jobs_return_path, notice: "#{jobs.size} #{"job".pluralize(jobs.size)} discarded."
end
rescue ArgumentError => e
Expand Down
2 changes: 2 additions & 0 deletions app/controllers/solid_queue_web/queues/pauses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class PausesController < ApplicationController
def create
queue = SolidQueue::Queue.find_by_name(params[:queue_name])
queue.pause
record_audit("queue_paused", queue_name: queue.name)
redirect_to queues_path, notice: "Queue \"#{queue.name}\" paused."
rescue => e
redirect_to queues_path, alert: "Could not pause queue: #{e.message}"
Expand All @@ -12,6 +13,7 @@ def create
def destroy
queue = SolidQueue::Queue.find_by_name(params[:queue_name])
queue.resume
record_audit("queue_resumed", queue_name: queue.name)
redirect_to queues_path, notice: "Queue \"#{queue.name}\" resumed."
rescue => e
redirect_to queues_path, alert: "Could not resume queue: #{e.message}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ def create
else
SolidQueue::FailedExecution.retry_all(jobs)
end
action = params[:id] ? "failed_job_retried" : "failed_jobs_retried"
record_audit(action, job_class: jobs.first&.class_name, queue_name: jobs.first&.queue_name, item_count: jobs.size)
redirect_to failed_jobs_path(queue: @queue, q: @search, period: @period),
notice: retry_notice(jobs.size)
rescue ArgumentError => e
Expand Down
17 changes: 17 additions & 0 deletions app/models/solid_queue_web/audit_event.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module SolidQueueWeb
class AuditEvent < ApplicationRecord
self.table_name = "solid_queue_web_audit_events"

ACTIONS = %w[
job_discarded jobs_discarded
failed_job_retried failed_jobs_retried
failed_job_discarded failed_jobs_discarded
queue_paused queue_resumed
].freeze

validates :action, presence: true, inclusion: { in: ACTIONS }
validates :item_count, numericality: { greater_than: 0 }

scope :recent, -> { order(created_at: :desc) }
end
end
1 change: 1 addition & 0 deletions app/views/layouts/solid_queue_web/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<li><%= link_to "Recurring", recurring_tasks_path, class: current_page?(recurring_tasks_path) ? "active" : "", aria: { current: current_page?(recurring_tasks_path) ? "page" : nil } %></li>
<li><%= link_to "Processes", processes_path, class: current_page?(processes_path) ? "active" : "", aria: { current: current_page?(processes_path) ? "page" : nil } %></li>
<li><%= link_to "Search", search_path, class: current_page?(search_path) ? "active" : "", aria: { current: current_page?(search_path) ? "page" : nil } %></li>
<li><%= link_to "Audit", audit_path, class: current_page?(audit_path) ? "active" : "", aria: { current: current_page?(audit_path) ? "page" : nil } %></li>
</ul>
</nav>
</div>
Expand Down
78 changes: 78 additions & 0 deletions app/views/solid_queue_web/audit/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<h1 class="sqd-page-title">Audit Log</h1>

<div class="sqd-page-header">
<div class="sqd-filters">
<form action="<%= audit_path %>" method="get" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<select name="action_filter" class="sqd-select" aria-label="Filter by action" onchange="this.form.submit()">
<option value="">All actions</option>
<% SolidQueueWeb::AuditEvent::ACTIONS.each do |a| %>
<option value="<%= a %>" <%= @action_filter == a ? "selected" : "" %>><%= a.tr("_", " ") %></option>
<% end %>
</select>
<% if @actor_filter.present? %>
<span class="sqd-badge sqd-badge--muted">Actor: <%= @actor_filter %></span>
<%= link_to "×", audit_path(action_filter: @action_filter, queue: @queue_filter), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% end %>
<% if @queue_filter.present? %>
<span class="sqd-badge sqd-badge--muted">Queue: <%= @queue_filter %></span>
<%= link_to "×", audit_path(action_filter: @action_filter, actor: @actor_filter), class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% end %>
<% if @action_filter.present? || @actor_filter.present? || @queue_filter.present? %>
<%= link_to "Clear", audit_path, class: "sqd-btn sqd-btn--muted sqd-btn--sm" %>
<% end %>
</form>
</div>
<% if @audit_events.any? %>
<div class="sqd-actions">
<%= link_to "Export CSV", audit_path(format: :csv, action_filter: @action_filter, actor: @actor_filter, queue: @queue_filter),
class: "sqd-btn sqd-btn--muted", data: { turbo: false } %>
</div>
<% end %>
</div>

<div class="sqd-card">
<% if @audit_events.empty? %>
<div class="sqd-empty">No audit events recorded.</div>
<% else %>
<table>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Action</th>
<th scope="col">Actor</th>
<th scope="col">Job Class</th>
<th scope="col">Queue</th>
<th scope="col">Count</th>
</tr>
</thead>
<tbody>
<% @audit_events.each do |event| %>
<tr>
<td class="sqd-mono"><%= format_timestamp(event.created_at) %></td>
<td><span class="sqd-badge sqd-badge--<%= event.action.include?("discard") ? "failed" : event.action.include?("paused") || event.action.include?("resumed") ? "paused" : "ready" %>"><%= event.action.tr("_", " ") %></span></td>
<td class="sqd-mono sqd-muted-text">
<% if event.actor.present? %>
<%= link_to event.actor, audit_path(action_filter: @action_filter, queue: @queue_filter, actor: event.actor), style: "color: inherit;" %>
<% else %>
<span style="color: var(--muted)">—</span>
<% end %>
</td>
<td class="sqd-mono"><%= event.job_class || "—" %></td>
<td class="sqd-mono">
<% if event.queue_name.present? %>
<%= link_to event.queue_name, audit_path(action_filter: @action_filter, actor: @actor_filter, queue: event.queue_name), style: "color: inherit;" %>
<% else %>
<span style="color: var(--muted)">—</span>
<% end %>
</td>
<td><%= event.item_count %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</div>

<% if @pagy.last > 1 %>
<%= @pagy.series_nav.html_safe %>
<% end %>
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
resource :blocked_jobs, only: [:destroy]

get "metrics", to: "metrics#index", as: :metrics, defaults: { format: :json }
get "audit", to: "audit#index", as: :audit
get "search", to: "search#index", as: :search
get "history", to: "history#index", as: :history
get "performance", to: "performance#index", as: :performance
Expand Down
16 changes: 16 additions & 0 deletions db/migrate/01_create_solid_queue_web_audit_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CreateSolidQueueWebAuditEvents < ActiveRecord::Migration[7.1]
def change
create_table :solid_queue_web_audit_events do |t|
t.string :action, null: false
t.string :actor
t.string :job_class
t.string :queue_name
t.integer :item_count, null: false, default: 1
t.datetime :created_at, null: false
end

add_index :solid_queue_web_audit_events, :created_at
add_index :solid_queue_web_audit_events, :action
add_index :solid_queue_web_audit_events, :actor
end
end
24 changes: 24 additions & 0 deletions lib/generators/solid_queue_web/install/migrations_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
require "rails/generators"
require "rails/generators/active_record"

module SolidQueueWeb
module Install
class MigrationsGenerator < Rails::Generators::Base
include Rails::Generators::Migration

source_root File.expand_path("templates", __dir__)
desc "Copy SolidQueueWeb migrations to your application."

def self.next_migration_number(path)
ActiveRecord::Generators::Base.next_migration_number(path)
end

def create_migration_file
migration_template(
"create_solid_queue_web_audit_events.rb.tt",
"db/migrate/create_solid_queue_web_audit_events.rb"
)
end
end
end
end
Loading