Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- P99 + standard deviation columns in performance stats — `p99` and `Std Dev` columns added to the stats table; both are sortable; high std dev flags inconsistent jobs worth investigating
- Failed job trend chart — "Failures — last 12 hours" sparkline added to the Solid Queue dashboard card below the throughput sparkline; bars render in the danger color to make failure spikes immediately visible
- Error frequency report — `GET /failed_jobs/errors` groups all failed jobs by exception class and message prefix, showing count and an expandable sample backtrace per group; links through to a filtered failed jobs list via `?error_class=`; the failed jobs index gains an "Error Summary" button and shows an active-filter breadcrumb with a clear link

Expand Down
15 changes: 12 additions & 3 deletions app/controllers/solid_stack_web/stats_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module SolidStackWeb
class StatsController < ApplicationController
SORTABLE_COLUMNS = %w[class_name count avg p50 p95 min max].freeze
SORTABLE_COLUMNS = %w[class_name count avg p50 p95 p99 stddev min max].freeze

def index
@sort = params[:sort].presence_in(SORTABLE_COLUMNS) || "p95"
Expand All @@ -18,14 +18,17 @@ def build_stats(jobs)
jobs.group_by(&:class_name).map do |class_name, group|
durations = group.map { |j| (j.finished_at - j.created_at).to_f }.sort
count = durations.size
avg = durations.sum / count
{
class_name: class_name,
count: count,
avg: durations.sum / count,
avg: avg,
min: durations.first,
max: durations.last,
p50: percentile(durations, 50),
p95: percentile(durations, 95)
p95: percentile(durations, 95),
p99: percentile(durations, 99),
stddev: stddev(durations, avg)
}
end
end
Expand All @@ -35,5 +38,11 @@ def percentile(sorted, pct)
k = (sorted.size - 1) * pct / 100.0
sorted[k.floor] + (sorted[k.ceil] - sorted[k.floor]) * (k - k.floor)
end

def stddev(durations, avg)
return 0.0 if durations.size < 2
variance = durations.sum { |d| (d - avg)**2 } / durations.size
Math.sqrt(variance)
end
end
end
4 changes: 4 additions & 0 deletions app/views/solid_stack_web/stats/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
["avg", "Avg"],
["p50", "p50"],
["p95", "p95"],
["p99", "p99"],
["stddev", "Std Dev"],
["min", "Min"],
["max", "Max"]
].each do |col, label| %>
Expand All @@ -35,6 +37,8 @@
<td class="sqw-muted"><%= format_duration(row[:avg]) %></td>
<td class="sqw-muted"><%= format_duration(row[:p50]) %></td>
<td><strong><%= format_duration(row[:p95]) %></strong></td>
<td class="sqw-muted"><%= format_duration(row[:p99]) %></td>
<td class="sqw-muted"><%= format_duration(row[:stddev]) %></td>
<td class="sqw-muted"><%= format_duration(row[:min]) %></td>
<td class="sqw-muted"><%= format_duration(row[:max]) %></td>
</tr>
Expand Down
19 changes: 19 additions & 0 deletions spec/requests/solid_stack_web/stats_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,24 @@ def create_finished(class_name: "MyJob", duration: 10)
get "#{engine_root}/stats"
expect(response.body).to match(/\d+(\.\d+)?s|ms/)
end

it "renders p99 and Std Dev column headers" do
create_finished(class_name: "MyJob")
get "#{engine_root}/stats"
expect(response.body).to include("p99")
expect(response.body).to include("Std Dev")
end

it "accepts sort by p99" do
create_finished(class_name: "MyJob")
get "#{engine_root}/stats", params: { sort: "p99", direction: "desc" }
expect(response).to have_http_status(:ok)
end

it "accepts sort by stddev" do
create_finished(class_name: "MyJob")
get "#{engine_root}/stats", params: { sort: "stddev", direction: "desc" }
expect(response).to have_http_status(:ok)
end
end
end