rails_error_dashboard 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +30 -1
- data/app/controllers/rails_error_dashboard/errors_controller.rb +51 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +18 -7
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +450 -0
- data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +152 -0
- data/config/routes.rb +2 -0
- data/lib/rails_error_dashboard/queries/database_health_summary.rb +82 -0
- data/lib/rails_error_dashboard/queries/job_health_summary.rb +101 -0
- data/lib/rails_error_dashboard/services/database_health_inspector.rb +168 -0
- data/lib/rails_error_dashboard/services/rspec_generator.rb +145 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +4 -0
- metadata +8 -2
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<% content_for :page_title, "Job Health" %>
|
|
2
|
+
|
|
3
|
+
<div class="container-fluid py-4">
|
|
4
|
+
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
5
|
+
<h1 class="h3 mb-0">
|
|
6
|
+
<i class="bi bi-cpu me-2"></i>
|
|
7
|
+
Job Health
|
|
8
|
+
</h1>
|
|
9
|
+
|
|
10
|
+
<div class="btn-group" role="group">
|
|
11
|
+
<%= link_to job_health_summary_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
|
|
12
|
+
7 Days
|
|
13
|
+
<% end %>
|
|
14
|
+
<%= link_to job_health_summary_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
|
|
15
|
+
30 Days
|
|
16
|
+
<% end %>
|
|
17
|
+
<%= link_to job_health_summary_errors_path(days: 90), class: "btn btn-sm #{@days == 90 ? 'btn-primary' : 'btn-outline-primary'}" do %>
|
|
18
|
+
90 Days
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<% if @errors_with_jobs == 0 %>
|
|
24
|
+
<div class="text-center py-5">
|
|
25
|
+
<i class="bi bi-check-circle display-1 text-success mb-3"></i>
|
|
26
|
+
<h4 class="text-muted">No Job Queue Data Found</h4>
|
|
27
|
+
<p class="text-muted">
|
|
28
|
+
No job queue stats were detected in system health snapshots over the last <%= @days %> days.
|
|
29
|
+
</p>
|
|
30
|
+
<div class="card mx-auto" style="max-width: 500px;">
|
|
31
|
+
<div class="card-body text-start">
|
|
32
|
+
<h6>How job health tracking works:</h6>
|
|
33
|
+
<ul class="mb-0">
|
|
34
|
+
<li>System health must be enabled (<code>enable_system_health = true</code>)</li>
|
|
35
|
+
<li>Job queue stats are captured automatically when Sidekiq, SolidQueue, or GoodJob is detected</li>
|
|
36
|
+
<li>Stats are collected per-error at the time of capture</li>
|
|
37
|
+
<li>This page shows job queue health per-error, sorted by failed count</li>
|
|
38
|
+
</ul>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<% else %>
|
|
43
|
+
<div class="row mb-4">
|
|
44
|
+
<div class="col-md-4">
|
|
45
|
+
<div class="card text-center">
|
|
46
|
+
<div class="card-body">
|
|
47
|
+
<div class="display-6 text-info"><%= @errors_with_jobs %></div>
|
|
48
|
+
<small class="text-muted">Errors with Job Data</small>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="col-md-4">
|
|
53
|
+
<div class="card text-center">
|
|
54
|
+
<div class="card-body">
|
|
55
|
+
<% failed_color = @total_failed > 0 ? "danger" : "success" %>
|
|
56
|
+
<div class="display-6 text-<%= failed_color %>"><%= @total_failed %></div>
|
|
57
|
+
<small class="text-muted">Total Failed Jobs</small>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="col-md-4">
|
|
62
|
+
<div class="card text-center">
|
|
63
|
+
<div class="card-body">
|
|
64
|
+
<div class="display-6 text-secondary"><%= @adapters_detected.size %></div>
|
|
65
|
+
<small class="text-muted">Adapters Detected</small>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="card mb-4">
|
|
72
|
+
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
|
73
|
+
<h5 class="mb-0">
|
|
74
|
+
<i class="bi bi-cpu text-info me-2"></i>
|
|
75
|
+
Job Queue Health by Error
|
|
76
|
+
<span class="badge bg-info"><%= @errors_with_jobs %></span>
|
|
77
|
+
</h5>
|
|
78
|
+
<small class="text-muted"><%== @pagy.info_tag %></small>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="card-body p-0">
|
|
81
|
+
<div class="table-responsive">
|
|
82
|
+
<table class="table table-hover mb-0">
|
|
83
|
+
<thead class="table-light">
|
|
84
|
+
<tr>
|
|
85
|
+
<th width="100">Error</th>
|
|
86
|
+
<th width="120">Adapter</th>
|
|
87
|
+
<th width="80">Failed</th>
|
|
88
|
+
<th width="120">Queued</th>
|
|
89
|
+
<th>Other Stats</th>
|
|
90
|
+
<th width="140">Last Seen</th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
<% @entries.each do |entry| %>
|
|
95
|
+
<tr>
|
|
96
|
+
<td><%= link_to "##{entry[:error_id]}", error_path(entry[:error_id]), class: "text-decoration-none" %></td>
|
|
97
|
+
<td><span class="badge bg-secondary"><%= entry[:adapter] %></span></td>
|
|
98
|
+
<td>
|
|
99
|
+
<% failed = entry[:failed] || entry[:errored] || 0 %>
|
|
100
|
+
<% if failed > 0 %>
|
|
101
|
+
<span class="badge bg-danger"><%= failed %></span>
|
|
102
|
+
<% else %>
|
|
103
|
+
<span class="text-success">0</span>
|
|
104
|
+
<% end %>
|
|
105
|
+
</td>
|
|
106
|
+
<td>
|
|
107
|
+
<% case entry[:adapter] %>
|
|
108
|
+
<% when "sidekiq" %>
|
|
109
|
+
<%= entry[:enqueued] %> enqueued
|
|
110
|
+
<% when "solid_queue" %>
|
|
111
|
+
<%= entry[:ready] %> ready
|
|
112
|
+
<% when "good_job" %>
|
|
113
|
+
<%= entry[:queued] %> queued
|
|
114
|
+
<% end %>
|
|
115
|
+
</td>
|
|
116
|
+
<td>
|
|
117
|
+
<small class="text-muted">
|
|
118
|
+
<% case entry[:adapter] %>
|
|
119
|
+
<% when "sidekiq" %>
|
|
120
|
+
dead: <%= entry[:dead] %>, retry: <%= entry[:retry] %>, workers: <%= entry[:workers] %>
|
|
121
|
+
<% when "solid_queue" %>
|
|
122
|
+
claimed: <%= entry[:claimed] %>, blocked: <%= entry[:blocked] %>, scheduled: <%= entry[:scheduled] %>
|
|
123
|
+
<% when "good_job" %>
|
|
124
|
+
finished: <%= entry[:finished] %>
|
|
125
|
+
<% end %>
|
|
126
|
+
</small>
|
|
127
|
+
</td>
|
|
128
|
+
<td><%= local_time_ago(entry[:occurred_at]) %></td>
|
|
129
|
+
</tr>
|
|
130
|
+
<% end %>
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
|
|
136
|
+
<div>
|
|
137
|
+
<small class="text-muted">
|
|
138
|
+
<i class="bi bi-lightbulb text-warning"></i> High failed job counts during errors may indicate job queue saturation or upstream service issues.
|
|
139
|
+
</small>
|
|
140
|
+
<small class="ms-3">
|
|
141
|
+
<a href="https://guides.rubyonrails.org/active_job_basics.html" target="_blank" rel="noopener" class="text-decoration-none">
|
|
142
|
+
<i class="bi bi-book"></i> Active Job Guide <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
|
|
143
|
+
</a>
|
|
144
|
+
</small>
|
|
145
|
+
</div>
|
|
146
|
+
<div>
|
|
147
|
+
<%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
<% end %>
|
|
152
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate connection pool health stats from system_health across all errors
|
|
6
|
+
# Scans error_logs system_health JSON, extracts connection_pool data per error
|
|
7
|
+
class DatabaseHealthSummary
|
|
8
|
+
def self.call(days = 30, application_id: nil)
|
|
9
|
+
new(days, application_id: application_id).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(days = 30, application_id: nil)
|
|
13
|
+
@days = days
|
|
14
|
+
@application_id = application_id
|
|
15
|
+
@start_date = days.days.ago
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
{
|
|
20
|
+
entries: aggregated_entries
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def base_query
|
|
27
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
28
|
+
.where.not(system_health: nil)
|
|
29
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
30
|
+
scope
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def aggregated_entries
|
|
34
|
+
results = []
|
|
35
|
+
|
|
36
|
+
base_query.select(:id, :error_type, :system_health, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
37
|
+
health = parse_system_health(error_log.system_health)
|
|
38
|
+
next if health.blank?
|
|
39
|
+
|
|
40
|
+
pool = health["connection_pool"]
|
|
41
|
+
next if pool.blank?
|
|
42
|
+
|
|
43
|
+
results << build_entry(error_log, pool)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sort by stress score descending (worst first)
|
|
47
|
+
results.sort_by { |r| -(r[:busy] + r[:dead] + r[:waiting]) }
|
|
48
|
+
rescue => e
|
|
49
|
+
Rails.logger.error("[RailsErrorDashboard] DatabaseHealthSummary query failed: #{e.class}: #{e.message}")
|
|
50
|
+
[]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_entry(error_log, pool)
|
|
54
|
+
size = pool["size"].to_i
|
|
55
|
+
busy = pool["busy"].to_i
|
|
56
|
+
dead = pool["dead"].to_i
|
|
57
|
+
idle = pool["idle"].to_i
|
|
58
|
+
waiting = pool["waiting"].to_i
|
|
59
|
+
utilization = size > 0 ? (busy.to_f / size * 100).round(1) : 0.0
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
error_id: error_log.id,
|
|
63
|
+
error_type: error_log.error_type,
|
|
64
|
+
size: size,
|
|
65
|
+
busy: busy,
|
|
66
|
+
dead: dead,
|
|
67
|
+
idle: idle,
|
|
68
|
+
waiting: waiting,
|
|
69
|
+
utilization: utilization,
|
|
70
|
+
occurred_at: error_log.occurred_at
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_system_health(raw)
|
|
75
|
+
return nil if raw.blank?
|
|
76
|
+
JSON.parse(raw)
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate job queue health stats from system_health across all errors
|
|
6
|
+
# Scans error_logs system_health JSON, extracts job_queue data per error
|
|
7
|
+
class JobHealthSummary
|
|
8
|
+
def self.call(days = 30, application_id: nil)
|
|
9
|
+
new(days, application_id: application_id).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(days = 30, application_id: nil)
|
|
13
|
+
@days = days
|
|
14
|
+
@application_id = application_id
|
|
15
|
+
@start_date = days.days.ago
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
{
|
|
20
|
+
entries: aggregated_entries
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def base_query
|
|
27
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
28
|
+
.where.not(system_health: nil)
|
|
29
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
30
|
+
scope
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def aggregated_entries
|
|
34
|
+
results = []
|
|
35
|
+
|
|
36
|
+
base_query.select(:id, :system_health, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
37
|
+
health = parse_system_health(error_log.system_health)
|
|
38
|
+
next if health.blank?
|
|
39
|
+
|
|
40
|
+
job_queue = health["job_queue"]
|
|
41
|
+
next if job_queue.blank?
|
|
42
|
+
|
|
43
|
+
adapter = job_queue["adapter"]
|
|
44
|
+
next if adapter.blank?
|
|
45
|
+
|
|
46
|
+
results << build_entry(error_log, job_queue, adapter)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sort by failed count descending (worst first)
|
|
50
|
+
results.sort_by { |r| -(r[:failed] || 0) }
|
|
51
|
+
rescue => e
|
|
52
|
+
Rails.logger.error("[RailsErrorDashboard] JobHealthSummary query failed: #{e.class}: #{e.message}")
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def build_entry(error_log, job_queue, adapter)
|
|
57
|
+
entry = {
|
|
58
|
+
error_id: error_log.id,
|
|
59
|
+
adapter: adapter,
|
|
60
|
+
occurred_at: error_log.occurred_at
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case adapter
|
|
64
|
+
when "sidekiq"
|
|
65
|
+
entry.merge!(
|
|
66
|
+
enqueued: job_queue["enqueued"],
|
|
67
|
+
processed: job_queue["processed"],
|
|
68
|
+
failed: job_queue["failed"],
|
|
69
|
+
dead: job_queue["dead"],
|
|
70
|
+
scheduled: job_queue["scheduled"],
|
|
71
|
+
retry: job_queue["retry"],
|
|
72
|
+
workers: job_queue["workers"]
|
|
73
|
+
)
|
|
74
|
+
when "solid_queue"
|
|
75
|
+
entry.merge!(
|
|
76
|
+
ready: job_queue["ready"],
|
|
77
|
+
scheduled: job_queue["scheduled"],
|
|
78
|
+
claimed: job_queue["claimed"],
|
|
79
|
+
failed: job_queue["failed"],
|
|
80
|
+
blocked: job_queue["blocked"]
|
|
81
|
+
)
|
|
82
|
+
when "good_job"
|
|
83
|
+
entry.merge!(
|
|
84
|
+
queued: job_queue["queued"],
|
|
85
|
+
errored: job_queue["errored"],
|
|
86
|
+
finished: job_queue["finished"]
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
entry
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_system_health(raw)
|
|
94
|
+
return nil if raw.blank?
|
|
95
|
+
JSON.parse(raw)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Display-time only service: queries PostgreSQL system tables when the
|
|
6
|
+
# DB Health page loads. NOT used in the capture path.
|
|
7
|
+
#
|
|
8
|
+
# Feature-detects PostgreSQL — returns nil for tables/indexes/activity
|
|
9
|
+
# on SQLite/MySQL. Connection pool stats work on all adapters.
|
|
10
|
+
# Every method individually rescue-wrapped (returns nil).
|
|
11
|
+
class DatabaseHealthInspector
|
|
12
|
+
def self.call
|
|
13
|
+
new.call
|
|
14
|
+
rescue => e
|
|
15
|
+
Rails.logger.error("[RailsErrorDashboard] DatabaseHealthInspector failed: #{e.class}: #{e.message}")
|
|
16
|
+
{ adapter: nil, postgresql: false, connection_pool: nil, tables: nil,
|
|
17
|
+
indexes: nil, unused_indexes: nil, activity: nil }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
{
|
|
22
|
+
adapter: adapter_name,
|
|
23
|
+
postgresql: postgresql?,
|
|
24
|
+
connection_pool: connection_pool_stats,
|
|
25
|
+
tables: postgresql? ? table_stats : nil,
|
|
26
|
+
indexes: postgresql? ? index_stats : nil,
|
|
27
|
+
unused_indexes: postgresql? ? unused_index_stats : nil,
|
|
28
|
+
activity: postgresql? ? activity_stats : nil
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def connection
|
|
35
|
+
ActiveRecord::Base.connection
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def adapter_name
|
|
39
|
+
connection.adapter_name
|
|
40
|
+
rescue => e
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def postgresql?
|
|
45
|
+
adapter_name == "PostgreSQL"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connection_pool_stats
|
|
49
|
+
pool = ActiveRecord::Base.connection_pool
|
|
50
|
+
stat = pool.stat
|
|
51
|
+
{ size: stat[:size], busy: stat[:busy], dead: stat[:dead],
|
|
52
|
+
idle: stat[:idle], waiting: stat[:waiting] }
|
|
53
|
+
rescue => e
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def table_stats
|
|
58
|
+
rows = connection.select_all(<<~SQL)
|
|
59
|
+
SELECT
|
|
60
|
+
schemaname,
|
|
61
|
+
relname AS name,
|
|
62
|
+
n_live_tup AS estimated_rows,
|
|
63
|
+
pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) AS total_bytes,
|
|
64
|
+
seq_scan,
|
|
65
|
+
idx_scan,
|
|
66
|
+
n_dead_tup AS dead_tuples,
|
|
67
|
+
last_vacuum,
|
|
68
|
+
last_autovacuum,
|
|
69
|
+
last_analyze
|
|
70
|
+
FROM pg_stat_user_tables
|
|
71
|
+
ORDER BY pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(relname)) DESC
|
|
72
|
+
SQL
|
|
73
|
+
|
|
74
|
+
rows.map do |row|
|
|
75
|
+
{
|
|
76
|
+
name: row["name"],
|
|
77
|
+
estimated_rows: row["estimated_rows"].to_i,
|
|
78
|
+
total_bytes: row["total_bytes"].to_i,
|
|
79
|
+
seq_scan: row["seq_scan"].to_i,
|
|
80
|
+
idx_scan: row["idx_scan"].to_i,
|
|
81
|
+
dead_tuples: row["dead_tuples"].to_i,
|
|
82
|
+
last_vacuum: row["last_vacuum"],
|
|
83
|
+
last_autovacuum: row["last_autovacuum"],
|
|
84
|
+
last_analyze: row["last_analyze"],
|
|
85
|
+
gem_table: row["name"].to_s.start_with?("rails_error_dashboard_")
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
rescue => e
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def index_stats
|
|
93
|
+
rows = connection.select_all(<<~SQL)
|
|
94
|
+
SELECT
|
|
95
|
+
sui.indexrelname AS name,
|
|
96
|
+
sui.relname AS table_name,
|
|
97
|
+
pg_relation_size(sui.indexrelid) AS size_bytes,
|
|
98
|
+
sui.idx_scan AS scans,
|
|
99
|
+
sui.idx_tup_read AS tuples_read,
|
|
100
|
+
sui.idx_tup_fetch AS tuples_fetched
|
|
101
|
+
FROM pg_stat_user_indexes sui
|
|
102
|
+
ORDER BY pg_relation_size(sui.indexrelid) DESC
|
|
103
|
+
SQL
|
|
104
|
+
|
|
105
|
+
rows.map do |row|
|
|
106
|
+
{
|
|
107
|
+
name: row["name"],
|
|
108
|
+
table_name: row["table_name"],
|
|
109
|
+
size_bytes: row["size_bytes"].to_i,
|
|
110
|
+
scans: row["scans"].to_i,
|
|
111
|
+
tuples_read: row["tuples_read"].to_i,
|
|
112
|
+
tuples_fetched: row["tuples_fetched"].to_i
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
rescue => e
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def unused_index_stats
|
|
120
|
+
rows = connection.select_all(<<~SQL)
|
|
121
|
+
SELECT
|
|
122
|
+
sui.indexrelname AS name,
|
|
123
|
+
sui.relname AS table_name,
|
|
124
|
+
pg_relation_size(sui.indexrelid) AS size_bytes
|
|
125
|
+
FROM pg_stat_user_indexes sui
|
|
126
|
+
WHERE sui.idx_scan = 0
|
|
127
|
+
AND pg_relation_size(sui.indexrelid) > 0
|
|
128
|
+
ORDER BY pg_relation_size(sui.indexrelid) DESC
|
|
129
|
+
SQL
|
|
130
|
+
|
|
131
|
+
rows.map do |row|
|
|
132
|
+
{
|
|
133
|
+
name: row["name"],
|
|
134
|
+
table_name: row["table_name"],
|
|
135
|
+
size_bytes: row["size_bytes"].to_i
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
rescue => e
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def activity_stats
|
|
143
|
+
rows = connection.select_all(<<~SQL)
|
|
144
|
+
SELECT
|
|
145
|
+
COALESCE(state, 'unknown') AS state,
|
|
146
|
+
COUNT(*) AS count,
|
|
147
|
+
COUNT(*) FILTER (WHERE wait_event_type IS NOT NULL) AS waiting
|
|
148
|
+
FROM pg_stat_activity
|
|
149
|
+
WHERE datname = current_database()
|
|
150
|
+
GROUP BY state
|
|
151
|
+
ORDER BY count DESC
|
|
152
|
+
SQL
|
|
153
|
+
|
|
154
|
+
by_state = rows.map do |row|
|
|
155
|
+
{ state: row["state"], count: row["count"].to_i, waiting: row["waiting"].to_i }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{
|
|
159
|
+
by_state: by_state,
|
|
160
|
+
total: by_state.sum { |r| r[:count] },
|
|
161
|
+
total_waiting: by_state.sum { |r| r[:waiting] }
|
|
162
|
+
}
|
|
163
|
+
rescue => e
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Assemble an RSpec request spec from an error log's request data
|
|
6
|
+
#
|
|
7
|
+
# Operates on data already stored in ErrorLog — zero runtime cost.
|
|
8
|
+
# Called at display time only.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# RailsErrorDashboard::Services::RspecGenerator.call(error)
|
|
12
|
+
# # => "RSpec.describe 'POST /users', type: :request do\n it 'reproduces the error' do\n ..."
|
|
13
|
+
class RspecGenerator
|
|
14
|
+
BODY_METHODS = %w[ POST PUT PATCH DELETE ].freeze
|
|
15
|
+
|
|
16
|
+
# @param error [ErrorLog] An error log record
|
|
17
|
+
# @return [String] RSpec request spec string, or "" if insufficient data
|
|
18
|
+
def self.call(error)
|
|
19
|
+
new(error).generate
|
|
20
|
+
rescue => e
|
|
21
|
+
""
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(error)
|
|
25
|
+
@error = error
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String]
|
|
29
|
+
def generate
|
|
30
|
+
path = request_path
|
|
31
|
+
return "" if path.blank?
|
|
32
|
+
|
|
33
|
+
method = http_method
|
|
34
|
+
lines = []
|
|
35
|
+
lines << header_lines(method, path)
|
|
36
|
+
lines << ""
|
|
37
|
+
lines << " it \"reproduces the error\" do"
|
|
38
|
+
lines << request_line(method, path)
|
|
39
|
+
lines << ""
|
|
40
|
+
lines << " # Original error: #{error_type}"
|
|
41
|
+
lines << " # Expect the response to indicate the error"
|
|
42
|
+
lines << " expect(response).to have_http_status(:internal_server_error)"
|
|
43
|
+
lines << " end"
|
|
44
|
+
lines << "end"
|
|
45
|
+
|
|
46
|
+
lines.join("\n")
|
|
47
|
+
rescue => e
|
|
48
|
+
""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def header_lines(method, path)
|
|
54
|
+
"RSpec.describe \"#{method} #{path}\", type: :request do"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request_line(method, path)
|
|
58
|
+
verb = method.downcase
|
|
59
|
+
parts = []
|
|
60
|
+
|
|
61
|
+
if BODY_METHODS.include?(method) && parsed_params.present?
|
|
62
|
+
params_str = format_params(parsed_params)
|
|
63
|
+
headers_str = format_headers
|
|
64
|
+
|
|
65
|
+
args = [ "\"#{path}\"" ]
|
|
66
|
+
args << "params: #{params_str}"
|
|
67
|
+
args << "headers: #{headers_str}" if headers_str
|
|
68
|
+
|
|
69
|
+
parts << " #{verb} #{args.join(', ')}"
|
|
70
|
+
else
|
|
71
|
+
query_params = extract_query_params(path)
|
|
72
|
+
clean_path = path.split("?").first
|
|
73
|
+
|
|
74
|
+
if query_params.present?
|
|
75
|
+
params_str = format_params(query_params)
|
|
76
|
+
parts << " #{verb} \"#{clean_path}\", params: #{params_str}"
|
|
77
|
+
else
|
|
78
|
+
parts << " #{verb} \"#{path}\""
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parts.join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def http_method
|
|
86
|
+
method = @error.respond_to?(:http_method) && @error.http_method.presence
|
|
87
|
+
(method || "GET").upcase
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def request_path
|
|
91
|
+
url = @error.respond_to?(:request_url) && @error.request_url.presence
|
|
92
|
+
return nil if url.blank?
|
|
93
|
+
|
|
94
|
+
# Strip scheme + host to get just the path
|
|
95
|
+
if url.start_with?("http://", "https://")
|
|
96
|
+
URI.parse(url).request_uri
|
|
97
|
+
else
|
|
98
|
+
url
|
|
99
|
+
end
|
|
100
|
+
rescue URI::InvalidURIError
|
|
101
|
+
url
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def error_type
|
|
105
|
+
@error.respond_to?(:error_type) && @error.error_type.presence || "Unknown"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parsed_params
|
|
109
|
+
raw = @error.respond_to?(:request_params) && @error.request_params.presence
|
|
110
|
+
return nil if raw.blank?
|
|
111
|
+
|
|
112
|
+
JSON.parse(raw)
|
|
113
|
+
rescue JSON::ParserError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def extract_query_params(path)
|
|
118
|
+
query = path.split("?", 2).last
|
|
119
|
+
return nil if query == path || query.blank?
|
|
120
|
+
|
|
121
|
+
Rack::Utils.parse_query(query)
|
|
122
|
+
rescue => e
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def format_params(hash)
|
|
127
|
+
return nil if hash.blank?
|
|
128
|
+
|
|
129
|
+
pairs = hash.map { |k, v| "#{format_key(k)} => #{v.inspect}" }
|
|
130
|
+
"{ #{pairs.join(', ')} }"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def format_headers
|
|
134
|
+
content_type = @error.respond_to?(:content_type) && @error.content_type.presence
|
|
135
|
+
return nil if content_type.blank?
|
|
136
|
+
|
|
137
|
+
"{ \"Content-Type\" => #{content_type.inspect} }"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_key(key)
|
|
141
|
+
key.to_s.inspect
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -54,6 +54,8 @@ require "rails_error_dashboard/services/notification_throttler"
|
|
|
54
54
|
require "rails_error_dashboard/services/breadcrumb_collector"
|
|
55
55
|
require "rails_error_dashboard/services/n_plus_one_detector"
|
|
56
56
|
require "rails_error_dashboard/services/curl_generator"
|
|
57
|
+
require "rails_error_dashboard/services/rspec_generator"
|
|
58
|
+
require "rails_error_dashboard/services/database_health_inspector"
|
|
57
59
|
require "rails_error_dashboard/services/cache_analyzer"
|
|
58
60
|
require "rails_error_dashboard/subscribers/breadcrumb_subscriber"
|
|
59
61
|
require "rails_error_dashboard/queries/co_occurring_errors"
|
|
@@ -89,6 +91,8 @@ require "rails_error_dashboard/queries/critical_alerts"
|
|
|
89
91
|
require "rails_error_dashboard/queries/deprecation_warnings"
|
|
90
92
|
require "rails_error_dashboard/queries/n_plus_one_summary"
|
|
91
93
|
require "rails_error_dashboard/queries/cache_health_summary"
|
|
94
|
+
require "rails_error_dashboard/queries/job_health_summary"
|
|
95
|
+
require "rails_error_dashboard/queries/database_health_summary"
|
|
92
96
|
require "rails_error_dashboard/error_reporter"
|
|
93
97
|
require "rails_error_dashboard/middleware/error_catcher"
|
|
94
98
|
require "rails_error_dashboard/middleware/rate_limiter"
|