From 367d5f7af9c5168b6a7f828e2c6f6fadd1a432b2 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Wed, 27 May 2026 09:27:24 -0400 Subject: [PATCH] feat: add p99 and std dev columns to performance stats (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the stats table with a 99th-percentile and population standard deviation column; both are sortable. High std dev flags inconsistent jobs worth investigating — completes v1.1 Error Intelligence milestone. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + .../solid_stack_web/stats_controller.rb | 15 ++++++++++++--- .../solid_stack_web/stats/index.html.erb | 4 ++++ spec/requests/solid_stack_web/stats_spec.rb | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d290d2..50a4bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/controllers/solid_stack_web/stats_controller.rb b/app/controllers/solid_stack_web/stats_controller.rb index 4aabd68..9b41c66 100644 --- a/app/controllers/solid_stack_web/stats_controller.rb +++ b/app/controllers/solid_stack_web/stats_controller.rb @@ -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" @@ -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 @@ -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 diff --git a/app/views/solid_stack_web/stats/index.html.erb b/app/views/solid_stack_web/stats/index.html.erb index caaf82c..1afc0b9 100644 --- a/app/views/solid_stack_web/stats/index.html.erb +++ b/app/views/solid_stack_web/stats/index.html.erb @@ -12,6 +12,8 @@ ["avg", "Avg"], ["p50", "p50"], ["p95", "p95"], + ["p99", "p99"], + ["stddev", "Std Dev"], ["min", "Min"], ["max", "Max"] ].each do |col, label| %> @@ -35,6 +37,8 @@ <%= format_duration(row[:avg]) %> <%= format_duration(row[:p50]) %> <%= format_duration(row[:p95]) %> + <%= format_duration(row[:p99]) %> + <%= format_duration(row[:stddev]) %> <%= format_duration(row[:min]) %> <%= format_duration(row[:max]) %> diff --git a/spec/requests/solid_stack_web/stats_spec.rb b/spec/requests/solid_stack_web/stats_spec.rb index b9b44a5..96b0d1e 100644 --- a/spec/requests/solid_stack_web/stats_spec.rb +++ b/spec/requests/solid_stack_web/stats_spec.rb @@ -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