rails_error_dashboard 0.12.1 → 0.14.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
  5. data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
  6. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  8. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
  9. data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
  10. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +122 -1
  11. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
  12. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
  13. data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
  14. data/app/models/rails_error_dashboard/error_comment.rb +0 -5
  15. data/app/models/rails_error_dashboard/error_log.rb +19 -3
  16. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  17. data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
  18. data/app/models/rails_error_dashboard/event_count.rb +132 -0
  19. data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +58 -5
  21. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
  22. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
  23. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
  24. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
  25. data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
  26. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
  28. data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
  29. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
  30. data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
  31. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  32. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
  33. data/config/locales/de.yml +30 -0
  34. data/config/locales/en.yml +37 -0
  35. data/config/locales/es.yml +30 -0
  36. data/config/locales/fr.yml +30 -0
  37. data/config/locales/it.yml +30 -0
  38. data/config/locales/ja.yml +30 -0
  39. data/config/locales/pl.yml +30 -0
  40. data/config/locales/pt-BR.yml +30 -0
  41. data/config/locales/ru.yml +30 -0
  42. data/config/locales/uk.yml +30 -0
  43. data/config/locales/zh-CN.yml +30 -0
  44. data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
  45. data/db/migrate/20260919000001_create_event_counts.rb +71 -0
  46. data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
  47. data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
  48. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
  49. data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
  50. data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
  51. data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
  52. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
  53. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
  54. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
  55. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
  56. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +109 -11
  57. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
  58. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +252 -12
  59. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
  60. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
  61. data/lib/rails_error_dashboard/commands/log_error.rb +291 -40
  62. data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
  63. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
  64. data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
  65. data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
  66. data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
  67. data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
  68. data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
  69. data/lib/rails_error_dashboard/configuration.rb +39 -1
  70. data/lib/rails_error_dashboard/engine.rb +28 -0
  71. data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
  72. data/lib/rails_error_dashboard/queries/analytics_stats.rb +89 -30
  73. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  74. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +216 -74
  75. data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
  76. data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
  77. data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
  78. data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
  79. data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
  80. data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
  81. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
  82. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
  83. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
  84. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
  85. data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
  86. data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
  87. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
  88. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
  89. data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
  90. data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
  91. data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
  92. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
  93. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
  94. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
  95. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
  96. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
  97. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
  98. data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
  99. data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
  100. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
  101. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
  102. data/lib/rails_error_dashboard/value_objects/error_context.rb +40 -3
  103. data/lib/rails_error_dashboard/version.rb +1 -1
  104. data/lib/rails_error_dashboard.rb +34 -0
  105. data/lib/tasks/error_dashboard.rake +54 -4
  106. metadata +16 -2
@@ -25,6 +25,17 @@ module RailsErrorDashboard
25
25
  RAISE_THREAD_KEY = :red_swallowed_raises
26
26
  RESCUE_THREAD_KEY = :red_swallowed_rescues
27
27
  FLUSH_THREAD_KEY = :red_swallowed_last_flush
28
+ # Monotonic time at which this thread's buffer stopped being empty. The
29
+ # flush is due one interval after THAT, whether or not anything else
30
+ # happens on the thread.
31
+ DEADLINE_THREAD_KEY = :red_swallowed_armed_at
32
+
33
+ # Where evicted counts go, so eviction bounds memory without losing the
34
+ # total. The flush command splits keys on "|" and "->", so these persist as
35
+ # ordinary rows under the class name "[overflow]".
36
+ OVERFLOW_LABEL = "[overflow]"
37
+ RAISE_OVERFLOW_KEY = "#{OVERFLOW_LABEL}|#{OVERFLOW_LABEL}".freeze
38
+ RESCUE_OVERFLOW_KEY = "#{OVERFLOW_LABEL}|#{OVERFLOW_LABEL}->#{OVERFLOW_LABEL}".freeze
28
39
  RAISE_LOC_IVAR = :@_red_raise_loc
29
40
 
30
41
  # Flow-control exceptions that are commonly raised/rescued in normal Rails operation.
@@ -95,7 +106,7 @@ module RailsErrorDashboard
95
106
  end
96
107
 
97
108
  # Force flush the current thread's counters (used by job and tests)
98
- def flush!
109
+ def flush!(sync: false)
99
110
  raises = Thread.current[RAISE_THREAD_KEY]
100
111
  rescues = Thread.current[RESCUE_THREAD_KEY]
101
112
  return if raises.nil? && rescues.nil?
@@ -107,14 +118,42 @@ module RailsErrorDashboard
107
118
  raises&.clear
108
119
  rescues&.clear
109
120
  Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f
121
+ Thread.current[DEADLINE_THREAD_KEY] = nil
110
122
 
111
- dispatch_flush(raise_snapshot, rescue_snapshot)
123
+ dispatch_flush(raise_snapshot, rescue_snapshot, sync: sync)
112
124
  rescue => e
113
125
  RailsErrorDashboard::Logger.debug(
114
126
  "[RailsErrorDashboard] SwallowedExceptionTracker.flush! failed: #{e.class} - #{e.message}"
115
127
  )
116
128
  end
117
129
 
130
+ # Drain this thread's buffer at the end of a unit of work (a request or a
131
+ # job) once it has been waiting for flush_interval.
132
+ #
133
+ # Without this the ONLY in-process drain was maybe_flush! inside the
134
+ # :rescue callback, so a buffer could only ever be flushed by a LATER
135
+ # rescue on the SAME thread: a swallowed exception that happened once
136
+ # stayed invisible until the process exited, and counts on a Puma thread
137
+ # that retired were lost. Same defect, same fix, as
138
+ # RackAttackTracker#flush_if_due!.
139
+ #
140
+ # Wired to Rails.application.executor.to_complete, which fires after the
141
+ # response body is closed, so this never delays a request. Deadline-gated:
142
+ # the usual cost is one thread-local read.
143
+ def flush_if_due!
144
+ return unless flush_due?
145
+
146
+ # sync: the response is already sent, and one upsert per interval is
147
+ # cheaper than enqueueing a job to do it.
148
+ flush!(sync: true)
149
+ nil
150
+ rescue => e
151
+ RailsErrorDashboard::Logger.debug(
152
+ "[RailsErrorDashboard] SwallowedExceptionTracker.flush_if_due! failed: #{e.class} - #{e.message}"
153
+ )
154
+ nil
155
+ end
156
+
118
157
  # Read current thread's counters (for testing/inspection)
119
158
  def current_raises
120
159
  Thread.current[RAISE_THREAD_KEY] || {}
@@ -129,6 +168,7 @@ module RailsErrorDashboard
129
168
  Thread.current[RAISE_THREAD_KEY] = nil
130
169
  Thread.current[RESCUE_THREAD_KEY] = nil
131
170
  Thread.current[FLUSH_THREAD_KEY] = nil
171
+ Thread.current[DEADLINE_THREAD_KEY] = nil
132
172
  end
133
173
 
134
174
  private
@@ -152,11 +192,9 @@ module RailsErrorDashboard
152
192
  class_name = exception.class.name || exception.class.to_s
153
193
  key = "#{class_name}|#{location}"
154
194
 
155
- raises = (Thread.current[RAISE_THREAD_KEY] ||= {})
156
- raises[key] = (raises[key] || 0) + 1
157
-
158
- # 5. LRU eviction if over capacity
159
- evict_oldest!(raises) if raises.size > max_cache_size
195
+ # 4b/5. Count it, most-recent-last, evicting into the overflow bucket
196
+ increment!((Thread.current[RAISE_THREAD_KEY] ||= {}), key, RAISE_OVERFLOW_KEY)
197
+ arm_deadline!
160
198
  end
161
199
 
162
200
  # TracePoint(:rescue) callback
@@ -181,11 +219,9 @@ module RailsErrorDashboard
181
219
  class_name = exception.class.name || exception.class.to_s
182
220
  key = "#{class_name}|#{raise_loc}->#{rescue_loc}"
183
221
 
184
- rescues = (Thread.current[RESCUE_THREAD_KEY] ||= {})
185
- rescues[key] = (rescues[key] || 0) + 1
186
-
187
- # 5. LRU eviction if over capacity
188
- evict_oldest!(rescues) if rescues.size > max_cache_size
222
+ # 4b/5. Count it, most-recent-last, evicting into the overflow bucket
223
+ increment!((Thread.current[RESCUE_THREAD_KEY] ||= {}), key, RESCUE_OVERFLOW_KEY)
224
+ arm_deadline!
189
225
 
190
226
  # 6. Maybe flush
191
227
  maybe_flush!
@@ -210,21 +246,60 @@ module RailsErrorDashboard
210
246
  false
211
247
  end
212
248
 
213
- # LRU eviction: delete the oldest key (Ruby hashes maintain insertion order)
214
- def evict_oldest!(hash)
215
- oldest_key = hash.each_key.first
216
- hash.delete(oldest_key) if oldest_key
249
+ # Count one event. delete-and-reinsert moves the key to the END of the
250
+ # Hash, so insertion order IS recency order and "oldest" below means least
251
+ # recently UPDATED. A plain `hash[key] += 1` leaves a key where it was
252
+ # first inserted, which made the busiest key in the process the first one
253
+ # evicted.
254
+ def increment!(hash, key, overflow_key)
255
+ hash[key] = hash.delete(key).to_i + 1
256
+
257
+ # Loops because the overflow bucket takes a slot of its own once it
258
+ # exists. evict_oldest! returns false when only that bucket is left,
259
+ # which terminates the loop even with max_cache_size misconfigured to 0.
260
+ while hash.size > max_cache_size
261
+ break unless evict_oldest!(hash, overflow_key)
262
+ end
217
263
  end
218
264
 
219
- # Cheap periodic flush check
220
- def maybe_flush!
221
- now = Time.now.to_f
222
- last_flush = Thread.current[FLUSH_THREAD_KEY] ||= now
223
- interval = RailsErrorDashboard.configuration.swallowed_exception_flush_interval
265
+ # Evict the least recently updated key, folding its count into the
266
+ # overflow bucket so the total is conserved. The overflow bucket itself is
267
+ # never the victim. Mirrors RackAttackTracker#evict_oldest!.
268
+ # @return [Boolean] false once only the overflow bucket remains
269
+ def evict_oldest!(hash, overflow_key)
270
+ oldest_key = hash.each_key.find { |k| k != overflow_key }
271
+ return false unless oldest_key
224
272
 
225
- return unless (now - last_flush) >= interval
273
+ dropped = hash.delete(oldest_key).to_i
274
+ hash[overflow_key] = hash[overflow_key].to_i + dropped if dropped.positive?
275
+ true
276
+ end
226
277
 
227
- flush!
278
+ # Start the flush clock when the buffer becomes non-empty; a later event
279
+ # never pushes it out. The old guard (`last_flush ||= now`) was first
280
+ # evaluated by the SECOND event on a thread, so a lone event had no
281
+ # deadline at all.
282
+ def arm_deadline!
283
+ Thread.current[DEADLINE_THREAD_KEY] ||= monotonic_now
284
+ end
285
+
286
+ # Monotonic: Time.now can jump backwards and would defer the flush.
287
+ def flush_due?
288
+ armed_at = Thread.current[DEADLINE_THREAD_KEY]
289
+ return false if armed_at.nil?
290
+
291
+ (monotonic_now - armed_at) >= RailsErrorDashboard.configuration.swallowed_exception_flush_interval.to_f
292
+ rescue => e
293
+ false
294
+ end
295
+
296
+ def monotonic_now
297
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
298
+ end
299
+
300
+ # Cheap periodic flush check on the :rescue path
301
+ def maybe_flush!
302
+ flush! if flush_due?
228
303
  end
229
304
 
230
305
  # Dispatch flush asynchronously via background job (zero I/O in request path).
@@ -263,6 +338,7 @@ module RailsErrorDashboard
263
338
  thread[RAISE_THREAD_KEY] = nil
264
339
  thread[RESCUE_THREAD_KEY] = nil
265
340
  thread[FLUSH_THREAD_KEY] = nil
341
+ thread[DEADLINE_THREAD_KEY] = nil
266
342
 
267
343
  dispatch_flush(raise_snapshot, rescue_snapshot, sync: true)
268
344
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module RailsErrorDashboard
6
+ module Services
7
+ # Pure algorithm: decide whether a string is safe to use as a link target
8
+ #
9
+ # A URL that ends up in an `href` must be absolute http(s). Rails' `link_to`
10
+ # and ERB escaping stop attribute breakout, but neither rejects a scheme such
11
+ # as `javascript:` — that has to be an allowlist, and it has to be the same
12
+ # rule where the value is written and where it is rendered.
13
+ #
14
+ # The value is judged exactly as given (no stripping), so what passes here is
15
+ # byte-for-byte what the caller goes on to store or render.
16
+ #
17
+ # @example
18
+ # UrlSafety.http_url?("https://github.com/a/b/issues/1") # => true
19
+ # UrlSafety.http_url?("javascript:alert(1)") # => false
20
+ class UrlSafety
21
+ # Browsers strip TAB/CR/LF from inside a URL before resolving the scheme,
22
+ # so "java\tscript:" is live. No legitimate URL contains these or a space.
23
+ UNSAFE_CHARACTERS = /[[:cntrl:][:space:]]/
24
+
25
+ # @param value [String, nil]
26
+ # @return [Boolean] true only for an absolute http:// or https:// URL with a host
27
+ def self.http_url?(value)
28
+ return false unless value.is_a?(String)
29
+ return false if value.empty? || value.match?(UNSAFE_CHARACTERS)
30
+
31
+ uri = URI.parse(value)
32
+ # URI::HTTPS is a subclass of URI::HTTP
33
+ uri.is_a?(URI::HTTP) && uri.host.present?
34
+ rescue URI::InvalidURIError, ArgumentError, EncodingError
35
+ # ArgumentError/EncodingError: a string with invalid bytes cannot be matched
36
+ false
37
+ end
38
+ end
39
+ end
40
+ end
@@ -175,19 +175,109 @@ module RailsErrorDashboard
175
175
  return { value: label, truncated: false }
176
176
  end
177
177
 
178
- # Fallback: .inspect with truncation
178
+ # #inspect on an unknown object is arbitrary APPLICATION code, running
179
+ # on the failure path. Truncating its output bounds what is STORED,
180
+ # not what it COSTS: an inspect that sleeps or builds a megabyte pays
181
+ # that in full before a single character is discarded. So the default
182
+ # is a safe structural summary, and inspect runs only for types the
183
+ # host app opted in to -- under a wall-clock budget even then.
179
184
  max_len = config.local_variable_max_string_length || 200
185
+
186
+ # A Struct is serialized MEMBER-WISE, never through its own #inspect.
187
+ #
188
+ # Struct was allowlisted because it prints its attributes cheaply --
189
+ # true of the container, false of what it holds. Struct#inspect calls
190
+ # each member's #inspect, so a Struct wrapping an unknown object ran
191
+ # that object's arbitrary code in full. Walking the members instead
192
+ # gives every one of them the same safe-summary default an unknown
193
+ # object already gets, so the guarantee holds by construction rather
194
+ # than by measuring afterwards.
195
+ return serialize_struct(value, config, depth, max_depth) if struct?(value)
196
+
197
+ return { value: safe_summary(value), truncated: false } unless inspectable?(value, config)
198
+
199
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
180
200
  inspected = value.inspect
201
+ elapsed_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
202
+
203
+ # An OUTPUT-selection threshold, not an execution budget: the inspect
204
+ # above has already run to completion by the time this is measured.
205
+ # Only reachable for a type the host app explicitly opted in to, and
206
+ # that opt-in is documented as accepting unbounded execution -- the
207
+ # only way to interrupt arbitrary Ruby mid-call is Timeout, which is
208
+ # not safe on the capture path (safety rule 1).
209
+ budget = config.local_variable_inspect_budget_ms || 5
210
+ if elapsed_ms > budget
211
+ RailsErrorDashboard::Logger.debug(
212
+ "[RailsErrorDashboard] #{value.class}#inspect took #{elapsed_ms.round(1)}ms " \
213
+ "(budget #{budget}ms) — storing a summary instead"
214
+ )
215
+ return { value: safe_summary(value), truncated: true }
216
+ end
217
+
181
218
  if inspected.length > max_len
182
219
  { value: inspected[0, max_len], truncated: true }
183
220
  else
184
221
  { value: inspected, truncated: false }
185
222
  end
186
223
  rescue
187
- { value: "#<#{value.class.name rescue "Object"}>", truncated: false }
224
+ { value: safe_summary(value), truncated: false }
188
225
  end
189
226
  private_class_method :serialize_object
190
227
 
228
+ def self.struct?(value)
229
+ value.is_a?(Struct)
230
+ rescue StandardError
231
+ false
232
+ end
233
+ private_class_method :struct?
234
+
235
+ # Serialize a Struct's members through the ordinary bounded path.
236
+ #
237
+ # Bounded twice over: member count is capped, and each member recurses
238
+ # with depth + 1, so a Struct of Structs cannot reintroduce unbounded
239
+ # work through recursion instead of through #inspect.
240
+ def self.serialize_struct(value, config, depth, max_depth)
241
+ max_members = config.local_variable_max_array_items || 10
242
+ members = value.members.first(max_members)
243
+ truncated = value.members.size > members.size
244
+
245
+ pairs = members.map do |member|
246
+ serialized = serialize_value(value[member], config, depth + 1, max_depth)
247
+ truncated ||= serialized[:truncated]
248
+ "#{member}=#{serialized[:value]}"
249
+ end
250
+
251
+ # An anonymous Struct has no class name; label it by shape rather than
252
+ # rendering "#<struct a=1>" with a hole in it.
253
+ label = value.class.name.presence || "struct"
254
+ { value: "#<#{label} #{pairs.join(', ')}>", truncated: truncated }
255
+ rescue StandardError
256
+ { value: safe_summary(value), truncated: false }
257
+ end
258
+ private_class_method :serialize_struct
259
+
260
+ # What an object is, without asking the object. Costs one class-name read.
261
+ def self.safe_summary(value)
262
+ "#<#{value.class.name}>"
263
+ rescue StandardError
264
+ "#<Object>"
265
+ end
266
+ private_class_method :safe_summary
267
+
268
+ # True when this object's class (or an ancestor) is on the allowlist, so
269
+ # the host app has accepted the cost of its #inspect.
270
+ def self.inspectable?(value, config)
271
+ allowlist = Array(config.local_variable_inspect_allowlist)
272
+ return false if allowlist.empty?
273
+
274
+ ancestors = value.class.ancestors.map { |mod| mod.name }.compact
275
+ (ancestors & allowlist).any?
276
+ rescue StandardError
277
+ false
278
+ end
279
+ private_class_method :inspectable?
280
+
191
281
  # --- Sensitive data filtering (post-serialization) ---
192
282
  # Reuses SensitiveDataFilter.parameter_filter — same pattern as BreadcrumbCollector.
193
283
  # Applied AFTER serialization so ParameterFilter works on clean JSON-compatible values.
@@ -215,14 +305,39 @@ module RailsErrorDashboard
215
305
  info[:value] = SensitiveDataFilter.send(:filter_message, filter, info[:value])
216
306
  end
217
307
 
218
- # Filter nested hash keys recursively
219
- if info[:value].is_a?(Hash)
220
- info[:value] = filter_hash_recursive(filter, info[:value])
221
- end
222
-
223
- # Filter nested array items
224
- if info[:value].is_a?(Array)
225
- info[:value] = filter_array_recursive(filter, info[:value])
308
+ # Path-aware filtering, in ONE call for every container type.
309
+ #
310
+ # The value is wrapped back under its own variable name so the filter
311
+ # sees the SAME key path Rails sees for request params. A dotted
312
+ # pattern like "profile.private_note" is a path, not a name: dropping
313
+ # the "profile" segment means the value Rails redacts in params stays
314
+ # readable here. Unwrapping afterwards leaves the stored shape
315
+ # unchanged.
316
+ #
317
+ # This must NOT ask "what shape is this?" first. Wrapping only Hashes
318
+ # and sending Arrays straight to the recursive walker is exactly how
319
+ # `profile = [{ private_note: ... }]` leaked while the identical
320
+ # request params were redacted: ParameterFilter already traverses
321
+ # arbitrary nesting of Hash and Array, so one wrap covers every shape
322
+ # and a new container type cannot reintroduce the gap.
323
+ #
324
+ # Parity with Rails is the contract in both directions -- a pattern
325
+ # that Rails does NOT match (profile.list.private_note, or a bare
326
+ # scalar) must survive here too. Over-redaction silently destroys
327
+ # data a developer needs to debug.
328
+ if info[:value].is_a?(Hash) || info[:value].is_a?(Array)
329
+ scoped = filter.filter(var_name => info[:value])[var_name]
330
+
331
+ # The recursive pass stays, and runs AFTER the path-aware filter:
332
+ # it scrubs sensitive CONTENT inside strings (credit-card and
333
+ # key=value patterns), which ParameterFilter does not do -- it only
334
+ # matches keys.
335
+ info[:value] =
336
+ case scoped
337
+ when Hash then filter_hash_recursive(filter, scoped)
338
+ when Array then filter_array_recursive(filter, scoped)
339
+ else scoped
340
+ end
226
341
  end
227
342
  end
228
343
 
@@ -16,6 +16,18 @@ module RailsErrorDashboard
16
16
  class BreadcrumbSubscriber
17
17
  SQL_MESSAGE_MAX = 200
18
18
 
19
+ # Where a failing job's trail waits for the reporter.
20
+ #
21
+ # ActiveJob reports a job error OUTSIDE the frame that runs
22
+ # `run_callbacks :perform`: perform_now's `rescue Exception` is one frame
23
+ # out (activejob execution.rb), and the report itself fires two frames
24
+ # further out still, from the :execute around-callback that the railtie
25
+ # registers (ExecutionWrapper.wrap's `rescue Exception` ->
26
+ # error_reporter.report). By the time LogError runs, our ensure has
27
+ # already cleared the buffer. So we snapshot on the way out and leave the
28
+ # snapshot here for LogError to pick up.
29
+ JOB_TRAIL_KEY = :rails_error_dashboard_job_breadcrumb_trail
30
+
19
31
  # Event subscriptions managed by this class
20
32
  @subscriptions = []
21
33
 
@@ -38,6 +50,105 @@ module RailsErrorDashboard
38
50
  @subscriptions
39
51
  end
40
52
 
53
+ # Open a breadcrumb buffer around every Active Job perform, so a job
54
+ # that fails outside a request still has an activity trail.
55
+ #
56
+ # Idempotent: the callback list belongs to ActiveJob::Base, and a
57
+ # second registration would open and close the buffer twice per job.
58
+ #
59
+ # The config check is INSIDE the callback, not around this method.
60
+ # enable_breadcrumbs defaults to false and the callback list is fixed
61
+ # once the class loads, so gating registration would permanently
62
+ # disable job breadcrumbs for any host that enables the feature in an
63
+ # initializer running after the engine's.
64
+ # @return [Boolean] true when the callback was installed
65
+ def install_job_buffer!
66
+ return false if @job_buffer_installed
67
+ return false unless defined?(ActiveJob::Base)
68
+
69
+ @job_buffer_installed = true
70
+ ActiveSupport.on_load(:active_job) do
71
+ around_perform do |job, block|
72
+ collector = RailsErrorDashboard::Services::BreadcrumbCollector
73
+ subscriber = RailsErrorDashboard::Subscribers::BreadcrumbSubscriber
74
+
75
+ # Drop any snapshot a previous perform on this pooled thread left
76
+ # behind. This, not the ensure below, is what bounds the leak:
77
+ # discard_on (and a host rescue_from) can swallow an exception so
78
+ # that nothing is ever reported and nothing ever consumes the
79
+ # snapshot. At most one job's serialized trail survives, and only
80
+ # until this thread's very next perform.
81
+ Thread.current[subscriber::JOB_TRAIL_KEY] = nil
82
+
83
+ owned =
84
+ if RailsErrorDashboard.configuration.enable_breadcrumbs &&
85
+ !subscriber.capture_job?(job)
86
+ collector.init_buffer_unless_present
87
+ else
88
+ false
89
+ end
90
+
91
+ completed = false
92
+ begin
93
+ block.call
94
+ completed = true
95
+ ensure
96
+ # Snapshot BEFORE clearing, and only when the job did not
97
+ # finish normally -- see JOB_TRAIL_KEY: the error is reported
98
+ # two frames outside this one, long after the clear.
99
+ #
100
+ # `completed`, not a `rescue Exception`, because retry_on and
101
+ # discard_on with `report: true` report the error and then
102
+ # return normally: no exception passes through here, yet the
103
+ # capture still needs the trail.
104
+ #
105
+ # Gated on `owned`: a job running inline inside a request must
106
+ # not copy out -- or clear -- a buffer the request owns.
107
+ if owned && !completed
108
+ begin
109
+ trail = collector.current_breadcrumbs
110
+ Thread.current[subscriber::JOB_TRAIL_KEY] = trail if trail.is_a?(Array) && trail.any?
111
+ rescue StandardError
112
+ nil # never raise from the capture path
113
+ end
114
+ end
115
+
116
+ # ensure, always: a worker pool reuses threads, and a
117
+ # thread-local left behind would leak one job's trail into the
118
+ # next (safety rule 4).
119
+ collector.clear_buffer_if_owned(owned)
120
+ end
121
+ end
122
+ end
123
+ true
124
+ rescue StandardError => e
125
+ RailsErrorDashboard::Logger.debug(
126
+ "[RailsErrorDashboard] install_job_buffer! failed: #{e.class} - #{e.message}"
127
+ )
128
+ false
129
+ end
130
+
131
+ # Is the job now performing one of the gem's OWN capture jobs?
132
+ #
133
+ # AsyncErrorLoggingJob descends from ActiveJob::Base, so it runs
134
+ # through the around_perform above like any host job. Opening a buffer
135
+ # for it collects the gem's own write traffic -- its cache reads for
136
+ # the application record, its SAVEPOINT/RELEASE SAVEPOINT pairs -- and
137
+ # the anti-recursion filter does not catch those (it is a substring
138
+ # test for "rails_error_dashboard_" against the SQL text, and
139
+ # transaction control carries no table name).
140
+ #
141
+ # Today that noise is discarded because the envelope wins in LogError.
142
+ # Once a failing job's buffer is snapshotted and stored, it would
143
+ # become the trail on the gem's own failure path. Never open one.
144
+ # @param job [ActiveJob::Base]
145
+ # @return [Boolean]
146
+ def capture_job?(job)
147
+ job.class.name.to_s.start_with?("RailsErrorDashboard::")
148
+ rescue StandardError
149
+ false
150
+ end
151
+
41
152
  # Remove all breadcrumb subscribers
42
153
  def unsubscribe!
43
154
  @subscriptions.each do |sub|
@@ -78,11 +78,20 @@ module RailsErrorDashboard
78
78
 
79
79
  if existing
80
80
  # Link this record to the existing issue instead of creating a new one
81
- error_log.update_columns(
81
+ link = {
82
82
  external_issue_url: existing.external_issue_url,
83
83
  external_issue_number: existing.external_issue_number,
84
84
  external_issue_provider: existing.external_issue_provider
85
- )
85
+ }
86
+ # The repository is half the issue's identity. Without it
87
+ # IssueTrackerClient.for_error falls back to the CONFIGURED repo,
88
+ # and every later comment/close/reopen for this row would hit
89
+ # whatever issue has that number there. Same column guard as
90
+ # LinkExistingIssue.
91
+ if ErrorLog.column_names.include?("external_issue_repo")
92
+ link[:external_issue_repo] = existing.external_issue_repo
93
+ end
94
+ error_log.update_columns(link)
86
95
  return false
87
96
  end
88
97
 
@@ -7,7 +7,8 @@ module RailsErrorDashboard
7
7
  class ErrorContext
8
8
  attr_reader :user_id, :request_url, :request_params, :user_agent, :ip_address, :platform,
9
9
  :controller_name, :action_name, :request_id, :session_id,
10
- :http_method, :hostname, :content_type, :request_duration_ms, :environment
10
+ :http_method, :hostname, :content_type, :request_duration_ms, :environment,
11
+ :occurred_at, :app_version
11
12
 
12
13
  def initialize(context, source = nil)
13
14
  @context = context
@@ -23,6 +24,8 @@ module RailsErrorDashboard
23
24
  @action_name = extract_action_name
24
25
  @request_id = extract_request_id
25
26
  @session_id = extract_session_id
27
+ @occurred_at = extract_occurred_at
28
+ @app_version = extract_app_version
26
29
  @http_method = extract_http_method
27
30
  @hostname = extract_hostname
28
31
  @content_type = extract_content_type
@@ -52,12 +55,40 @@ module RailsErrorDashboard
52
55
  hostname: hostname,
53
56
  content_type: content_type,
54
57
  request_duration_ms: request_duration_ms,
55
- environment: environment
58
+ environment: environment,
59
+ # Both belong in to_h, not only in the readers: LogError builds a
60
+ # SECOND ErrorContext from this hash on the async path, and a key
61
+ # missing here is silently dropped there. That hop is what lost
62
+ # request_id and session_id before.
63
+ occurred_at: occurred_at,
64
+ app_version: app_version
56
65
  }
57
66
  end
58
67
 
59
68
  private
60
69
 
70
+ # A caller-supplied event time, e.g. a mobile client reporting a failure
71
+ # that happened while it was offline. Never in the future: a client clock
72
+ # can be wrong, and a future row would sort above every real error and
73
+ # never age out of a window.
74
+ def extract_occurred_at
75
+ raw = @context[:occurred_at]
76
+ return nil if raw.blank?
77
+
78
+ time = raw.is_a?(String) ? Time.zone.parse(raw) : raw
79
+ return nil unless time.respond_to?(:to_time)
80
+
81
+ [ time, Time.current ].min
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ # The release the REPORTER was running, which for a mobile or frontend
87
+ # report is the whole point -- it differs from the server's version.
88
+ def extract_app_version
89
+ @context[:app_version].presence
90
+ end
91
+
61
92
  def extract_user_id
62
93
  @context[:current_user]&.id ||
63
94
  @context[:user_id] ||
@@ -120,12 +151,18 @@ module RailsErrorDashboard
120
151
  # Additional context (from mobile apps, etc.)
121
152
  params.merge!(@context[:additional_context]) if @context[:additional_context]
122
153
 
154
+ # Caller-supplied metadata, documented by ManualErrorReporter and
155
+ # previously accepted and discarded.
156
+ params.merge!(@context[:metadata]) if @context[:metadata].is_a?(Hash)
157
+
123
158
  # Pre-serialized params (from async logging or double-ErrorContext path).
124
159
  # LogError creates a second ErrorContext from error_context.to_h which
125
160
  # has :request_params as a JSON string but no :request object.
126
161
  return @context[:request_params] if params.empty? && @context[:request_params].present?
127
162
 
128
- params.to_json
163
+ # Params read off a live request or job object never passed through the
164
+ # context scrub LogError does, and to_json raises on an invalid byte.
165
+ Services::EncodingSanitizer.scrub_deep(params).to_json
129
166
  end
130
167
 
131
168
  def extract_user_agent
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.12.1"
2
+ VERSION = "0.14.0"
3
3
  end