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.
- checksums.yaml +4 -4
- data/README.md +160 -861
- data/app/controllers/rails_error_dashboard/errors_controller.rb +89 -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 +21 -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/diagnostic_dumps.html.erb +182 -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 +4 -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/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/diagnostic_dump_generator.rb +98 -0
- data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -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 +9 -0
- data/lib/tasks/error_dashboard.rake +34 -0
- 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
|