rails_error_dashboard 0.3.1 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -861
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +89 -0
  4. data/app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb +32 -0
  5. data/app/models/rails_error_dashboard/diagnostic_dump.rb +14 -0
  6. data/app/models/rails_error_dashboard/swallowed_exception.rb +38 -0
  7. data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
  8. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +55 -0
  9. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +46 -0
  10. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
  11. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
  12. data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
  13. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
  14. data/config/routes.rb +4 -0
  15. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
  16. data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
  17. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
  18. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
  19. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
  20. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  21. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
  22. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
  23. data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
  24. data/lib/rails_error_dashboard/configuration.rb +122 -0
  25. data/lib/rails_error_dashboard/engine.rb +24 -0
  26. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
  27. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
  28. data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
  29. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
  30. data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
  31. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
  32. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
  33. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
  34. data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
  35. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
  36. data/lib/rails_error_dashboard/version.rb +1 -1
  37. data/lib/rails_error_dashboard.rb +9 -0
  38. data/lib/tasks/error_dashboard.rake +34 -0
  39. metadata +23 -2
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tmpdir"
5
+ require "timeout"
6
+
7
+ module RailsErrorDashboard
8
+ module Services
9
+ # Last-resort crash capture via Ruby's `at_exit` hook.
10
+ #
11
+ # When the Rails process dies from an unhandled exception, the error never
12
+ # reaches the error subscriber or middleware. This service registers an
13
+ # `at_exit` hook that captures `$!` (the fatal exception) and writes it to
14
+ # disk as JSON. On the next boot, `import!` reads crash files and creates
15
+ # ErrorLog records with severity "fatal".
16
+ #
17
+ # Safety contract:
18
+ # - Default OFF (opt-in via config.enable_crash_capture)
19
+ # - Writes to tmpfile, NOT the database (connection pool may be closed)
20
+ # - Timeout: 1 second max for file write, then give up
21
+ # - Skips clean exits (SystemExit.success?, SignalException)
22
+ # - Every operation wrapped in rescue (crash capture must never itself crash)
23
+ # - Zero runtime overhead — hook only fires during process shutdown
24
+ class CrashCapture
25
+ FILE_PREFIX = "red_crash_"
26
+
27
+ class << self
28
+ # Enable crash capture. Registers the `at_exit` hook and records boot time.
29
+ # @return [true]
30
+ def enable!
31
+ return true if enabled?
32
+
33
+ @boot_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ @enabled = true
35
+
36
+ at_exit { capture!($!) }
37
+
38
+ true
39
+ end
40
+
41
+ # Disable crash capture. The `at_exit` hook remains registered but will
42
+ # no-op because `@enabled` is false.
43
+ def disable!
44
+ @enabled = false
45
+ end
46
+
47
+ # @return [Boolean] whether crash capture is enabled
48
+ def enabled?
49
+ @enabled == true
50
+ end
51
+
52
+ # Capture a fatal exception to disk. Called from the `at_exit` hook.
53
+ # @param exception [Exception, nil] the fatal exception ($!)
54
+ def capture!(exception)
55
+ return unless @enabled
56
+ return unless exception
57
+ return if exception.is_a?(SystemExit) && exception.success?
58
+ return if exception.is_a?(SignalException)
59
+
60
+ crash_data = build_crash_data(exception)
61
+ path = crash_file_path
62
+
63
+ Timeout.timeout(1) do
64
+ FileUtils.mkdir_p(File.dirname(path))
65
+ File.write(path, JSON.generate(crash_data))
66
+ end
67
+ rescue => e
68
+ # Crash capture must NEVER itself crash the exit.
69
+ # Best-effort stderr warning (may not be visible).
70
+ $stderr.puts "[RailsErrorDashboard] CrashCapture.capture! failed: #{e.class} - #{e.message}" rescue nil
71
+ end
72
+
73
+ # Import crash files from disk into the database. Called during boot
74
+ # (config.after_initialize) BEFORE enable! so old crashes are processed first.
75
+ def import!
76
+ dir = crash_capture_dir
77
+ return unless Dir.exist?(dir)
78
+
79
+ pattern = File.join(dir, "#{FILE_PREFIX}*.json")
80
+ Dir.glob(pattern).each do |file|
81
+ import_crash_file(file)
82
+ end
83
+ rescue => e
84
+ RailsErrorDashboard::Logger.debug(
85
+ "[RailsErrorDashboard] CrashCapture.import! failed: #{e.class} - #{e.message}"
86
+ )
87
+ end
88
+
89
+ # Reset internal state (for testing)
90
+ def reset!
91
+ @enabled = false
92
+ @boot_time = nil
93
+ end
94
+
95
+ private
96
+
97
+ def build_crash_data(exception)
98
+ data = {
99
+ exception_class: exception.class.name,
100
+ message: exception.message.to_s[0, 10_000],
101
+ backtrace: exception.backtrace&.first(50),
102
+ timestamp: Time.now.utc.iso8601,
103
+ pid: Process.pid,
104
+ ruby_version: RUBY_VERSION,
105
+ thread_count: Thread.list.count
106
+ }
107
+
108
+ # Rails version (may not be available during crash)
109
+ data[:rails_version] = Rails.version if defined?(Rails) && Rails.respond_to?(:version)
110
+
111
+ # Uptime
112
+ if @boot_time
113
+ data[:uptime_seconds] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @boot_time).round(1)
114
+ end
115
+
116
+ # GC stats (safe, read-only, <1ms)
117
+ data[:gc] = GC.stat rescue nil
118
+
119
+ # Cause chain (up to 5 causes)
120
+ data[:cause_chain] = extract_cause_chain(exception)
121
+
122
+ data
123
+ end
124
+
125
+ def extract_cause_chain(exception)
126
+ causes = []
127
+ current = exception.cause
128
+ 5.times do
129
+ break unless current
130
+ causes << {
131
+ exception_class: current.class.name,
132
+ message: current.message.to_s[0, 2_000]
133
+ }
134
+ current = current.cause
135
+ end
136
+ causes
137
+ end
138
+
139
+ def crash_file_path
140
+ File.join(crash_capture_dir, "#{FILE_PREFIX}#{Process.pid}.json")
141
+ end
142
+
143
+ def crash_capture_dir
144
+ RailsErrorDashboard.configuration.crash_capture_path || Dir.tmpdir
145
+ end
146
+
147
+ def import_crash_file(file)
148
+ raw = File.read(file)
149
+ data = JSON.parse(raw)
150
+
151
+ # Build attributes for ErrorLog
152
+ backtrace = data["backtrace"]
153
+ backtrace_text = backtrace.is_a?(Array) ? backtrace.join("\n") : backtrace.to_s
154
+
155
+ cause_chain = data["cause_chain"]
156
+ cause_json = cause_chain.is_a?(Array) && cause_chain.any? ? cause_chain.to_json : nil
157
+
158
+ # Build environment_info from crash metadata
159
+ env_info = {
160
+ ruby_version: data["ruby_version"],
161
+ rails_version: data["rails_version"],
162
+ pid: data["pid"],
163
+ thread_count: data["thread_count"],
164
+ uptime_seconds: data["uptime_seconds"],
165
+ gc: data["gc"],
166
+ crash_captured_at: data["timestamp"],
167
+ source: "crash_capture"
168
+ }.compact
169
+
170
+ # Resolve application (same as LogError does)
171
+ app_name = RailsErrorDashboard.configuration.application_name ||
172
+ (defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "Unknown")
173
+ application = Commands::FindOrCreateApplication.call(app_name)
174
+
175
+ occurred_at = parse_timestamp(data["timestamp"])
176
+
177
+ attributes = {
178
+ application_id: application.id,
179
+ error_type: data["exception_class"] || "UnknownCrash",
180
+ message: data["message"] || "Process crash captured via at_exit hook",
181
+ backtrace: backtrace_text,
182
+ occurred_at: occurred_at,
183
+ platform: "crash_capture",
184
+ resolved: false
185
+ }
186
+
187
+ # Add optional columns if they exist on the model
188
+ if ErrorLog.column_names.include?("environment_info")
189
+ attributes[:environment_info] = env_info.to_json
190
+ end
191
+
192
+ if ErrorLog.column_names.include?("exception_cause") && cause_json
193
+ attributes[:exception_cause] = cause_json
194
+ end
195
+
196
+ if ErrorLog.column_names.include?("error_hash")
197
+ attributes[:error_hash] = Services::ErrorHashGenerator.from_attributes(
198
+ error_type: attributes[:error_type],
199
+ message: attributes[:message],
200
+ backtrace: backtrace_text,
201
+ application_id: application.id
202
+ )
203
+ end
204
+
205
+ if ErrorLog.column_names.include?("first_seen_at")
206
+ attributes[:first_seen_at] = occurred_at
207
+ end
208
+
209
+ if ErrorLog.column_names.include?("last_seen_at")
210
+ attributes[:last_seen_at] = occurred_at
211
+ end
212
+
213
+ ErrorLog.create!(attributes)
214
+
215
+ # Delete file after successful import
216
+ File.delete(file)
217
+ rescue => e
218
+ RailsErrorDashboard::Logger.debug(
219
+ "[RailsErrorDashboard] CrashCapture.import_crash_file failed for #{file}: #{e.class} - #{e.message}"
220
+ )
221
+ # Rename to .failed to prevent infinite reimport while preserving data for debugging
222
+ File.rename(file, "#{file}.failed") rescue nil
223
+ end
224
+
225
+ def parse_timestamp(ts)
226
+ return Time.current unless ts
227
+ Time.parse(ts).utc
228
+ rescue
229
+ Time.current
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: Capture on-demand diagnostic snapshot of current system state
6
+ #
7
+ # Aggregates data from existing services (zero duplication):
8
+ # - SystemHealthSnapshot: GC, memory, threads, connection pool, Puma, job queue
9
+ # - EnvironmentSnapshot: Ruby/Rails versions, gems, server, DB adapter
10
+ # - BreadcrumbCollector: Current thread's breadcrumb buffer (non-destructive)
11
+ #
12
+ # Additional data unique to diagnostic dumps:
13
+ # - Per-thread info (name, status, alive)
14
+ # - Full GC.stat (SystemHealthSnapshot only captures a subset)
15
+ # - ObjectSpace.count_objects (O(1) type counts — NOT each_object)
16
+ # - Process uptime
17
+ #
18
+ # SAFETY RULES (HOST_APP_SAFETY.md):
19
+ # - Every section individually wrapped in rescue => nil
20
+ # - Never raises — returns partial dump on error
21
+ # - No ObjectSpace.each_object (banned rule #8)
22
+ # - No Thread.list.map(&:backtrace) (GVL hold)
23
+ # - No Signal.trap (banned rule #9)
24
+ class DiagnosticDumpGenerator
25
+ def self.call
26
+ new.call
27
+ end
28
+
29
+ def call
30
+ {
31
+ captured_at: Time.current.iso8601,
32
+ pid: Process.pid,
33
+ uptime_seconds: process_uptime,
34
+ environment: environment_info,
35
+ system_health: system_health,
36
+ breadcrumbs: breadcrumbs,
37
+ threads: thread_info,
38
+ gc: gc_info,
39
+ object_counts: object_counts
40
+ }
41
+ rescue => e
42
+ { captured_at: Time.current.iso8601, error: e.message }
43
+ end
44
+
45
+ private
46
+
47
+ def process_uptime
48
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - PROCESS_START_TIME
49
+ rescue => e
50
+ nil
51
+ end
52
+
53
+ def environment_info
54
+ EnvironmentSnapshot.snapshot.dup
55
+ rescue => e
56
+ nil
57
+ end
58
+
59
+ def system_health
60
+ SystemHealthSnapshot.capture
61
+ rescue => e
62
+ nil
63
+ end
64
+
65
+ def breadcrumbs
66
+ return [] unless RailsErrorDashboard.configuration.enable_breadcrumbs
67
+ BreadcrumbCollector.current_breadcrumbs
68
+ rescue => e
69
+ []
70
+ end
71
+
72
+ def thread_info
73
+ Thread.list.map do |t|
74
+ { name: t.name, status: t.status, alive: t.alive? }
75
+ rescue => e
76
+ { name: nil, status: "unknown", alive: false }
77
+ end
78
+ rescue => e
79
+ nil
80
+ end
81
+
82
+ def gc_info
83
+ GC.stat
84
+ rescue => e
85
+ nil
86
+ end
87
+
88
+ def object_counts
89
+ ObjectSpace.count_objects
90
+ rescue => e
91
+ nil
92
+ end
93
+
94
+ PROCESS_START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
95
+ private_constant :PROCESS_START_TIME
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # TracePoint lifecycle manager for capturing local variables and instance
6
+ # variables at raise time.
7
+ #
8
+ # Uses TracePoint(:raise) which only fires when exceptions are raised (rare
9
+ # in normal request flow). Sentry ships this in production (proven safe).
10
+ #
11
+ # Safety contract:
12
+ # - Default OFF (opt-in via config.enable_local_variables / enable_instance_variables)
13
+ # - Never stores Binding objects or object references — extracts vars immediately in callback
14
+ # - Every callback wrapped in rescue => e (never raises)
15
+ # - Per-variable rescue in extraction
16
+ # - Skips SystemExit, SignalException, Interrupt
17
+ # - Skips non-app-code paths (gems, vendor, stdlib, this gem)
18
+ # - Re-raise guard: skips if exception already has @_red_locals / @_red_instance_vars
19
+ class LocalVariableCapturer
20
+ # Instance variable names used to attach captured data to the exception
21
+ LOCALS_IVAR = :@_red_locals
22
+ INSTANCE_VARS_IVAR = :@_red_instance_vars
23
+
24
+ class << self
25
+ # Enable the TracePoint(:raise) hook globally
26
+ def enable!
27
+ return if enabled?
28
+
29
+ @tracepoint = TracePoint.new(:raise) do |tp|
30
+ on_raise(tp)
31
+ rescue => e
32
+ # CRITICAL: never let the callback crash the app
33
+ RailsErrorDashboard::Logger.debug(
34
+ "[RailsErrorDashboard] LocalVariableCapturer callback error: #{e.class} - #{e.message}"
35
+ )
36
+ end
37
+
38
+ @tracepoint.enable
39
+ end
40
+
41
+ # Disable the TracePoint hook
42
+ def disable!
43
+ @tracepoint&.disable
44
+ @tracepoint = nil
45
+ end
46
+
47
+ # Check if currently enabled
48
+ def enabled?
49
+ @tracepoint&.enabled? == true
50
+ end
51
+
52
+ # Extract captured locals from an exception (if any)
53
+ # @param exception [Exception] The exception to check
54
+ # @return [Hash, nil] Raw locals hash or nil
55
+ def extract(exception)
56
+ return nil unless exception.is_a?(Exception)
57
+ return nil unless exception.instance_variable_defined?(LOCALS_IVAR)
58
+
59
+ exception.instance_variable_get(LOCALS_IVAR)
60
+ rescue => e
61
+ RailsErrorDashboard::Logger.debug(
62
+ "[RailsErrorDashboard] LocalVariableCapturer.extract failed: #{e.message}"
63
+ )
64
+ nil
65
+ end
66
+
67
+ # Extract captured instance variables from an exception (if any)
68
+ # @param exception [Exception] The exception to check
69
+ # @return [Hash, nil] Raw instance vars hash or nil
70
+ def extract_instance_vars(exception)
71
+ return nil unless exception.is_a?(Exception)
72
+ return nil unless exception.instance_variable_defined?(INSTANCE_VARS_IVAR)
73
+
74
+ exception.instance_variable_get(INSTANCE_VARS_IVAR)
75
+ rescue => e
76
+ RailsErrorDashboard::Logger.debug(
77
+ "[RailsErrorDashboard] LocalVariableCapturer.extract_instance_vars failed: #{e.message}"
78
+ )
79
+ nil
80
+ end
81
+
82
+ private
83
+
84
+ # TracePoint callback — runs on every :raise event
85
+ # Filter chain ordered cheapest-first for minimal overhead
86
+ def on_raise(tp)
87
+ exception = tp.raised_exception
88
+ config = RailsErrorDashboard.configuration
89
+
90
+ # 1. Re-raise guard: skip if already captured both
91
+ locals_captured = exception.instance_variable_defined?(LOCALS_IVAR)
92
+ ivars_captured = exception.instance_variable_defined?(INSTANCE_VARS_IVAR)
93
+ return if locals_captured && ivars_captured
94
+
95
+ # 2. Skip system/signal exceptions
96
+ return if exception.is_a?(SystemExit) || exception.is_a?(SignalException) || exception.is_a?(Interrupt)
97
+
98
+ # 3. Skip common flow-control exceptions (avoid expensive tp.binding call)
99
+ return if defined?(ActionController::RoutingError) && exception.is_a?(ActionController::RoutingError)
100
+ return if defined?(ActiveRecord::RecordNotFound) && exception.is_a?(ActiveRecord::RecordNotFound)
101
+ return if defined?(ActionController::UnknownFormat) && exception.is_a?(ActionController::UnknownFormat)
102
+
103
+ # 4. Skip non-app-code paths
104
+ path = tp.path.to_s
105
+ return if skip_path?(path)
106
+
107
+ # 5. Extract local variables from the binding (if enabled and not already captured)
108
+ if config.enable_local_variables && !locals_captured
109
+ locals = extract_locals(tp.binding)
110
+ exception.instance_variable_set(LOCALS_IVAR, locals) if locals && locals.any?
111
+ end
112
+
113
+ # 6. Extract instance variables from tp.self (if enabled and not already captured)
114
+ if config.enable_instance_variables && !ivars_captured
115
+ ivars = capture_instance_vars(tp.self)
116
+ exception.instance_variable_set(INSTANCE_VARS_IVAR, ivars) if ivars && ivars.any?
117
+ end
118
+ end
119
+
120
+ # Check if the path should be skipped (gem code, vendor, stdlib, this gem)
121
+ def skip_path?(path)
122
+ path.include?("/gems/") ||
123
+ path.include?("/vendor/") ||
124
+ path.include?("/ruby/") ||
125
+ path.include?("rails_error_dashboard") ||
126
+ path.start_with?("<") || # Ruby 3.3+: <eval>, <irb>, etc.
127
+ path.start_with?("(") # Ruby 3.2: (eval), (irb), etc.
128
+ end
129
+
130
+ # Extract local variables from a binding (per-variable rescue)
131
+ # @param binding_obj [Binding] The binding at raise time
132
+ # @return [Hash] { variable_name_symbol => raw_value }
133
+ def extract_locals(binding_obj)
134
+ return nil unless binding_obj
135
+
136
+ var_names = binding_obj.local_variables
137
+ return nil if var_names.empty?
138
+
139
+ # Respect max count limit at extraction time (reduces memory on exception object)
140
+ max_count = RailsErrorDashboard.configuration.local_variable_max_count || 15
141
+ var_names = var_names.first(max_count)
142
+
143
+ locals = {}
144
+ var_names.each do |name|
145
+ locals[name] = binding_obj.local_variable_get(name)
146
+ rescue => e
147
+ locals[name] = "(extraction error: #{e.class.name})"
148
+ end
149
+
150
+ locals
151
+ rescue => e
152
+ RailsErrorDashboard::Logger.debug(
153
+ "[RailsErrorDashboard] extract_locals failed: #{e.message}"
154
+ )
155
+ nil
156
+ end
157
+
158
+ # Extract instance variables from the receiver object (tp.self)
159
+ # Never stores the object reference — extracts values immediately.
160
+ # @param obj [Object] The receiver where the exception was raised
161
+ # @return [Hash, nil] { :_self_class => class_name, :@ivar_name => raw_value }
162
+ def capture_instance_vars(obj)
163
+ return nil if obj.nil?
164
+
165
+ config = RailsErrorDashboard.configuration
166
+ max_count = config.instance_variable_max_count || 20
167
+
168
+ # Get instance variable names (safe — instance_variables is always available)
169
+ ivar_names = obj.instance_variables
170
+ return nil if ivar_names.empty?
171
+
172
+ # Filter out internal ivars:
173
+ # - @_red_* — our own gem ivars (e.g. @_red_locals, @_red_instance_vars)
174
+ # - @_* — Rails framework internals (e.g. @_request, @_response, @_action_name)
175
+ ivar_names = ivar_names.reject { |name| name.to_s.start_with?("@_") }
176
+ return nil if ivar_names.empty?
177
+
178
+ # Respect max count limit
179
+ ivar_names = ivar_names.first(max_count)
180
+
181
+ result = {}
182
+
183
+ # Add metadata: class name of the receiver
184
+ result[:_self_class] = begin
185
+ obj.class.name || obj.class.to_s
186
+ rescue
187
+ "Unknown"
188
+ end
189
+
190
+ # Extract each instance variable (per-variable rescue)
191
+ ivar_names.each do |name|
192
+ result[name] = obj.instance_variable_get(name)
193
+ rescue => e
194
+ result[name] = "(extraction error: #{e.class.name})"
195
+ end
196
+
197
+ result
198
+ rescue => e
199
+ RailsErrorDashboard::Logger.debug(
200
+ "[RailsErrorDashboard] capture_instance_vars failed: #{e.message}"
201
+ )
202
+ nil
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end