rails_error_dashboard 0.12.1 → 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 +8 -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 +4 -3
  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
@@ -23,6 +23,113 @@ module RailsErrorDashboard
23
23
  new(error_type, platform).weekly_baseline
24
24
  end
25
25
 
26
+ # Pairs considered by .current_anomalies, busiest first. A bound, so the
27
+ # check costs the same whether 5 or 50,000 error types are stored.
28
+ MAX_ANOMALY_PAIRS = 500
29
+ BASELINE_PRECEDENCE = %w[hourly daily weekly].freeze
30
+
31
+ # Every (error_type, platform) pair that is anomalous RIGHT NOW, for all
32
+ # pairs at once. Same rule as #check_current_anomaly -- the first baseline
33
+ # available among hourly / daily / weekly, compared against this hour /
34
+ # today / this week -- but in a fixed number of queries instead of about six
35
+ # per pair. DashboardStats calls this from the live stats broadcast, which
36
+ # runs inside the host app's capture path.
37
+ #
38
+ # Only pairs with an event this week are looked at: a pair with none has a
39
+ # count of zero, which no baseline can call anomalous.
40
+ #
41
+ # @return [Array<Hash>] error_type, platform, count, level, std_devs_above,
42
+ # baseline_type. Empty on any failure -- never raises.
43
+ def self.current_anomalies(sensitivity: 2, application_id: nil)
44
+ return [] unless defined?(ErrorBaseline) && ErrorBaseline.table_exists?
45
+
46
+ counts = current_counts_by_pair(application_id: application_id)
47
+ return [] if counts.empty?
48
+
49
+ baselines = latest_baselines_for(counts.keys)
50
+
51
+ counts.filter_map do |(error_type, platform), windows|
52
+ kind = BASELINE_PRECEDENCE.find { |type| baselines[[ error_type, platform, type ]] }
53
+ next unless kind
54
+
55
+ baseline = baselines[[ error_type, platform, kind ]]
56
+ # A flat history has no spread to measure against; dividing by it
57
+ # makes any count above the mean infinitely anomalous.
58
+ next if baseline.std_dev.nil? || baseline.std_dev.zero?
59
+
60
+ count = windows.fetch(kind.to_sym)
61
+ level = baseline.anomaly_level(count, sensitivity: sensitivity)
62
+ next unless level
63
+
64
+ {
65
+ error_type: error_type,
66
+ platform: platform,
67
+ count: count,
68
+ level: level,
69
+ std_devs_above: baseline.std_devs_above_mean(count),
70
+ baseline_type: kind
71
+ }
72
+ end
73
+ rescue => e
74
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] current_anomalies failed: #{e.class}: #{e.message}")
75
+ []
76
+ end
77
+
78
+ # { [error_type, platform] => { hourly:, daily:, weekly: } } in ONE grouped
79
+ # query, counted in the units the baselines were built from (occurrence
80
+ # rows when that table exists). The week is the widest window, so it is
81
+ # the WHERE; the day and the hour are conditional sums inside it.
82
+ def self.current_counts_by_pair(application_id: nil)
83
+ logs = ErrorLog.table_name
84
+ column = Services::BaselineCalculator.time_column
85
+ now = Time.current
86
+
87
+ relation = if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
88
+ ErrorOccurrence.joins(:error_log)
89
+ else
90
+ ErrorLog.all
91
+ end
92
+ relation = relation.where(logs => { application_id: application_id }) if application_id.present?
93
+
94
+ since = ->(time) { ErrorLog.sanitize_sql_array([ "SUM(CASE WHEN #{column} >= ? THEN 1 ELSE 0 END)", time ]) }
95
+
96
+ rows = relation
97
+ .where("#{column} >= ?", now.beginning_of_week)
98
+ .group("#{logs}.error_type", "#{logs}.platform")
99
+ .order(Arel.sql("COUNT(*) DESC"))
100
+ .limit(MAX_ANOMALY_PAIRS)
101
+ .pluck(Arel.sql("#{logs}.error_type"), Arel.sql("#{logs}.platform"), Arel.sql("COUNT(*)"),
102
+ Arel.sql(since.call(now.beginning_of_day)), Arel.sql(since.call(now.beginning_of_hour)))
103
+
104
+ rows.to_h do |error_type, platform, weekly, daily, hourly|
105
+ [ [ error_type, platform ], { weekly: weekly.to_i, daily: daily.to_i, hourly: hourly.to_i } ]
106
+ end
107
+ end
108
+
109
+ # { [error_type, platform, baseline_type] => ErrorBaseline }, the most recent
110
+ # row of each, in ONE query. Baseline rows accumulate (one per calculation
111
+ # period), so "latest" is resolved in SQL with a join on MAX(period_start)
112
+ # rather than by loading the history. The join form is portable to every
113
+ # adapter; a row-value IN is not.
114
+ def self.latest_baselines_for(pairs)
115
+ table = ErrorBaseline.table_name
116
+ types = pairs.map(&:first).compact.uniq
117
+ return {} if types.empty?
118
+
119
+ latest = ErrorBaseline.where(error_type: types, baseline_type: BASELINE_PRECEDENCE)
120
+ .group(:error_type, :platform, :baseline_type)
121
+ .select(:error_type, :platform, :baseline_type, "MAX(period_start) AS latest_period_start")
122
+
123
+ ErrorBaseline
124
+ .joins("INNER JOIN (#{latest.to_sql}) latest_baselines ON " \
125
+ "latest_baselines.error_type = #{table}.error_type AND " \
126
+ "latest_baselines.platform = #{table}.platform AND " \
127
+ "latest_baselines.baseline_type = #{table}.baseline_type AND " \
128
+ "latest_baselines.latest_period_start = #{table}.period_start")
129
+ .index_by { |baseline| [ baseline.error_type, baseline.platform, baseline.baseline_type ] }
130
+ end
131
+ private_class_method :current_counts_by_pair, :latest_baselines_for
132
+
26
133
  def initialize(error_type, platform)
27
134
  @error_type = error_type
28
135
  @platform = platform
@@ -5,6 +5,9 @@ module RailsErrorDashboard
5
5
  # Query: Fetch dashboard statistics
6
6
  # This is a read operation that aggregates error data for the dashboard
7
7
  class DashboardStats
8
+ # One instance answers ONE call: several aggregates (today's event count,
9
+ # the 7-day trend, spike detection) are memoised on it so that they are
10
+ # computed once per call rather than once per card that shows them.
8
11
  def initialize(application_id: nil)
9
12
  @application_id = application_id
10
13
  end
@@ -24,7 +27,7 @@ module RailsErrorDashboard
24
27
  # rows reported five users hitting one error as "1 error today".
25
28
  # Summing is also exact during a storm: counted-only events never
26
29
  # create occurrence rows, but they DO raise occurrence_count.
27
- total_today: event_count_since(Time.current.beginning_of_day),
30
+ total_today: today_event_count,
28
31
  total_week: event_count_since(7.days.ago),
29
32
  total_month: event_count_since(30.days.ago),
30
33
  unresolved: base_scope.unresolved.count,
@@ -93,13 +96,14 @@ module RailsErrorDashboard
93
96
  end
94
97
 
95
98
  def cache_key
96
- # Cache key includes last error update timestamp for auto-invalidation
97
- # Also includes current hour to ensure fresh data
98
- # Uses base_scope to respect application_id filter for proper cache isolation
99
+ # The cache GENERATION, not maximum(:updated_at): the timestamp cost a
100
+ # query per key build and changed on every capture, so the cache never
101
+ # hit while errors were arriving. Freshness after a capture is the
102
+ # 1-minute TTL; user actions bump the generation (AnalyticsCacheManager).
99
103
  [
100
104
  "dashboard_stats",
101
105
  @application_id || "all",
102
- base_scope.maximum(:updated_at)&.to_i || 0,
106
+ Services::AnalyticsCacheManager.generation,
103
107
  Time.current.hour
104
108
  ].join("/")
105
109
  end
@@ -147,7 +151,7 @@ module RailsErrorDashboard
147
151
  recorded = occurrence_scope
148
152
  .where("#{ErrorOccurrence.table_name}.occurred_at >= ?", Time.current.beginning_of_day)
149
153
  .count
150
- recorded < event_count_since(Time.current.beginning_of_day)
154
+ recorded < today_event_count
151
155
  rescue StandardError
152
156
  false
153
157
  end
@@ -169,9 +173,10 @@ module RailsErrorDashboard
169
173
 
170
174
  # Get 7-day error trend (daily counts)
171
175
  def errors_trend_7d
172
- base_scope.where("occurred_at >= ?", 7.days.ago)
173
- .group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
174
- .sum(:occurrence_count)
176
+ @errors_trend_7d ||=
177
+ base_scope.where("occurred_at >= ?", 7.days.ago)
178
+ .group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
179
+ .sum(:occurrence_count)
175
180
  end
176
181
 
177
182
  # Get error counts by severity for last 7 days
@@ -193,21 +198,25 @@ module RailsErrorDashboard
193
198
 
194
199
  # Detect if there's an error spike
195
200
  # Uses baselines if available, falls back to simple 2x average
201
+ #
202
+ # Memoised: the stats hash asks twice (spike_detected and spike_info), and
203
+ # this runs from the live stats broadcast inside the capture path.
196
204
  def spike_detected?
197
- return false if errors_trend_7d.empty?
205
+ return @spike_detected if defined?(@spike_detected)
198
206
 
199
- today_count = event_count_since(Time.current.beginning_of_day)
207
+ @spike_detected = compute_spike_detected
208
+ end
200
209
 
201
- # Try baseline-based detection first
202
- if baseline_anomaly_detected?(today_count)
203
- return true
204
- end
210
+ def compute_spike_detected
211
+ trend = errors_trend_7d
212
+ return false if trend.empty?
213
+ return true if baseline_anomalies.any?
205
214
 
206
215
  # Fall back to simple 2x average detection
207
- avg_count = errors_trend_7d.values.sum / 7.0
216
+ avg_count = trend.values.sum / 7.0
208
217
  return false if avg_count.zero?
209
218
 
210
- today_count >= (avg_count * 2)
219
+ today_event_count >= (avg_count * 2)
211
220
  end
212
221
 
213
222
  # Get spike information
@@ -215,7 +224,7 @@ module RailsErrorDashboard
215
224
  def spike_info
216
225
  return nil unless spike_detected?
217
226
 
218
- today_count = event_count_since(Time.current.beginning_of_day)
227
+ today_count = today_event_count
219
228
  avg_count = (errors_trend_7d.values.sum / 7.0).round(1)
220
229
 
221
230
  info = {
@@ -226,46 +235,35 @@ module RailsErrorDashboard
226
235
  }
227
236
 
228
237
  # Add baseline info if available
229
- baseline_info = baseline_anomaly_info(today_count)
238
+ baseline_info = baseline_anomaly_info
230
239
  info.merge!(baseline_info) if baseline_info.present?
231
240
 
232
241
  info
233
242
  end
234
243
 
235
- # Check if baseline indicates anomaly
236
- def baseline_anomaly_detected?(_count)
237
- return false unless defined?(Queries::BaselineStats)
244
+ def today_event_count
245
+ @today_event_count ||= event_count_since(Time.current.beginning_of_day)
246
+ end
247
+
248
+ # Every anomalous (error_type, platform) pair, from a fixed number of
249
+ # queries (BaselineStats.current_anomalies), loaded once per call. This
250
+ # used to be about six queries per distinct pair, run twice.
251
+ def baseline_anomalies
252
+ return @baseline_anomalies if defined?(@baseline_anomalies)
238
253
 
239
- # Check most common error types for anomalies
240
- base_scope.distinct.pluck(:error_type, :platform).compact.any? do |(error_type, platform)|
241
- Queries::BaselineStats.new(error_type, platform)
242
- .check_current_anomaly(sensitivity: 2, application_id: @application_id)[:anomaly]
254
+ @baseline_anomalies = if defined?(Queries::BaselineStats)
255
+ Queries::BaselineStats.current_anomalies(sensitivity: 2, application_id: @application_id)
256
+ else
257
+ []
243
258
  end
244
259
  end
245
260
 
246
261
  # Get baseline anomaly information
247
- def baseline_anomaly_info(_total_count)
248
- return nil unless defined?(Queries::BaselineStats)
249
-
250
- # Find the most anomalous error type
251
- anomalies = base_scope.distinct.pluck(:error_type, :platform).compact.map do |(error_type, platform)|
252
- result = Queries::BaselineStats.new(error_type, platform)
253
- .check_current_anomaly(sensitivity: 2, application_id: @application_id)
254
- next unless result[:anomaly]
255
-
256
- {
257
- error_type: error_type,
258
- platform: platform,
259
- count: result[:current_count],
260
- level: result[:level],
261
- std_devs_above: result[:std_devs_above]
262
- }
263
- end.compact
264
-
265
- return nil if anomalies.empty?
262
+ def baseline_anomaly_info
263
+ return nil if baseline_anomalies.empty?
266
264
 
267
265
  # Return info about worst anomaly
268
- worst = anomalies.max_by { |a| a[:std_devs_above] || 0 }
266
+ worst = baseline_anomalies.max_by { |a| a[:std_devs_above] || 0 }
269
267
  {
270
268
  baseline_detected: true,
271
269
  anomaly_error_type: worst[:error_type],
@@ -283,7 +281,7 @@ module RailsErrorDashboard
283
281
  # make a real failure percentage from, so the honest figure is the rate
284
282
  # itself, uncapped, labelled with its unit.
285
283
  def error_rate
286
- today_events = event_count_since(Time.current.beginning_of_day)
284
+ today_events = today_event_count
287
285
  return 0.0 if today_events.zero?
288
286
 
289
287
  hours_today = ((Time.current - Time.current.beginning_of_day) / 1.hour).round(1)
@@ -299,11 +297,12 @@ module RailsErrorDashboard
299
297
  # affected user. Storm count-only events create no occurrence row, so
300
298
  # this is a floor during a storm -- affected_users_incomplete? says when.
301
299
  def affected_users_today
302
- distinct_affected_users(Time.current.beginning_of_day, nil)
300
+ @affected_users_today ||= distinct_affected_users(Time.current.beginning_of_day, nil)
303
301
  end
304
302
 
305
303
  def affected_users_yesterday
306
- distinct_affected_users(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
304
+ @affected_users_yesterday ||=
305
+ distinct_affected_users(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
307
306
  end
308
307
 
309
308
  def distinct_affected_users(from, to)
@@ -334,7 +333,13 @@ module RailsErrorDashboard
334
333
 
335
334
  # Calculate percentage change in errors (today vs yesterday)
336
335
  def trend_percentage
337
- today = event_count_since(Time.current.beginning_of_day)
336
+ return @trend_percentage if defined?(@trend_percentage)
337
+
338
+ @trend_percentage = compute_trend_percentage
339
+ end
340
+
341
+ def compute_trend_percentage
342
+ today = today_event_count
338
343
  yesterday = event_count_between(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
339
344
 
340
345
  return 0.0 if today.zero? && yesterday.zero?
@@ -224,12 +224,15 @@ module RailsErrorDashboard
224
224
  previous_start = @start_date
225
225
  previous_end = current_start
226
226
 
227
- current_errors = ErrorLog
227
+ # Both periods come from base_query, which carries the application
228
+ # filter (and the window start). Querying ErrorLog directly made this
229
+ # the one panel on an application-filtered page that counted every app.
230
+ current_errors = base_query
228
231
  .where("occurred_at >= ?", current_start)
229
232
  .count
230
233
 
231
- previous_errors = ErrorLog
232
- .where("occurred_at >= ? AND occurred_at < ?", previous_start, previous_end)
234
+ previous_errors = base_query
235
+ .where("occurred_at < ?", previous_end)
233
236
  .count
234
237
 
235
238
  change_percentage = if previous_errors > 0
@@ -5,6 +5,9 @@ module RailsErrorDashboard
5
5
  # Query: Fetch errors with filtering and pagination
6
6
  # This is a read operation that returns a filtered collection of errors
7
7
  class ErrorsList
8
+ # Escape character for LIKE patterns; see filter_by_search.
9
+ LIKE_ESCAPE = "!"
10
+
8
11
  def self.call(filters = {})
9
12
  new(filters).call
10
13
  end
@@ -129,9 +132,19 @@ module RailsErrorDashboard
129
132
  else
130
133
  # Fall back to LIKE for SQLite/MySQL - search across all relevant fields
131
134
  # Use LOWER() for case-insensitive search
132
- search_pattern = "%#{@filters[:search]}%"
135
+ #
136
+ # % and _ in the term are escaped so they match themselves: a search
137
+ # for "snake_case" must not match "snakeXcase", and "%" must not
138
+ # match every row. The escape character is named explicitly because
139
+ # SQLite has no default one, and it is "!" rather than a backslash
140
+ # because a backslash inside a quoted SQL literal means different
141
+ # things to MySQL and to everyone else.
142
+ escaped = ActiveRecord::Base.sanitize_sql_like(@filters[:search].to_s, LIKE_ESCAPE)
143
+ search_pattern = "%#{escaped}%"
133
144
  query.where(
134
- "LOWER(message) LIKE LOWER(?) OR LOWER(COALESCE(backtrace, '')) LIKE LOWER(?) OR LOWER(error_type) LIKE LOWER(?)",
145
+ "LOWER(message) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}' " \
146
+ "OR LOWER(COALESCE(backtrace, '')) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}' " \
147
+ "OR LOWER(error_type) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}'",
135
148
  search_pattern, search_pattern, search_pattern
136
149
  )
137
150
  end
@@ -78,7 +78,7 @@ module RailsErrorDashboard
78
78
  if error_prefix.present?
79
79
  candidates += ErrorLog
80
80
  .where(platform: target_error.platform)
81
- .where("error_type LIKE ?", "%#{error_prefix}%")
81
+ .where("error_type LIKE ? ESCAPE '!'", "%#{ActiveRecord::Base.sanitize_sql_like(error_prefix, "!")}%")
82
82
  .where.not(id: target_error.id)
83
83
  .where.not(id: candidates.map(&:id))
84
84
  .limit(20)
@@ -2,29 +2,54 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module Services
5
- # Infrastructure service: Clear analytics caches
5
+ # Infrastructure service: invalidate the dashboard's cached statistics
6
6
  #
7
- # Clears dashboard_stats, analytics_stats, and platform_comparison
8
- # cache entries. Handles cache stores that don't support delete_matched
9
- # (e.g., SolidCache) gracefully.
7
+ # Every cached stats key (DashboardStats, AnalyticsStats) embeds a
8
+ # GENERATION number read from Rails.cache. Invalidation is one increment of
9
+ # that number: entries written under the old generation are simply never
10
+ # read again and age out by their own TTL.
11
+ #
12
+ # This replaced delete_matched("dashboard_stats/*") and friends, which is a
13
+ # SCAN of the HOST app's whole Redis keyspace, NotImplementedError on
14
+ # memcached, and used to run from ErrorLog's after_save -- i.e. inside the
15
+ # host's request thread on every captured error.
16
+ #
17
+ # Who calls .clear: user actions and maintenance that change what the cards
18
+ # show (resolve, status change, batch actions, mute, retention). Captures
19
+ # deliberately do NOT: they rely on the TTL (1 minute for the stat cards,
20
+ # 5 for analytics), which decouples capture cost from dashboard freshness.
21
+ #
22
+ # With :memory_store the generation is per process, so an action handled by
23
+ # one worker reaches the others when their entries expire. With :null_store
24
+ # nothing is cached and there is nothing to invalidate.
10
25
  class AnalyticsCacheManager
11
- CACHE_PATTERNS = %w[
12
- dashboard_stats/*
13
- analytics_stats/*
14
- platform_comparison/*
15
- ].freeze
26
+ GENERATION_KEY = "red/cache_gen"
27
+
28
+ # @return [Integer] the current cache generation; 0 when unset or unreadable
29
+ def self.generation
30
+ Rails.cache.read(GENERATION_KEY).to_i
31
+ rescue => e
32
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] cache generation unreadable: #{e.class}: #{e.message}")
33
+ 0
34
+ end
16
35
 
17
- # Clear all analytics caches
36
+ # Invalidate every cached dashboard statistic. Never raises.
37
+ #
38
+ # A plain read + write, deliberately not Rails.cache.increment: increment
39
+ # is unimplemented on some stores, returns nil for a missing key on
40
+ # others, and on Redis is an INCRBY that fails against an entry written
41
+ # by #write. It does not need to be atomic either -- racing clears only
42
+ # need the number to differ from the one stale entries were written under.
43
+ #
44
+ # Never lower than the clock in milliseconds, so that a store which evicts
45
+ # the generation key cannot restart at a number that entries still inside
46
+ # their TTL were written under a few minutes earlier.
18
47
  def self.clear
19
- if Rails.cache.respond_to?(:delete_matched)
20
- CACHE_PATTERNS.each { |pattern| Rails.cache.delete_matched(pattern) }
21
- else
22
- Rails.logger.info("Cache store doesn't support delete_matched, skipping cache clear") if Rails.logger
23
- end
24
- rescue NotImplementedError => e
25
- Rails.logger.info("Cache store doesn't support delete_matched: #{e.message}") if Rails.logger
48
+ Rails.cache.write(GENERATION_KEY, [ generation + 1, (Time.now.to_f * 1000).to_i ].max)
49
+ nil
26
50
  rescue => e
27
- Rails.logger.error("Failed to clear analytics cache: #{e.message}") if Rails.logger
51
+ RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to clear analytics cache: #{e.class}: #{e.message}")
52
+ nil
28
53
  end
29
54
  end
30
55
  end
@@ -22,7 +22,9 @@ module RailsErrorDashboard
22
22
 
23
23
  max_lines ||= RailsErrorDashboard.configuration.max_backtrace_lines
24
24
 
25
- limited_backtrace = backtrace.first(max_lines).map { |line| shorten_gem_path(line) }
25
+ # Scrubbed per line: a frame label can hold any bytes, and both the sub
26
+ # below and the join (mixed encodings) raise on an invalid one.
27
+ limited_backtrace = backtrace.first(max_lines).map { |line| shorten_gem_path(EncodingSanitizer.scrub(line)) }
26
28
  result = limited_backtrace.join("\n")
27
29
 
28
30
  if backtrace.length > max_lines
@@ -43,7 +43,9 @@ module RailsErrorDashboard
43
43
  depth += 1
44
44
  end
45
45
 
46
- chain.empty? ? nil : chain.to_json
46
+ # to_json raises on a cause message or frame with invalid bytes, which
47
+ # used to drop the whole chain.
48
+ chain.empty? ? nil : EncodingSanitizer.scrub_deep(chain).to_json
47
49
  rescue => e
48
50
  # SAFETY: Never let cause chain extraction break error logging
49
51
  RailsErrorDashboard::Logger.debug(
@@ -40,7 +40,7 @@ module RailsErrorDashboard
40
40
  auth_headers
41
41
  )
42
42
 
43
- response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
43
+ patch_result(response)
44
44
  end
45
45
 
46
46
  def reopen_issue(number:)
@@ -50,7 +50,7 @@ module RailsErrorDashboard
50
50
  auth_headers
51
51
  )
52
52
 
53
- response[:status] == 201 ? success_response({}) : error_response("Codeberg API error (#{response[:status]})")
53
+ patch_result(response)
54
54
  end
55
55
 
56
56
  def add_comment(number:, body:)
@@ -114,6 +114,17 @@ module RailsErrorDashboard
114
114
 
115
115
  private
116
116
 
117
+ # Gitea/Forgejo answer a successful PATCH with 200; 201 is what they
118
+ # return for creation. Accepting only 201 reported every close and reopen
119
+ # as failed although the forge had applied it.
120
+ def patch_result(response)
121
+ if [ 200, 201 ].include?(response[:status])
122
+ success_response({})
123
+ else
124
+ error_response("Codeberg API error (#{response[:status]})")
125
+ end
126
+ end
127
+
117
128
  def auth_headers
118
129
  { "Authorization" => "token #{@token}" }
119
130
  end
@@ -27,7 +27,9 @@ module RailsErrorDashboard
27
27
  end
28
28
 
29
29
  def call
30
- {
30
+ # Thread names and breadcrumb text are arbitrary bytes; both callers
31
+ # immediately to_json this, which raises on an invalid one.
32
+ EncodingSanitizer.scrub_deep(
31
33
  captured_at: Time.current.iso8601,
32
34
  pid: Process.pid,
33
35
  uptime_seconds: process_uptime,
@@ -37,9 +39,9 @@ module RailsErrorDashboard
37
39
  threads: thread_info,
38
40
  gc: gc_info,
39
41
  object_counts: object_counts
40
- }
42
+ )
41
43
  rescue => e
42
- { captured_at: Time.current.iso8601, error: e.message }
44
+ { captured_at: Time.current.iso8601, error: EncodingSanitizer.scrub(e.message) }
43
45
  end
44
46
 
45
47
  private
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Pure algorithm: make every String in a value safe to store, serialize and render
6
+ #
7
+ # Exception messages, backtrace lines, request URLs, user agents and params can
8
+ # carry bytes that are not valid UTF-8 (a binary upload echoed in a message, a
9
+ # Latin-1 query string, a NUL from a fuzzer). Left alone they raise at the
10
+ # worst possible moments: ActiveJob JSON-encoding the async payload in the
11
+ # host's request thread, the PostgreSQL INSERT, or `blank?` while rendering the
12
+ # error's own page.
13
+ #
14
+ # Invalid sequences become "?" and NUL bytes are removed (PostgreSQL text
15
+ # columns reject them). Strings tagged ASCII-8BIT (or any other encoding) are
16
+ # re-tagged UTF-8 and then scrubbed — not transcoded, because a binary tag
17
+ # says nothing about what the bytes mean and transcoding would raise.
18
+ #
19
+ # The common case costs one `valid_encoding?` scan and returns the SAME
20
+ # object. Nothing here raises, and the caller's objects are never mutated.
21
+ #
22
+ # @example
23
+ # EncodingSanitizer.scrub("caf\xC3 \xFF".b) # => "caf? ?"
24
+ # EncodingSanitizer.scrub_deep({ url: "/?q=\xFF" }) # => { url: "/?q=?" }
25
+ class EncodingSanitizer
26
+ REPLACEMENT = "?"
27
+ UNREADABLE = "[unreadable]"
28
+ TOO_DEEP = "[truncated: nested too deep]"
29
+ MAX_DEPTH = 10
30
+ NUL = "\0"
31
+
32
+ # @param str [Object] anything; only Strings are touched
33
+ # @return [Object] the same object when already clean, otherwise a clean copy
34
+ def self.scrub(str)
35
+ return str unless String === str
36
+
37
+ s = str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8)
38
+ return s if s.valid_encoding? && !s.include?(NUL)
39
+
40
+ s = s.scrub(REPLACEMENT) unless s.valid_encoding?
41
+ s.delete(NUL)
42
+ rescue => e
43
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EncodingSanitizer.scrub failed: #{e.class}")
44
+ UNREADABLE
45
+ end
46
+
47
+ # Recursively scrub Strings inside Hashes (keys and values) and Arrays.
48
+ # Anything else is returned untouched. A container nested deeper than
49
+ # MAX_DEPTH is replaced by a placeholder rather than passed through: an
50
+ # unscrubbed subtree would defeat the point, and a self-referencing one
51
+ # would never end.
52
+ #
53
+ # @param obj [Object]
54
+ # @return [Object]
55
+ def self.scrub_deep(obj, depth = 0)
56
+ case obj
57
+ when String
58
+ scrub(obj)
59
+ when Hash
60
+ return TOO_DEEP if depth >= MAX_DEPTH
61
+
62
+ # Build into an empty copy of the same class so a
63
+ # HashWithIndifferentAccess stays one.
64
+ obj.each_with_object(obj.class.new) do |(key, value), result|
65
+ result[scrub_deep(key, depth + 1)] = scrub_deep(value, depth + 1)
66
+ end
67
+ when Array
68
+ return TOO_DEEP if depth >= MAX_DEPTH
69
+
70
+ obj.map { |value| scrub_deep(value, depth + 1) }
71
+ else
72
+ obj
73
+ end
74
+ rescue => e
75
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EncodingSanitizer.scrub_deep failed: #{e.class}")
76
+ String === obj ? UNREADABLE : obj
77
+ end
78
+ end
79
+ end
80
+ end