rails_error_dashboard 0.3.0 → 0.4.0
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 +162 -834
- data/app/controllers/rails_error_dashboard/errors_controller.rb +140 -0
- data/app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb +32 -0
- data/app/models/rails_error_dashboard/diagnostic_dump.rb +14 -0
- data/app/models/rails_error_dashboard/swallowed_exception.rb +38 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +33 -0
- data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +55 -0
- data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +46 -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/diagnostic_dumps.html.erb +182 -0
- data/app/views/rails_error_dashboard/errors/job_health_summary.html.erb +152 -0
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
- data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
- data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
- data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
- data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
- data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
- data/lib/rails_error_dashboard/configuration.rb +122 -0
- data/lib/rails_error_dashboard/engine.rb +24 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
- 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/queries/rack_attack_summary.rb +90 -0
- data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
- data/lib/rails_error_dashboard/services/database_health_inspector.rb +168 -0
- data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
- data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
- data/lib/rails_error_dashboard/services/rspec_generator.rb +145 -0
- data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
- data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
- data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +13 -0
- data/lib/tasks/error_dashboard.rake +34 -0
- metadata +29 -2
|
@@ -356,7 +356,54 @@ RailsErrorDashboard.configure do |config|
|
|
|
356
356
|
config.enable_system_health = false
|
|
357
357
|
|
|
358
358
|
<% end -%>
|
|
359
|
+
<% if @enable_swallowed_exceptions -%>
|
|
360
|
+
# Swallowed Exception Detection - ENABLED
|
|
361
|
+
# Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
|
|
362
|
+
# Uses TracePoint(:rescue), which was added in Ruby 3.3 (Feature #19572)
|
|
363
|
+
config.detect_swallowed_exceptions = true
|
|
364
|
+
config.swallowed_exception_threshold = 0.95 # Rescue ratio to flag (95%+)
|
|
365
|
+
# config.swallowed_exception_flush_interval = 60 # Seconds between DB flushes
|
|
366
|
+
# config.swallowed_exception_max_cache_size = 1000 # Max entries per thread
|
|
367
|
+
# config.swallowed_exception_ignore_classes = [] # App-specific exceptions to skip
|
|
368
|
+
# To disable: Set config.detect_swallowed_exceptions = false
|
|
359
369
|
|
|
370
|
+
<% else -%>
|
|
371
|
+
# Swallowed Exception Detection - DISABLED
|
|
372
|
+
# Requires Ruby 3.3+ (TracePoint(:rescue) not available before 3.3)
|
|
373
|
+
# To enable: Set config.detect_swallowed_exceptions = true
|
|
374
|
+
config.detect_swallowed_exceptions = false
|
|
375
|
+
# config.swallowed_exception_threshold = 0.95
|
|
376
|
+
|
|
377
|
+
<% end -%>
|
|
378
|
+
<% if @enable_diagnostic_dump -%>
|
|
379
|
+
# Diagnostic Dump - ENABLED
|
|
380
|
+
# On-demand system state snapshot via rake task or dashboard button
|
|
381
|
+
config.enable_diagnostic_dump = true
|
|
382
|
+
# To disable: Set config.enable_diagnostic_dump = false
|
|
383
|
+
|
|
384
|
+
<% else -%>
|
|
385
|
+
# Diagnostic Dump - DISABLED
|
|
386
|
+
# On-demand system state snapshot (rake task + dashboard page)
|
|
387
|
+
# To enable: Set config.enable_diagnostic_dump = true
|
|
388
|
+
config.enable_diagnostic_dump = false
|
|
389
|
+
|
|
390
|
+
<% end -%>
|
|
391
|
+
<% if @enable_crash_capture -%>
|
|
392
|
+
# Process Crash Capture - ENABLED
|
|
393
|
+
# Captures fatal crashes via at_exit hook. Crash data is written to disk as JSON
|
|
394
|
+
# and imported into the database on next boot. Zero runtime overhead.
|
|
395
|
+
config.enable_crash_capture = true
|
|
396
|
+
# config.crash_capture_path = "/tmp/my_app_crashes" # Default: Dir.tmpdir
|
|
397
|
+
# To disable: Set config.enable_crash_capture = false
|
|
398
|
+
|
|
399
|
+
<% else -%>
|
|
400
|
+
# Process Crash Capture - DISABLED
|
|
401
|
+
# Captures fatal crashes via at_exit hook (written to disk, imported on next boot)
|
|
402
|
+
# To enable: Set config.enable_crash_capture = true
|
|
403
|
+
config.enable_crash_capture = false
|
|
404
|
+
# config.crash_capture_path = "/tmp/my_app_crashes"
|
|
405
|
+
|
|
406
|
+
<% end -%>
|
|
360
407
|
# Repository settings (auto-detected from git remote, optional override)
|
|
361
408
|
# config.repository_url = ENV["REPOSITORY_URL"] # e.g., "https://github.com/user/repo"
|
|
362
409
|
# config.repository_branch = ENV.fetch("REPOSITORY_BRANCH", "main") # Default branch
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Commands
|
|
5
|
+
# Command: Upsert swallowed exception raise/rescue counts into the database.
|
|
6
|
+
#
|
|
7
|
+
# Receives snapshot hashes from SwallowedExceptionTracker and merges them
|
|
8
|
+
# into hourly-bucketed rows. Uses find_or_initialize_by + increment for
|
|
9
|
+
# cross-database compatibility (no raw SQL upsert).
|
|
10
|
+
#
|
|
11
|
+
# raise_counts keys: "ClassName|path:line"
|
|
12
|
+
# rescue_counts keys: "ClassName|raise_path:line->rescue_path:line"
|
|
13
|
+
class FlushSwallowedExceptions
|
|
14
|
+
def self.call(raise_counts:, rescue_counts:)
|
|
15
|
+
new(raise_counts: raise_counts, rescue_counts: rescue_counts).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(raise_counts:, rescue_counts:)
|
|
19
|
+
@raise_counts = raise_counts
|
|
20
|
+
@rescue_counts = rescue_counts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
period = Time.current.beginning_of_hour
|
|
25
|
+
app_id = current_application_id
|
|
26
|
+
|
|
27
|
+
# Process raise counts
|
|
28
|
+
@raise_counts.each do |key, count|
|
|
29
|
+
class_name, location = key.split("|", 2)
|
|
30
|
+
next if class_name.blank? || location.blank?
|
|
31
|
+
|
|
32
|
+
upsert_raise(class_name, location, period, app_id, count)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Process rescue counts
|
|
36
|
+
@rescue_counts.each do |key, count|
|
|
37
|
+
class_name, locations = key.split("|", 2)
|
|
38
|
+
next if class_name.blank? || locations.blank?
|
|
39
|
+
|
|
40
|
+
raise_loc, rescue_loc = locations.split("->", 2)
|
|
41
|
+
next if raise_loc.blank?
|
|
42
|
+
|
|
43
|
+
upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
RailsErrorDashboard::Logger.debug(
|
|
47
|
+
"[RailsErrorDashboard] FlushSwallowedExceptions failed: #{e.class} - #{e.message}"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def upsert_raise(class_name, location, period, app_id, count)
|
|
54
|
+
record = SwallowedException.find_or_initialize_by(
|
|
55
|
+
exception_class: truncate(class_name, 255),
|
|
56
|
+
raise_location: truncate(location, 500),
|
|
57
|
+
rescue_location: nil,
|
|
58
|
+
period_hour: period,
|
|
59
|
+
application_id: app_id
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
record.raise_count = (record.raise_count || 0) + count
|
|
63
|
+
record.last_seen_at = Time.current
|
|
64
|
+
record.save!
|
|
65
|
+
rescue => e
|
|
66
|
+
RailsErrorDashboard::Logger.debug(
|
|
67
|
+
"[RailsErrorDashboard] FlushSwallowedExceptions.upsert_raise failed for #{class_name}: #{e.message}"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
|
|
72
|
+
record = SwallowedException.find_or_initialize_by(
|
|
73
|
+
exception_class: truncate(class_name, 255),
|
|
74
|
+
raise_location: truncate(raise_loc, 500),
|
|
75
|
+
rescue_location: rescue_loc.present? ? truncate(rescue_loc, 500) : nil,
|
|
76
|
+
period_hour: period,
|
|
77
|
+
application_id: app_id
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
record.rescue_count = (record.rescue_count || 0) + count
|
|
81
|
+
record.last_seen_at = Time.current
|
|
82
|
+
record.save!
|
|
83
|
+
rescue => e
|
|
84
|
+
RailsErrorDashboard::Logger.debug(
|
|
85
|
+
"[RailsErrorDashboard] FlushSwallowedExceptions.upsert_rescue failed for #{class_name}: #{e.message}"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def current_application_id
|
|
90
|
+
app_name = RailsErrorDashboard.configuration.application_name
|
|
91
|
+
return nil unless app_name.present?
|
|
92
|
+
|
|
93
|
+
Application.find_by(name: app_name)&.id
|
|
94
|
+
rescue => e
|
|
95
|
+
nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def truncate(str, max)
|
|
99
|
+
str.to_s.truncate(max, omission: "")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -37,6 +37,34 @@ module RailsErrorDashboard
|
|
|
37
37
|
context = context.merge(_serialized_system_health: Services::SystemHealthSnapshot.capture)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Capture local variables NOW (TracePoint attaches to exception, must extract before job dispatch)
|
|
41
|
+
if RailsErrorDashboard.configuration.enable_local_variables
|
|
42
|
+
begin
|
|
43
|
+
raw_locals = Services::LocalVariableCapturer.extract(exception)
|
|
44
|
+
if raw_locals.is_a?(Hash) && raw_locals.any?
|
|
45
|
+
context = context.merge(_serialized_local_variables: Services::VariableSerializer.call(raw_locals))
|
|
46
|
+
end
|
|
47
|
+
rescue => e
|
|
48
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Async local variable serialization failed: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Capture instance variables NOW (same reason — attached to exception object)
|
|
53
|
+
if RailsErrorDashboard.configuration.enable_instance_variables
|
|
54
|
+
begin
|
|
55
|
+
raw_ivars = Services::LocalVariableCapturer.extract_instance_vars(exception)
|
|
56
|
+
if raw_ivars.is_a?(Hash) && raw_ivars.any?
|
|
57
|
+
context = context.merge(_serialized_instance_variables: Services::VariableSerializer.call(
|
|
58
|
+
raw_ivars,
|
|
59
|
+
max_count: RailsErrorDashboard.configuration.instance_variable_max_count,
|
|
60
|
+
additional_filter_patterns: RailsErrorDashboard.configuration.instance_variable_filter_patterns
|
|
61
|
+
))
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Async instance variable serialization failed: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
40
68
|
# Enqueue the async job using ActiveJob
|
|
41
69
|
# The queue adapter (:sidekiq, :solid_queue, :async) is configured separately
|
|
42
70
|
AsyncErrorLoggingJob.perform_later(exception_data, context)
|
|
@@ -179,6 +207,46 @@ module RailsErrorDashboard
|
|
|
179
207
|
attributes[:system_health] = health_data.to_json
|
|
180
208
|
end
|
|
181
209
|
|
|
210
|
+
# Capture local variables (if enabled and column exists)
|
|
211
|
+
if ErrorLog.column_names.include?("local_variables") && RailsErrorDashboard.configuration.enable_local_variables
|
|
212
|
+
begin
|
|
213
|
+
# Sync path: extract from exception ivar
|
|
214
|
+
raw_locals = Services::LocalVariableCapturer.extract(@exception)
|
|
215
|
+
# Async path fallback: use pre-serialized locals from call_async context
|
|
216
|
+
raw_locals ||= @context[:_serialized_local_variables]
|
|
217
|
+
if raw_locals.is_a?(Hash) && raw_locals.any?
|
|
218
|
+
serialized = raw_locals == @context[:_serialized_local_variables] ? raw_locals : Services::VariableSerializer.call(raw_locals)
|
|
219
|
+
attributes[:local_variables] = serialized.to_json
|
|
220
|
+
end
|
|
221
|
+
rescue => e
|
|
222
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Local variable serialization failed: #{e.message}")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Capture instance variables (if enabled and column exists)
|
|
227
|
+
if ErrorLog.column_names.include?("instance_variables") && RailsErrorDashboard.configuration.enable_instance_variables
|
|
228
|
+
begin
|
|
229
|
+
# Sync path: extract from exception ivar
|
|
230
|
+
raw_ivars = Services::LocalVariableCapturer.extract_instance_vars(@exception)
|
|
231
|
+
# Async path fallback: use pre-serialized ivars from call_async context
|
|
232
|
+
raw_ivars ||= @context[:_serialized_instance_variables]
|
|
233
|
+
if raw_ivars.is_a?(Hash) && raw_ivars.any?
|
|
234
|
+
serialized = if raw_ivars == @context[:_serialized_instance_variables]
|
|
235
|
+
raw_ivars
|
|
236
|
+
else
|
|
237
|
+
Services::VariableSerializer.call(
|
|
238
|
+
raw_ivars,
|
|
239
|
+
max_count: RailsErrorDashboard.configuration.instance_variable_max_count,
|
|
240
|
+
additional_filter_patterns: RailsErrorDashboard.configuration.instance_variable_filter_patterns
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
attributes[:instance_variables] = serialized.to_json
|
|
244
|
+
end
|
|
245
|
+
rescue => e
|
|
246
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Instance variable serialization failed: #{e.message}")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
182
250
|
# Find existing error or create new one
|
|
183
251
|
# This ensures accurate occurrence tracking
|
|
184
252
|
error_log = ErrorLog.find_or_increment_by_hash(error_hash, attributes.merge(error_hash: error_hash))
|
|
@@ -127,6 +127,37 @@ module RailsErrorDashboard
|
|
|
127
127
|
# System health snapshot (GC, memory, threads, connection pool at error time)
|
|
128
128
|
attr_accessor :enable_system_health # Master switch (default: false)
|
|
129
129
|
|
|
130
|
+
# Local variable capture via TracePoint(:raise)
|
|
131
|
+
attr_accessor :enable_local_variables # Master switch (default: false)
|
|
132
|
+
attr_accessor :local_variable_max_count # Max variables to capture (default: 15)
|
|
133
|
+
attr_accessor :local_variable_max_depth # Max object nesting depth (default: 3)
|
|
134
|
+
attr_accessor :local_variable_max_string_length # Max string value length (default: 200)
|
|
135
|
+
attr_accessor :local_variable_max_array_items # Max array items to serialize (default: 10)
|
|
136
|
+
attr_accessor :local_variable_max_hash_items # Max hash entries to serialize (default: 20)
|
|
137
|
+
attr_accessor :local_variable_filter_patterns # Additional sensitive name patterns (default: [])
|
|
138
|
+
|
|
139
|
+
# Instance variable capture from tp.self (receiver object at raise time)
|
|
140
|
+
attr_accessor :enable_instance_variables # Master switch (default: false)
|
|
141
|
+
attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
|
|
142
|
+
attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
|
|
143
|
+
|
|
144
|
+
# Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
|
|
145
|
+
attr_accessor :detect_swallowed_exceptions # Master switch (default: false)
|
|
146
|
+
attr_accessor :swallowed_exception_max_cache_size # Max entries per thread (default: 1000)
|
|
147
|
+
attr_accessor :swallowed_exception_flush_interval # Seconds between flushes (default: 60)
|
|
148
|
+
attr_accessor :swallowed_exception_threshold # Rescue ratio to flag (default: 0.95)
|
|
149
|
+
attr_accessor :swallowed_exception_ignore_classes # Additional exception classes to skip (default: [])
|
|
150
|
+
|
|
151
|
+
# Process crash capture via at_exit hook
|
|
152
|
+
attr_accessor :enable_crash_capture # Master switch (default: false)
|
|
153
|
+
attr_accessor :crash_capture_path # Directory for crash files (default: Dir.tmpdir)
|
|
154
|
+
|
|
155
|
+
# On-demand diagnostic dump (rake task + dashboard endpoint)
|
|
156
|
+
attr_accessor :enable_diagnostic_dump # Master switch (default: false)
|
|
157
|
+
|
|
158
|
+
# Rack Attack event tracking (requires enable_breadcrumbs = true)
|
|
159
|
+
attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
|
|
160
|
+
|
|
130
161
|
# Notification callbacks (managed via helper methods, not set directly)
|
|
131
162
|
attr_reader :notification_callbacks
|
|
132
163
|
|
|
@@ -238,6 +269,37 @@ module RailsErrorDashboard
|
|
|
238
269
|
# System health snapshot defaults - OFF by default (opt-in)
|
|
239
270
|
@enable_system_health = false # Capture GC, memory, threads, connection pool at error time
|
|
240
271
|
|
|
272
|
+
# Local variable capture defaults - OFF by default (opt-in)
|
|
273
|
+
@enable_local_variables = false # TracePoint(:raise) for local var capture
|
|
274
|
+
@local_variable_max_count = 15 # Max variables per exception
|
|
275
|
+
@local_variable_max_depth = 3 # Max nesting depth for objects
|
|
276
|
+
@local_variable_max_string_length = 200 # Truncate strings beyond this
|
|
277
|
+
@local_variable_max_array_items = 10 # Max array items to serialize
|
|
278
|
+
@local_variable_max_hash_items = 20 # Max hash entries to serialize
|
|
279
|
+
@local_variable_filter_patterns = [] # Additional sensitive variable name patterns
|
|
280
|
+
|
|
281
|
+
# Instance variable capture defaults - OFF by default (opt-in)
|
|
282
|
+
@enable_instance_variables = false # Capture ivars from tp.self at raise time
|
|
283
|
+
@instance_variable_max_count = 20 # Max ivars per exception
|
|
284
|
+
@instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
|
|
285
|
+
|
|
286
|
+
# Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
|
|
287
|
+
@detect_swallowed_exceptions = false # TracePoint(:raise) + TracePoint(:rescue)
|
|
288
|
+
@swallowed_exception_max_cache_size = 1000 # Max entries per thread-local hash
|
|
289
|
+
@swallowed_exception_flush_interval = 60 # Seconds between DB flushes
|
|
290
|
+
@swallowed_exception_threshold = 0.95 # Rescue ratio to flag as swallowed
|
|
291
|
+
@swallowed_exception_ignore_classes = [] # Additional exception classes to skip
|
|
292
|
+
|
|
293
|
+
# Process crash capture defaults - OFF by default (opt-in)
|
|
294
|
+
@enable_crash_capture = false # at_exit hook for fatal crash capture
|
|
295
|
+
@crash_capture_path = nil # nil = Dir.tmpdir
|
|
296
|
+
|
|
297
|
+
# Diagnostic dump defaults - OFF by default (opt-in)
|
|
298
|
+
@enable_diagnostic_dump = false # On-demand system state snapshot
|
|
299
|
+
|
|
300
|
+
# Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
301
|
+
@enable_rack_attack_tracking = false
|
|
302
|
+
|
|
241
303
|
# Internal logging defaults - SILENT by default
|
|
242
304
|
@enable_internal_logging = false # Opt-in for debugging
|
|
243
305
|
@log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
|
|
@@ -256,11 +318,13 @@ module RailsErrorDashboard
|
|
|
256
318
|
|
|
257
319
|
# Validate configuration values
|
|
258
320
|
# Raises ConfigurationError if any validation fails
|
|
321
|
+
# Logs warnings for non-fatal issues (e.g., Ruby version incompatibilities)
|
|
259
322
|
#
|
|
260
323
|
# @raise [ConfigurationError] if configuration is invalid
|
|
261
324
|
# @return [true] if configuration is valid
|
|
262
325
|
def validate!
|
|
263
326
|
errors = []
|
|
327
|
+
warnings = []
|
|
264
328
|
|
|
265
329
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
266
330
|
if sampling_rate && (sampling_rate < 0.0 || sampling_rate > 1.0)
|
|
@@ -330,6 +394,59 @@ module RailsErrorDashboard
|
|
|
330
394
|
errors << "n_plus_one_threshold must be at least 2 (got: #{n_plus_one_threshold})"
|
|
331
395
|
end
|
|
332
396
|
|
|
397
|
+
# Validate local variable capture settings
|
|
398
|
+
if enable_local_variables
|
|
399
|
+
if local_variable_max_count && local_variable_max_count < 1
|
|
400
|
+
errors << "local_variable_max_count must be at least 1 (got: #{local_variable_max_count})"
|
|
401
|
+
end
|
|
402
|
+
if local_variable_max_depth && local_variable_max_depth < 1
|
|
403
|
+
errors << "local_variable_max_depth must be at least 1 (got: #{local_variable_max_depth})"
|
|
404
|
+
end
|
|
405
|
+
if local_variable_max_string_length && local_variable_max_string_length < 1
|
|
406
|
+
errors << "local_variable_max_string_length must be at least 1 (got: #{local_variable_max_string_length})"
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Validate instance variable capture settings
|
|
411
|
+
if enable_instance_variables && instance_variable_max_count && instance_variable_max_count < 1
|
|
412
|
+
errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Validate swallowed exception detection settings
|
|
416
|
+
# Auto-disable on Ruby < 3.3 (warn, don't crash)
|
|
417
|
+
if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
|
|
418
|
+
warnings << "detect_swallowed_exceptions requires Ruby 3.3+ (current: #{RUBY_VERSION}). " \
|
|
419
|
+
"TracePoint(:rescue) was added in Ruby 3.3 (Feature #19572). " \
|
|
420
|
+
"Feature has been auto-disabled. Upgrade Ruby to use this feature."
|
|
421
|
+
@detect_swallowed_exceptions = false
|
|
422
|
+
end
|
|
423
|
+
# Validate sub-settings only if feature is still active after version check
|
|
424
|
+
if detect_swallowed_exceptions
|
|
425
|
+
if swallowed_exception_max_cache_size && swallowed_exception_max_cache_size < 1
|
|
426
|
+
errors << "swallowed_exception_max_cache_size must be at least 1 (got: #{swallowed_exception_max_cache_size})"
|
|
427
|
+
end
|
|
428
|
+
if swallowed_exception_flush_interval && swallowed_exception_flush_interval < 1
|
|
429
|
+
errors << "swallowed_exception_flush_interval must be at least 1 (got: #{swallowed_exception_flush_interval})"
|
|
430
|
+
end
|
|
431
|
+
if swallowed_exception_threshold && (swallowed_exception_threshold < 0.0 || swallowed_exception_threshold > 1.0)
|
|
432
|
+
errors << "swallowed_exception_threshold must be between 0.0 and 1.0 (got: #{swallowed_exception_threshold})"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Validate rack_attack tracking requires breadcrumbs
|
|
437
|
+
if enable_rack_attack_tracking && !enable_breadcrumbs
|
|
438
|
+
warnings << "enable_rack_attack_tracking requires enable_breadcrumbs = true. " \
|
|
439
|
+
"Rack Attack tracking has been auto-disabled."
|
|
440
|
+
@enable_rack_attack_tracking = false
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Validate crash capture path (must exist if custom path specified)
|
|
444
|
+
if enable_crash_capture && crash_capture_path
|
|
445
|
+
unless Dir.exist?(crash_capture_path)
|
|
446
|
+
errors << "crash_capture_path '#{crash_capture_path}' does not exist"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
333
450
|
# Validate notification dependencies
|
|
334
451
|
if enable_slack_notifications && (slack_webhook_url.nil? || slack_webhook_url.strip.empty?)
|
|
335
452
|
errors << "slack_webhook_url is required when enable_slack_notifications is true"
|
|
@@ -388,6 +505,11 @@ module RailsErrorDashboard
|
|
|
388
505
|
errors << "notification_threshold_alerts must be an Array (got: #{notification_threshold_alerts.class})"
|
|
389
506
|
end
|
|
390
507
|
|
|
508
|
+
# Log warnings (non-fatal issues)
|
|
509
|
+
warnings.each do |warning|
|
|
510
|
+
Rails.logger.warn "[Rails Error Dashboard] #{warning}" if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
511
|
+
end
|
|
512
|
+
|
|
391
513
|
# Raise exception if any errors found
|
|
392
514
|
raise ConfigurationError, errors if errors.any?
|
|
393
515
|
|
|
@@ -69,6 +69,30 @@ module RailsErrorDashboard
|
|
|
69
69
|
if RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
70
70
|
RailsErrorDashboard::Subscribers::BreadcrumbSubscriber.subscribe!
|
|
71
71
|
end
|
|
72
|
+
|
|
73
|
+
# Subscribe to Rack Attack AS::Notifications events (requires breadcrumbs + Rack::Attack)
|
|
74
|
+
if RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
|
|
75
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
76
|
+
defined?(Rack::Attack)
|
|
77
|
+
RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Enable TracePoint(:raise) for local variable and/or instance variable capture
|
|
81
|
+
if RailsErrorDashboard.configuration.enable_local_variables ||
|
|
82
|
+
RailsErrorDashboard.configuration.enable_instance_variables
|
|
83
|
+
RailsErrorDashboard::Services::LocalVariableCapturer.enable!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Enable TracePoint(:raise) + TracePoint(:rescue) for swallowed exception detection
|
|
87
|
+
if RailsErrorDashboard.configuration.detect_swallowed_exceptions
|
|
88
|
+
RailsErrorDashboard::Services::SwallowedExceptionTracker.enable!
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Import crash files from previous process death, then register at_exit hook
|
|
92
|
+
if RailsErrorDashboard.configuration.enable_crash_capture
|
|
93
|
+
RailsErrorDashboard::Services::CrashCapture.import!
|
|
94
|
+
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
95
|
+
end
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
98
|
end
|
|
@@ -309,37 +309,58 @@ module RailsErrorDashboard
|
|
|
309
309
|
def top_errors_by_impact
|
|
310
310
|
base_scope.where("occurred_at >= ?", 7.days.ago)
|
|
311
311
|
.group(:error_type, :id)
|
|
312
|
-
.select("error_type, id, occurrence_count,
|
|
312
|
+
.select("error_type, id, message, occurred_at, occurrence_count,
|
|
313
313
|
COUNT(DISTINCT user_id) as affected_users,
|
|
314
314
|
COUNT(DISTINCT user_id) * occurrence_count as impact_score")
|
|
315
315
|
.order("impact_score DESC")
|
|
316
316
|
.limit(6)
|
|
317
317
|
.map do |error|
|
|
318
|
-
full_error = ErrorLog.find(error.id)
|
|
319
318
|
{
|
|
320
319
|
id: error.id,
|
|
321
320
|
error_type: error.error_type,
|
|
322
|
-
message:
|
|
323
|
-
severity:
|
|
321
|
+
message: error.message&.truncate(80),
|
|
322
|
+
severity: Services::SeverityClassifier.classify(error.error_type),
|
|
324
323
|
occurrence_count: error.occurrence_count,
|
|
325
324
|
affected_users: error.affected_users.to_i,
|
|
326
325
|
impact_score: error.impact_score.to_i,
|
|
327
|
-
occurred_at:
|
|
326
|
+
occurred_at: error.occurred_at
|
|
328
327
|
}
|
|
329
328
|
end
|
|
330
329
|
end
|
|
331
330
|
|
|
332
331
|
# Calculate average resolution time (MTTR) in hours for the last 30 days
|
|
332
|
+
# Uses SQL AVG to avoid loading all resolved errors into Ruby memory
|
|
333
333
|
def average_resolution_time
|
|
334
|
-
|
|
335
|
-
return nil
|
|
334
|
+
scope = base_scope.resolved.where("resolved_at >= ?", 30.days.ago)
|
|
335
|
+
return nil unless scope.exists?
|
|
336
336
|
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
avg_seconds = scope.pick(Arel.sql(avg_seconds_sql))
|
|
338
|
+
return nil unless avg_seconds
|
|
339
|
+
|
|
340
|
+
(avg_seconds.to_f / 3600.0).round(2)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def avg_seconds_sql
|
|
344
|
+
case db_adapter
|
|
345
|
+
when :postgresql
|
|
346
|
+
"AVG(EXTRACT(EPOCH FROM (resolved_at - occurred_at)))"
|
|
347
|
+
when :mysql
|
|
348
|
+
"AVG(TIMESTAMPDIFF(SECOND, occurred_at, resolved_at))"
|
|
349
|
+
else
|
|
350
|
+
# SQLite: julianday difference * 86400 gives seconds
|
|
351
|
+
"AVG((julianday(resolved_at) - julianday(occurred_at)) * 86400)"
|
|
339
352
|
end
|
|
353
|
+
end
|
|
340
354
|
|
|
341
|
-
|
|
342
|
-
|
|
355
|
+
def db_adapter
|
|
356
|
+
adapter = ErrorLog.connection.adapter_name.downcase
|
|
357
|
+
if adapter.include?("postgresql")
|
|
358
|
+
:postgresql
|
|
359
|
+
elsif adapter.include?("mysql") || adapter.include?("trilogy")
|
|
360
|
+
:mysql
|
|
361
|
+
else
|
|
362
|
+
:sqlite
|
|
363
|
+
end
|
|
343
364
|
end
|
|
344
365
|
end
|
|
345
366
|
end
|
|
@@ -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
|