rails_error_dashboard 0.2.4 → 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 +96 -14
- data/app/controllers/rails_error_dashboard/errors_controller.rb +139 -1
- data/app/helpers/rails_error_dashboard/application_helper.rb +25 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +18 -6
- data/app/views/layouts/rails_error_dashboard.html.erb +157 -1
- data/app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb +236 -0
- data/app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb +70 -0
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +107 -0
- data/app/views/rails_error_dashboard/errors/_error_cascades.html.erb +138 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +190 -0
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +139 -0
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +108 -0
- data/app/views/rails_error_dashboard/errors/_show_scripts.html.erb +156 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +352 -0
- data/app/views/rails_error_dashboard/errors/_similar_errors.html.erb +75 -0
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/cache_health_summary.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +450 -0
- data/app/views/rails_error_dashboard/errors/deprecations.html.erb +129 -0
- data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +152 -0
- data/app/views/rails_error_dashboard/errors/n_plus_one_summary.html.erb +134 -0
- data/app/views/rails_error_dashboard/errors/settings.html.erb +17 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +20 -1132
- data/config/routes.rb +5 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +6 -0
- data/db/migrate/20260303000001_add_breadcrumbs_to_error_logs.rb +9 -0
- data/db/migrate/20260304000001_add_system_health_to_error_logs.rb +12 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +31 -3
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +67 -5
- data/lib/rails_error_dashboard/commands/log_error.rb +33 -0
- data/lib/rails_error_dashboard/configuration.rb +45 -3
- data/lib/rails_error_dashboard/engine.rb +6 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +8 -0
- data/lib/rails_error_dashboard/queries/cache_health_summary.rb +72 -0
- data/lib/rails_error_dashboard/queries/database_health_summary.rb +82 -0
- data/lib/rails_error_dashboard/queries/deprecation_warnings.rb +80 -0
- data/lib/rails_error_dashboard/queries/job_health_summary.rb +101 -0
- data/lib/rails_error_dashboard/queries/n_plus_one_summary.rb +83 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +182 -0
- data/lib/rails_error_dashboard/services/cache_analyzer.rb +76 -0
- data/lib/rails_error_dashboard/services/curl_generator.rb +80 -0
- data/lib/rails_error_dashboard/services/database_health_inspector.rb +168 -0
- data/lib/rails_error_dashboard/services/n_plus_one_detector.rb +74 -0
- data/lib/rails_error_dashboard/services/rspec_generator.rb +145 -0
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +145 -0
- data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +210 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +24 -0
- data/lib/tasks/error_dashboard.rake +68 -2
- metadata +33 -2
|
@@ -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,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate N+1 query patterns from breadcrumbs across all errors
|
|
6
|
+
# Scans error_logs breadcrumbs JSON, runs NplusOneDetector per error, and groups by fingerprint
|
|
7
|
+
class NplusOneSummary
|
|
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
|
+
patterns: aggregated_patterns
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def base_query
|
|
27
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
28
|
+
.where.not(breadcrumbs: nil)
|
|
29
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
30
|
+
scope
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def aggregated_patterns
|
|
34
|
+
results = {}
|
|
35
|
+
|
|
36
|
+
base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
37
|
+
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
38
|
+
next if crumbs.empty?
|
|
39
|
+
|
|
40
|
+
patterns = Services::NplusOneDetector.call(crumbs)
|
|
41
|
+
next if patterns.empty?
|
|
42
|
+
|
|
43
|
+
patterns.each do |pattern|
|
|
44
|
+
fingerprint = pattern[:fingerprint]
|
|
45
|
+
|
|
46
|
+
if results[fingerprint]
|
|
47
|
+
results[fingerprint][:count] += pattern[:count]
|
|
48
|
+
results[fingerprint][:total_duration_ms] += pattern[:total_duration_ms]
|
|
49
|
+
results[fingerprint][:error_ids] << error_log.id
|
|
50
|
+
results[fingerprint][:last_seen] = [ results[fingerprint][:last_seen], error_log.occurred_at ].compact.max
|
|
51
|
+
else
|
|
52
|
+
results[fingerprint] = {
|
|
53
|
+
fingerprint: fingerprint,
|
|
54
|
+
sample_query: pattern[:sample_query],
|
|
55
|
+
count: pattern[:count],
|
|
56
|
+
error_ids: [ error_log.id ],
|
|
57
|
+
total_duration_ms: pattern[:total_duration_ms],
|
|
58
|
+
last_seen: error_log.occurred_at
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
results.values.each do |r|
|
|
65
|
+
r[:error_ids] = r[:error_ids].uniq
|
|
66
|
+
r[:error_count] = r[:error_ids].size
|
|
67
|
+
r[:total_duration_ms] = r[:total_duration_ms].round(2)
|
|
68
|
+
end
|
|
69
|
+
results.values.sort_by { |r| -r[:count] }
|
|
70
|
+
rescue => e
|
|
71
|
+
Rails.logger.error("[RailsErrorDashboard] NplusOneSummary query failed: #{e.class}: #{e.message}")
|
|
72
|
+
[]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_breadcrumbs(raw)
|
|
76
|
+
return [] if raw.blank?
|
|
77
|
+
JSON.parse(raw)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure service: Collect breadcrumbs (request activity trail) for error context
|
|
6
|
+
#
|
|
7
|
+
# Uses a thread-local ring buffer to capture events during a request lifecycle.
|
|
8
|
+
# Thread.current isolation means no mutex/lock is needed.
|
|
9
|
+
#
|
|
10
|
+
# SAFETY RULES (HOST_APP_SAFETY.md):
|
|
11
|
+
# - Every public method wrapped in rescue => e; nil
|
|
12
|
+
# - Never raise, never block, never allocate large objects
|
|
13
|
+
# - Messages truncated to 500 chars, metadata values to 200 chars
|
|
14
|
+
# - Ring buffer has fixed max size (no unbounded growth)
|
|
15
|
+
class BreadcrumbCollector
|
|
16
|
+
THREAD_KEY = :red_breadcrumbs
|
|
17
|
+
MAX_MESSAGE_LENGTH = 500
|
|
18
|
+
MAX_METADATA_VALUE_LENGTH = 200
|
|
19
|
+
MAX_METADATA_KEYS = 10
|
|
20
|
+
|
|
21
|
+
# Fixed-size ring buffer — O(1) append, wraps around when full
|
|
22
|
+
class RingBuffer
|
|
23
|
+
def initialize(max_size)
|
|
24
|
+
@max_size = max_size
|
|
25
|
+
@buffer = Array.new(max_size)
|
|
26
|
+
@write_pos = 0
|
|
27
|
+
@count = 0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def add(entry)
|
|
31
|
+
@buffer[@write_pos] = entry
|
|
32
|
+
@write_pos = (@write_pos + 1) % @max_size
|
|
33
|
+
@count += 1 if @count < @max_size
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_a
|
|
37
|
+
return [] if @count == 0
|
|
38
|
+
|
|
39
|
+
if @count < @max_size
|
|
40
|
+
@buffer[0...@count]
|
|
41
|
+
else
|
|
42
|
+
# Buffer has wrapped — read from write_pos to end, then start to write_pos
|
|
43
|
+
@buffer[@write_pos...@max_size] + @buffer[0...@write_pos]
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear
|
|
48
|
+
@buffer = Array.new(@max_size)
|
|
49
|
+
@write_pos = 0
|
|
50
|
+
@count = 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Initialize a new ring buffer for the current thread (start of request)
|
|
55
|
+
def self.init_buffer
|
|
56
|
+
size = RailsErrorDashboard.configuration.breadcrumb_buffer_size || 40
|
|
57
|
+
Thread.current[THREAD_KEY] = RingBuffer.new(size)
|
|
58
|
+
rescue => e
|
|
59
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.init_buffer failed: #{e.message}")
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Clear the ring buffer (end of request — MUST be called in ensure block)
|
|
64
|
+
def self.clear_buffer
|
|
65
|
+
Thread.current[THREAD_KEY] = nil
|
|
66
|
+
rescue => e
|
|
67
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.clear_buffer failed: #{e.message}")
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Add a breadcrumb to the current buffer
|
|
72
|
+
# @param category [String] Event category (sql, controller, cache, job, mailer, custom)
|
|
73
|
+
# @param message [String] Human-readable description
|
|
74
|
+
# @param duration_ms [Float, nil] Duration in milliseconds
|
|
75
|
+
# @param metadata [Hash, nil] Optional key-value pairs
|
|
76
|
+
def self.add(category, message, duration_ms: nil, metadata: nil)
|
|
77
|
+
buffer = Thread.current[THREAD_KEY]
|
|
78
|
+
return unless buffer
|
|
79
|
+
|
|
80
|
+
# Check category filter
|
|
81
|
+
allowed = RailsErrorDashboard.configuration.breadcrumb_categories
|
|
82
|
+
if allowed
|
|
83
|
+
cat_sym = category.to_s.to_sym
|
|
84
|
+
return unless allowed.include?(cat_sym)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Build breadcrumb entry with compact keys
|
|
88
|
+
entry = {
|
|
89
|
+
t: (Time.now.to_f * 1000).to_i,
|
|
90
|
+
c: category.to_s,
|
|
91
|
+
m: truncate_message(message)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
entry[:d] = duration_ms if duration_ms
|
|
95
|
+
entry[:meta] = truncate_metadata(metadata) if metadata.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
buffer.add(entry)
|
|
98
|
+
rescue => e
|
|
99
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.add failed: #{e.message}")
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Harvest breadcrumbs from the current buffer and clear it
|
|
104
|
+
# @return [Array<Hash>] Array of breadcrumb hashes (empty if none)
|
|
105
|
+
def self.harvest
|
|
106
|
+
buffer = Thread.current[THREAD_KEY]
|
|
107
|
+
return [] unless buffer
|
|
108
|
+
|
|
109
|
+
result = buffer.to_a
|
|
110
|
+
buffer.clear
|
|
111
|
+
result
|
|
112
|
+
rescue => e
|
|
113
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.harvest failed: #{e.message}")
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get the current buffer (for inspection)
|
|
118
|
+
# @return [RingBuffer, nil]
|
|
119
|
+
def self.current_buffer
|
|
120
|
+
Thread.current[THREAD_KEY]
|
|
121
|
+
rescue => e
|
|
122
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.current_buffer failed: #{e.message}")
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Filter sensitive data from breadcrumbs before storage
|
|
127
|
+
# Reuses existing SensitiveDataFilter — no new filter logic
|
|
128
|
+
# @param breadcrumbs [Array<Hash>] Raw breadcrumbs
|
|
129
|
+
# @return [Array<Hash>] Filtered breadcrumbs
|
|
130
|
+
def self.filter_sensitive(breadcrumbs)
|
|
131
|
+
return [] unless breadcrumbs.is_a?(Array)
|
|
132
|
+
return breadcrumbs unless RailsErrorDashboard.configuration.filter_sensitive_data
|
|
133
|
+
|
|
134
|
+
filter = SensitiveDataFilter.parameter_filter
|
|
135
|
+
return breadcrumbs unless filter
|
|
136
|
+
|
|
137
|
+
breadcrumbs.map do |crumb|
|
|
138
|
+
filtered = crumb.dup
|
|
139
|
+
|
|
140
|
+
# Filter message (SQL queries, key=value patterns)
|
|
141
|
+
if filtered[:m]
|
|
142
|
+
filtered[:m] = SensitiveDataFilter.send(:filter_message, filter, filtered[:m])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Filter metadata values
|
|
146
|
+
if filtered[:meta].is_a?(Hash)
|
|
147
|
+
filtered[:meta] = filter.filter(filtered[:meta])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
filtered
|
|
151
|
+
end
|
|
152
|
+
rescue => e
|
|
153
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.filter_sensitive failed: #{e.message}")
|
|
154
|
+
breadcrumbs.is_a?(Array) ? breadcrumbs : []
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Truncate message to MAX_MESSAGE_LENGTH
|
|
158
|
+
def self.truncate_message(message)
|
|
159
|
+
str = message.to_s
|
|
160
|
+
str.length > MAX_MESSAGE_LENGTH ? str[0, MAX_MESSAGE_LENGTH] : str
|
|
161
|
+
rescue => e
|
|
162
|
+
""
|
|
163
|
+
end
|
|
164
|
+
private_class_method :truncate_message
|
|
165
|
+
|
|
166
|
+
# Truncate metadata: limit keys and value lengths
|
|
167
|
+
def self.truncate_metadata(metadata)
|
|
168
|
+
return {} unless metadata.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
result = {}
|
|
171
|
+
metadata.first(MAX_METADATA_KEYS).each do |key, value|
|
|
172
|
+
str_value = value.to_s
|
|
173
|
+
result[key] = str_value.length > MAX_METADATA_VALUE_LENGTH ? str_value[0, MAX_METADATA_VALUE_LENGTH] : str_value
|
|
174
|
+
end
|
|
175
|
+
result
|
|
176
|
+
rescue => e
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
private_class_method :truncate_metadata
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Analyze cache breadcrumbs at display time
|
|
6
|
+
#
|
|
7
|
+
# Operates on already-captured breadcrumb data — zero runtime cost.
|
|
8
|
+
# Called at display time only. Similar pattern to NplusOneDetector.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# RailsErrorDashboard::Services::CacheAnalyzer.call(breadcrumbs)
|
|
12
|
+
# # => { reads: 5, writes: 2, hits: 3, misses: 1, unknown: 1,
|
|
13
|
+
# # hit_rate: 75.0, total_duration_ms: 12.5,
|
|
14
|
+
# # slowest: { message: "cache read: users/1", duration_ms: 5.2 } }
|
|
15
|
+
class CacheAnalyzer
|
|
16
|
+
# @param breadcrumbs [Array<Hash>] Parsed breadcrumb array
|
|
17
|
+
# @return [Hash, nil] Cache analysis summary, or nil if no cache breadcrumbs
|
|
18
|
+
def self.call(breadcrumbs)
|
|
19
|
+
return nil unless breadcrumbs.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
cache_crumbs = breadcrumbs.select { |c| c["c"] == "cache" }
|
|
22
|
+
return nil if cache_crumbs.empty?
|
|
23
|
+
|
|
24
|
+
reads = 0
|
|
25
|
+
writes = 0
|
|
26
|
+
hits = 0
|
|
27
|
+
misses = 0
|
|
28
|
+
unknown = 0
|
|
29
|
+
total_duration = 0.0
|
|
30
|
+
slowest = nil
|
|
31
|
+
|
|
32
|
+
cache_crumbs.each do |crumb|
|
|
33
|
+
message = crumb["m"].to_s
|
|
34
|
+
duration = crumb["d"].to_f
|
|
35
|
+
|
|
36
|
+
if message.start_with?("cache read:")
|
|
37
|
+
reads += 1
|
|
38
|
+
hit_status = crumb.dig("meta", "hit")
|
|
39
|
+
if hit_status.nil?
|
|
40
|
+
unknown += 1
|
|
41
|
+
elsif hit_status == true || hit_status == "true"
|
|
42
|
+
hits += 1
|
|
43
|
+
else
|
|
44
|
+
misses += 1
|
|
45
|
+
end
|
|
46
|
+
elsif message.start_with?("cache write:")
|
|
47
|
+
writes += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
total_duration += duration
|
|
51
|
+
|
|
52
|
+
if slowest.nil? || duration > slowest[:duration_ms]
|
|
53
|
+
slowest = { message: message, duration_ms: duration }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only calculate hit_rate if we have read breadcrumbs with known hit status
|
|
58
|
+
known_reads = hits + misses
|
|
59
|
+
hit_rate = known_reads > 0 ? (hits.to_f / known_reads * 100).round(1) : nil
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
reads: reads,
|
|
63
|
+
writes: writes,
|
|
64
|
+
hits: hits,
|
|
65
|
+
misses: misses,
|
|
66
|
+
unknown: unknown,
|
|
67
|
+
hit_rate: hit_rate,
|
|
68
|
+
total_duration_ms: total_duration.round(1),
|
|
69
|
+
slowest: slowest
|
|
70
|
+
}
|
|
71
|
+
rescue => e
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Assemble a curl command 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::CurlGenerator.call(error)
|
|
12
|
+
# # => "curl -X POST 'https://example.com/users' -H 'Content-Type: application/json' -d '{\"name\":\"test\"}'"
|
|
13
|
+
class CurlGenerator
|
|
14
|
+
BODY_METHODS = %w[ POST PUT PATCH ].freeze
|
|
15
|
+
|
|
16
|
+
# @param error [ErrorLog] An error log record
|
|
17
|
+
# @return [String] curl command 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
|
+
url = build_url
|
|
31
|
+
return "" if url.blank?
|
|
32
|
+
|
|
33
|
+
parts = [ "curl" ]
|
|
34
|
+
|
|
35
|
+
method = @error.respond_to?(:http_method) && @error.http_method.presence
|
|
36
|
+
parts << "-X #{method}" if method && method != "GET"
|
|
37
|
+
|
|
38
|
+
parts << shell_quote(url)
|
|
39
|
+
|
|
40
|
+
content_type = @error.respond_to?(:content_type) && @error.content_type.presence
|
|
41
|
+
parts << "-H #{shell_quote("Content-Type: #{content_type}")}" if content_type
|
|
42
|
+
|
|
43
|
+
user_agent = @error.respond_to?(:user_agent) && @error.user_agent.presence
|
|
44
|
+
parts << "-H #{shell_quote("User-Agent: #{user_agent}")}" if user_agent
|
|
45
|
+
|
|
46
|
+
if method && BODY_METHODS.include?(method.to_s.upcase)
|
|
47
|
+
body = @error.respond_to?(:request_params) && @error.request_params.presence
|
|
48
|
+
parts << "-d #{shell_quote(body)}" if body
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
parts.join(" ")
|
|
52
|
+
rescue => e
|
|
53
|
+
""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def build_url
|
|
59
|
+
request_url = @error.respond_to?(:request_url) && @error.request_url.presence
|
|
60
|
+
return nil unless request_url
|
|
61
|
+
|
|
62
|
+
# If request_url is already a full URL, use it directly
|
|
63
|
+
return request_url if request_url.start_with?("http://", "https://")
|
|
64
|
+
|
|
65
|
+
# Otherwise, prepend hostname
|
|
66
|
+
hostname = @error.respond_to?(:hostname) && @error.hostname.presence
|
|
67
|
+
return nil unless hostname
|
|
68
|
+
|
|
69
|
+
scheme = hostname.include?("localhost") ? "http" : "https"
|
|
70
|
+
"#{scheme}://#{hostname}#{request_url}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shell_quote(str)
|
|
74
|
+
# Replace ' with '\'' (end quote, escaped quote, start quote)
|
|
75
|
+
escaped = str.to_s.gsub("'") { "'\\''" }
|
|
76
|
+
"'#{escaped}'"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
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
|