rails_error_dashboard 0.12.0 → 0.13.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 (90) 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/concerns/plain_channel_message.rb +71 -0
  8. data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
  9. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +68 -1
  10. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
  11. data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
  12. data/app/models/rails_error_dashboard/error_comment.rb +0 -5
  13. data/app/models/rails_error_dashboard/error_log.rb +9 -3
  14. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  15. data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
  16. data/app/views/layouts/rails_error_dashboard.html.erb +11 -3
  17. data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
  18. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
  19. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
  20. data/app/views/rails_error_dashboard/errors/analytics.html.erb +14 -8
  21. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
  22. data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
  23. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
  24. data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
  25. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  26. data/config/locales/de.yml +28 -0
  27. data/config/locales/en.yml +35 -0
  28. data/config/locales/es.yml +28 -0
  29. data/config/locales/fr.yml +28 -0
  30. data/config/locales/it.yml +28 -0
  31. data/config/locales/ja.yml +28 -0
  32. data/config/locales/pl.yml +28 -0
  33. data/config/locales/pt-BR.yml +28 -0
  34. data/config/locales/ru.yml +28 -0
  35. data/config/locales/uk.yml +28 -0
  36. data/config/locales/zh-CN.yml +28 -0
  37. data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
  38. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
  39. data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
  40. data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
  41. data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
  42. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
  43. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
  44. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
  45. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
  46. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +39 -7
  47. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
  48. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +27 -4
  49. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
  50. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
  51. data/lib/rails_error_dashboard/commands/log_error.rb +82 -23
  52. data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
  53. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
  54. data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
  55. data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
  56. data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
  57. data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
  58. data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
  59. data/lib/rails_error_dashboard/configuration.rb +19 -1
  60. data/lib/rails_error_dashboard/engine.rb +15 -0
  61. data/lib/rails_error_dashboard/queries/analytics_stats.rb +107 -26
  62. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  63. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +55 -50
  64. data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
  65. data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
  66. data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
  67. data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
  68. data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
  69. data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
  70. data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
  71. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
  72. data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
  73. data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
  74. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
  75. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
  76. data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
  77. data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
  78. data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
  79. data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
  80. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
  81. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
  82. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
  83. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
  84. data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
  85. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
  86. data/lib/rails_error_dashboard/value_objects/error_context.rb +3 -1
  87. data/lib/rails_error_dashboard/version.rb +1 -1
  88. data/lib/rails_error_dashboard.rb +31 -0
  89. data/lib/tasks/error_dashboard.rake +54 -4
  90. metadata +10 -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
@@ -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
 
@@ -125,7 +125,9 @@ module RailsErrorDashboard
125
125
  # has :request_params as a JSON string but no :request object.
126
126
  return @context[:request_params] if params.empty? && @context[:request_params].present?
127
127
 
128
- params.to_json
128
+ # Params read off a live request or job object never passed through the
129
+ # context scrub LogError does, and to_json raises on an invalid byte.
130
+ Services::EncodingSanitizer.scrub_deep(params).to_json
129
131
  end
130
132
 
131
133
  def extract_user_agent
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.12.0"
2
+ VERSION = "0.13.0"
3
3
  end
@@ -56,11 +56,14 @@ require "rails_error_dashboard/services/pearson_correlation"
56
56
  require "rails_error_dashboard/services/statistical_classifier"
57
57
  require "rails_error_dashboard/services/source_code_reader"
58
58
  require "rails_error_dashboard/services/git_blame_reader"
59
+ require "rails_error_dashboard/services/git_head_reader"
59
60
  require "rails_error_dashboard/services/github_link_generator"
60
61
  require "rails_error_dashboard/services/cause_chain_extractor"
61
62
  require "rails_error_dashboard/services/environment_snapshot"
62
63
  require "rails_error_dashboard/services/system_health_snapshot"
64
+ require "rails_error_dashboard/services/encoding_sanitizer"
63
65
  require "rails_error_dashboard/services/sensitive_data_filter"
66
+ require "rails_error_dashboard/services/url_safety"
64
67
  require "rails_error_dashboard/services/notification_throttler"
65
68
  require "rails_error_dashboard/services/breadcrumb_collector"
66
69
  require "rails_error_dashboard/services/n_plus_one_detector"
@@ -132,6 +135,8 @@ require "rails_error_dashboard/commands/upsert_cascade_pattern"
132
135
  require "rails_error_dashboard/commands/upsert_baseline"
133
136
  require "rails_error_dashboard/commands/flush_swallowed_exceptions"
134
137
  require "rails_error_dashboard/commands/flush_rack_attack_events"
138
+ require "rails_error_dashboard/commands/scrub_invalid_encoding"
139
+ require "rails_error_dashboard/commands/backfill_resolved_at"
135
140
  require "rails_error_dashboard/queries/errors_list"
136
141
  require "rails_error_dashboard/queries/dashboard_stats"
137
142
  require "rails_error_dashboard/queries/analytics_stats"
@@ -177,6 +182,32 @@ module RailsErrorDashboard
177
182
  end
178
183
  end
179
184
 
185
+ # The commit this process is running, read from .git once and remembered.
186
+ #
187
+ # LogError stamps every error with a SHA. When neither config.git_sha nor a
188
+ # platform ENV variable supplies one it used to run `git rev-parse` in a
189
+ # subprocess PER CAPTURED ERROR. The answer cannot change while the process
190
+ # lives, so it is resolved once (the engine does it at boot, so normally
191
+ # not even the first capture pays for it) and nil is remembered too: an app
192
+ # with no repository must not go looking again for every error.
193
+ #
194
+ # @return [String, nil] short SHA
195
+ def self.detected_git_sha
196
+ return @detected_git_sha if defined?(@detected_git_sha)
197
+
198
+ @detected_git_sha = begin
199
+ Services::GitHeadReader.call(defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : nil)
200
+ rescue => e
201
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Could not detect git SHA: #{e.class}: #{e.message}")
202
+ nil
203
+ end
204
+ end
205
+
206
+ # Forget the memo (specs).
207
+ def self.reset_detected_git_sha!
208
+ remove_instance_variable(:@detected_git_sha) if defined?(@detected_git_sha)
209
+ end
210
+
180
211
  # Register a plugin
181
212
  # @param plugin [Plugin] The plugin instance to register
182
213
  # @return [Boolean] True if registered successfully, false otherwise
@@ -459,6 +459,8 @@ namespace :error_dashboard do
459
459
  puts "\nDeleting errors..."
460
460
  start_time = Time.current
461
461
  deleted = scope.delete_all
462
+ # delete_all skips callbacks, and the stat cards are cached.
463
+ RailsErrorDashboard::Services::AnalyticsCacheManager.clear
462
464
  elapsed = (Time.current - start_time).round(2)
463
465
 
464
466
  puts "\n✓ Cleanup complete!"
@@ -492,7 +494,7 @@ namespace :error_dashboard do
492
494
  application_id: app.id,
493
495
  dump_data: dump.to_json,
494
496
  captured_at: Time.current,
495
- note: ENV["NOTE"]
497
+ note: RailsErrorDashboard::Services::EncodingSanitizer.scrub(ENV["NOTE"])
496
498
  )
497
499
 
498
500
  puts "\n" + JSON.pretty_generate(dump)
@@ -502,7 +504,7 @@ namespace :error_dashboard do
502
504
  puts "\n" + "=" * 70 + "\n"
503
505
  end
504
506
 
505
- desc "Run retention cleanup (delete errors older than retention_days)"
507
+ desc "Run retention cleanup (delete errors not seen for retention_days)"
506
508
  task retention_cleanup: :environment do
507
509
  config = RailsErrorDashboard.configuration
508
510
 
@@ -517,14 +519,15 @@ namespace :error_dashboard do
517
519
  end
518
520
 
519
521
  cutoff = config.retention_days.days.ago
520
- count = RailsErrorDashboard::ErrorLog.where("occurred_at < ?", cutoff).count
522
+ # The job's own selection, so the number shown is the number deleted.
523
+ count = RailsErrorDashboard::RetentionCleanupJob.expired_scope(cutoff).count
521
524
 
522
525
  puts "\n Retention policy: #{config.retention_days} days"
523
526
  puts " Cutoff date: #{cutoff.strftime('%Y-%m-%d %H:%M:%S')}"
524
527
  puts " Errors to delete: #{count}"
525
528
 
526
529
  if count.zero?
527
- puts "\n No errors older than #{config.retention_days} days"
530
+ puts "\n No errors unseen for more than #{config.retention_days} days"
528
531
  puts "\n" + "=" * 80 + "\n"
529
532
  next
530
533
  end
@@ -550,6 +553,53 @@ namespace :error_dashboard do
550
553
  puts "\n" + "=" * 80 + "\n"
551
554
  end
552
555
 
556
+ desc "Repair rows stored with invalid UTF-8 or NUL bytes (run once after upgrading on SQLite/MySQL)"
557
+ task scrub_invalid_encoding: :environment do
558
+ puts "Scanning error logs and occurrences for invalid bytes..."
559
+ result = RailsErrorDashboard::Commands::ScrubInvalidEncoding.call
560
+
561
+ puts " scanned: #{result[:scanned]}"
562
+ puts " repaired: #{result[:repaired]}"
563
+ puts " unreadable: #{result[:unreadable].size}"
564
+ result[:unreadable].each { |row| puts " - #{row}" }
565
+ end
566
+
567
+ desc "Backfill resolved_at for errors resolved through the status workflow (run once after upgrading)"
568
+ task backfill_resolved_at: :environment do
569
+ result = RailsErrorDashboard::Commands::BackfillResolvedAt.call
570
+
571
+ puts "Backfilled resolved_at on #{result[:updated]} resolved error(s)."
572
+ puts "MTTR now includes them, so expect the figure to rise to its true value." if result[:updated].positive?
573
+ end
574
+
575
+ desc "Replace raw session IDs stored on occurrences with keyed digests (idempotent)"
576
+ task digest_session_ids: :environment do
577
+ filter = RailsErrorDashboard::Services::SensitiveDataFilter
578
+ prefix = filter::SESSION_DIGEST_PREFIX
579
+ scope = RailsErrorDashboard::ErrorOccurrence
580
+ .where.not(session_id: [ nil, "" ])
581
+ .where("session_id NOT LIKE ? OR LENGTH(session_id) <> 35", "#{prefix}%")
582
+
583
+ digested = 0
584
+ failed = 0
585
+ scope.in_batches(of: 1000) do |batch|
586
+ batch.pluck(:id, :session_id).each do |id, raw|
587
+ digest = filter.digest_session_id(raw)
588
+ next if digest == raw # already a digest that the SQL prefilter let through
589
+
590
+ if digest
591
+ RailsErrorDashboard::ErrorOccurrence.where(id: id).update_all(session_id: digest)
592
+ digested += 1
593
+ else
594
+ failed += 1
595
+ end
596
+ end
597
+ end
598
+
599
+ puts "Session IDs digested: #{digested}"
600
+ puts "Could not be digested (left unchanged): #{failed}" if failed.positive?
601
+ end
602
+
553
603
  desc "Send error digest email (PERIOD=daily|weekly, APP_ID=optional)"
554
604
  task send_digest: :environment do
555
605
  period = ENV.fetch("PERIOD", "daily")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_error_dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -283,9 +283,11 @@ files:
283
283
  - app/jobs/rails_error_dashboard/baseline_calculation_job.rb
284
284
  - app/jobs/rails_error_dashboard/close_linked_issue_job.rb
285
285
  - app/jobs/rails_error_dashboard/concerns/localized_job.rb
286
+ - app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb
286
287
  - app/jobs/rails_error_dashboard/create_issue_job.rb
287
288
  - app/jobs/rails_error_dashboard/discord_error_notification_job.rb
288
289
  - app/jobs/rails_error_dashboard/email_error_notification_job.rb
290
+ - app/jobs/rails_error_dashboard/notification_burst_summary_job.rb
289
291
  - app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb
290
292
  - app/jobs/rails_error_dashboard/rack_attack_flush_job.rb
291
293
  - app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb
@@ -417,6 +419,7 @@ files:
417
419
  - db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb
418
420
  - db/migrate/20260915000003_add_context_provenance_to_error_logs.rb
419
421
  - db/migrate/20260915000004_create_storm_flush_batches.rb
422
+ - db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb
420
423
  - lib/generators/rails_error_dashboard/install/install_generator.rb
421
424
  - lib/generators/rails_error_dashboard/install/templates/README
422
425
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -427,6 +430,7 @@ files:
427
430
  - lib/rails_error_dashboard/commands/add_error_comment.rb
428
431
  - lib/rails_error_dashboard/commands/assign_error.rb
429
432
  - lib/rails_error_dashboard/commands/backfill_environments.rb
433
+ - lib/rails_error_dashboard/commands/backfill_resolved_at.rb
430
434
  - lib/rails_error_dashboard/commands/batch_delete_errors.rb
431
435
  - lib/rails_error_dashboard/commands/batch_mute_errors.rb
432
436
  - lib/rails_error_dashboard/commands/batch_resolve_errors.rb
@@ -443,6 +447,7 @@ files:
443
447
  - lib/rails_error_dashboard/commands/log_error.rb
444
448
  - lib/rails_error_dashboard/commands/mute_error.rb
445
449
  - lib/rails_error_dashboard/commands/resolve_error.rb
450
+ - lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb
446
451
  - lib/rails_error_dashboard/commands/snooze_error.rb
447
452
  - lib/rails_error_dashboard/commands/unassign_error.rb
448
453
  - lib/rails_error_dashboard/commands/unmute_error.rb
@@ -517,6 +522,7 @@ files:
517
522
  - lib/rails_error_dashboard/services/diagnostic_dump_generator.rb
518
523
  - lib/rails_error_dashboard/services/digest_builder.rb
519
524
  - lib/rails_error_dashboard/services/discord_payload_builder.rb
525
+ - lib/rails_error_dashboard/services/encoding_sanitizer.rb
520
526
  - lib/rails_error_dashboard/services/environment_snapshot.rb
521
527
  - lib/rails_error_dashboard/services/error_broadcaster.rb
522
528
  - lib/rails_error_dashboard/services/error_hash_generator.rb
@@ -524,6 +530,7 @@ files:
524
530
  - lib/rails_error_dashboard/services/error_notification_dispatcher.rb
525
531
  - lib/rails_error_dashboard/services/exception_filter.rb
526
532
  - lib/rails_error_dashboard/services/git_blame_reader.rb
533
+ - lib/rails_error_dashboard/services/git_head_reader.rb
527
534
  - lib/rails_error_dashboard/services/github_issue_client.rb
528
535
  - lib/rails_error_dashboard/services/github_link_generator.rb
529
536
  - lib/rails_error_dashboard/services/gitlab_issue_client.rb
@@ -558,6 +565,7 @@ files:
558
565
  - lib/rails_error_dashboard/services/storm_protection/gate.rb
559
566
  - lib/rails_error_dashboard/services/swallowed_exception_tracker.rb
560
567
  - lib/rails_error_dashboard/services/system_health_snapshot.rb
568
+ - lib/rails_error_dashboard/services/url_safety.rb
561
569
  - lib/rails_error_dashboard/services/variable_serializer.rb
562
570
  - lib/rails_error_dashboard/services/webhook_payload_builder.rb
563
571
  - lib/rails_error_dashboard/subscribers/action_cable_subscriber.rb
@@ -585,7 +593,7 @@ metadata:
585
593
  funding_uri: https://github.com/sponsors/AnjanJ
586
594
  post_install_message: |
587
595
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
588
- RED (Rails Error Dashboard) v0.12.0
596
+ RED (Rails Error Dashboard) v0.13.0
589
597
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
590
598
 
591
599
  First install: