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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c23f8c75c71f71913d2f5964f25a38642373f2f720e8996377a5877e54a1e6a
4
- data.tar.gz: 95e1db7a2687ed2900e3a02a2f33d1a1c21fc70c4597f9d9bf5d5855356d2f98
3
+ metadata.gz: 2d762f92c5645173b17543925db4d5740d1294af6a2da64e0a9cef6b6032211b
4
+ data.tar.gz: 056bca6e5ba6083666cf44302979bc8d3ff276d76563de87793051619b616994
5
5
  SHA512:
6
- metadata.gz: acb081a03269304c9c70b63ee996bd8d446a525bd506f401851d8249ad9bffe2ead0bf63798be64c231604176e03c614c761e4f551b9827802b7881b99f3b1b0
7
- data.tar.gz: d28393ca63c1b9639daeb2409cdc3f20074f905e1cbc9e3a89ae7b881dbe2666bfbaf53052d1172803de0ca46d58ff7ae8b4bd6f4d9b24be3ffa61ae1283a20e
6
+ metadata.gz: 5383eafcd408b881b4721fc496df7077c387c6ecb7f10543dfe8570049dda45c2c408f59f2f0e10c2c385a448a8d1acc6719da0367a654b8e42455bd4c568797
7
+ data.tar.gz: 6c77de41c4c43f81eab6f7a3bd59626ca2d48d002fe312c535c71e8b8ffd33ec5eddcbec2b3601a8d3d52aee25ceb65037fdf4d56497730f3d85b7a5c8b463e7
@@ -10,7 +10,13 @@ module RailsErrorDashboard
10
10
  # Declaring it here inverts the default: a controller is protected unless
11
11
  # it explicitly opts out with skip_before_action, which is a visible,
12
12
  # reviewable act rather than an omission nobody notices.
13
- before_action :authenticate_dashboard_user!
13
+ #
14
+ # Prepended so it runs before the CSRF check. A host app with
15
+ # default_protect_from_forgery registers verify_authenticity_token on
16
+ # ActionController::Base, ahead of anything declared here; an unauthenticated
17
+ # POST was then answered about its token (and by the error renderer) instead
18
+ # of with a 401.
19
+ prepend_before_action :authenticate_dashboard_user!
14
20
 
15
21
  include Pagy::Method
16
22
 
@@ -46,7 +52,9 @@ module RailsErrorDashboard
46
52
  # Log the error for debugging
47
53
  Rails.logger.error("[RailsErrorDashboard] Dashboard controller error: #{exception.class} - #{exception.message}")
48
54
  Rails.logger.error("Request: #{request.path} (#{request.method})")
49
- Rails.logger.error("Params: #{params.inspect}")
55
+ # filtered_parameters, not params.inspect: the host app's filter_parameters
56
+ # must apply to the dashboard's log lines too.
57
+ Rails.logger.error("Params: #{request.filtered_parameters.inspect}")
50
58
  Rails.logger.error(exception.backtrace&.first(10)&.join("\n")) if exception.backtrace
51
59
 
52
60
  render_dashboard_error(
@@ -72,6 +80,45 @@ module RailsErrorDashboard
72
80
  )
73
81
  end
74
82
 
83
+ # Client mistakes are not dashboard failures. Without these, the catch-all
84
+ # above answered 500 "Something went wrong" for an expired CSRF token, which
85
+ # is both the wrong status and the wrong advice. One warn line each, and no
86
+ # parameters: a rejected form post is exactly where a secret would be.
87
+ rescue_from ActionController::ParameterMissing, ActionController::BadRequest do |exception|
88
+ Rails.logger.warn("[RailsErrorDashboard] Bad request: #{exception.class} on #{request.method} #{request.path}")
89
+
90
+ render_dashboard_error(
91
+ icon: "bi-x-octagon",
92
+ title: red_t("red.errors_page.bad_request.title"),
93
+ message: red_t("red.errors_page.bad_request.message"),
94
+ status: :bad_request
95
+ )
96
+ end
97
+
98
+ rescue_from ActionController::UnknownFormat do |exception|
99
+ Rails.logger.warn("[RailsErrorDashboard] Unknown format: #{exception.class} on #{request.method} #{request.path}")
100
+
101
+ render_dashboard_error(
102
+ icon: "bi-file-earmark-x",
103
+ title: red_t("red.errors_page.not_acceptable.title"),
104
+ message: red_t("red.errors_page.not_acceptable.message"),
105
+ status: :not_acceptable
106
+ )
107
+ end
108
+
109
+ rescue_from ActionController::InvalidAuthenticityToken do |exception|
110
+ Rails.logger.warn("[RailsErrorDashboard] CSRF token rejected: #{exception.class} on #{request.method} #{request.path}")
111
+
112
+ render_dashboard_error(
113
+ icon: "bi-shield-exclamation",
114
+ title: red_t("red.errors_page.csrf.title"),
115
+ message: red_t("red.errors_page.csrf.message"),
116
+ # Numeric: Rack renamed the symbol (:unprocessable_entity is deprecated,
117
+ # :unprocessable_content is unknown to the Rack that Rails 7.0 uses).
118
+ status: 422
119
+ )
120
+ end
121
+
75
122
  # Handle Pagy pagination errors — redirect to page 1, preserving filters.
76
123
  # Drop both :page and :per_page from the preserved query string. Either can
77
124
  # trigger the rescue (page out of range, per_page negative or non-numeric);
@@ -81,9 +128,12 @@ module RailsErrorDashboard
81
128
  Rails.logger.warn("[RailsErrorDashboard] Pagination error: #{exception.message}")
82
129
  preserved = request.query_parameters.except("page", :page, "per_page", :per_page)
83
130
  target = preserved.any? ? "#{request.path}?#{preserved.to_query}" : request.path
84
- redirect_to target, status: :moved_permanently
131
+ redirect_to target, status: :see_other
85
132
  end
86
133
 
134
+ DEFAULT_PER_PAGE = 25
135
+ MAX_PER_PAGE = 100
136
+
87
137
  private
88
138
 
89
139
  # Set the dashboard's locale for this request and restore whatever was
@@ -176,7 +226,26 @@ module RailsErrorDashboard
176
226
  @resolved_pagy_locales ||= {}
177
227
  end
178
228
 
229
+ # Page size for every paginated action. Only the upper bound is enforced
230
+ # here: a value that is not a positive integer is passed through unchanged
231
+ # so that Pagy rejects it and the rescue above redirects, as it always has.
232
+ def per_page_param
233
+ raw = params[:per_page]
234
+ return DEFAULT_PER_PAGE if raw.blank?
235
+
236
+ number = Integer(raw, exception: false) if raw.is_a?(String)
237
+ number && number > MAX_PER_PAGE ? MAX_PER_PAGE : raw
238
+ end
239
+
179
240
  def render_dashboard_error(icon:, title:, message:, detail: nil, icon_style: nil, status: :internal_server_error)
241
+ # The styled page runs queries and names the host app's applications. A
242
+ # caller who has not authenticated gets the title and nothing else: no
243
+ # layout, no queries, and no exception detail.
244
+ unless @dashboard_authenticated
245
+ render plain: title, status: status
246
+ return
247
+ end
248
+
180
249
  set_common_view_variables
181
250
  error_html = <<~ERB
182
251
  <div class="red-empty-state" style="margin-top: var(--space-6);">
@@ -208,6 +277,10 @@ module RailsErrorDashboard
208
277
  else
209
278
  authenticate_with_basic_auth
210
279
  end
280
+
281
+ # performed? means a 401/403 (or a redirect from the host's lambda) has
282
+ # already been rendered. Read by render_dashboard_error.
283
+ @dashboard_authenticated = !performed?
211
284
  end
212
285
 
213
286
  def authenticate_with_lambda(auth_lambda)
@@ -229,14 +302,22 @@ module RailsErrorDashboard
229
302
 
230
303
  def authenticate_with_basic_auth
231
304
  authenticate_or_request_with_http_basic do |username, password|
232
- ActiveSupport::SecurityUtils.secure_compare(
233
- username,
234
- RailsErrorDashboard.configuration.dashboard_username
235
- ) &
236
- ActiveSupport::SecurityUtils.secure_compare(
237
- password,
238
- RailsErrorDashboard.configuration.dashboard_password
239
- )
305
+ # A Basic header need not decode to "user:pass": with no colon the
306
+ # password is nil, with nothing decodable both are. secure_compare
307
+ # raises on nil, which used to surface as a 500 rendered by the
308
+ # dashboard's own error page. A malformed header is a failed login.
309
+ next false if username.nil? || password.nil?
310
+
311
+ # A credential configured as nil must deny everyone. Coercing it with
312
+ # to_s would make it equal to an empty login ("Basic Og==" is ":").
313
+ expected_username = RailsErrorDashboard.configuration.dashboard_username
314
+ expected_password = RailsErrorDashboard.configuration.dashboard_password
315
+ next false if expected_username.nil? || expected_password.nil?
316
+
317
+ # Non-short-circuit & on purpose: both comparisons always run, so the
318
+ # response time does not reveal which half was wrong.
319
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, expected_username.to_s) &
320
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, expected_password.to_s)
240
321
  end
241
322
  end
242
323
  end
@@ -2,7 +2,8 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  class ErrorsController < ApplicationController
5
- before_action :authenticate_dashboard_user!
5
+ # Authentication is declared once, on ApplicationController, and prepended.
6
+ # Re-declaring it here moved it to the END of the chain, behind the CSRF check.
6
7
  before_action :set_application_context
7
8
  before_action :check_default_credentials
8
9
  before_action :load_storm_banner
@@ -45,6 +46,10 @@ module RailsErrorDashboard
45
46
  # 4000, and the copy that drifts is the one the user reads.
46
47
  AI_HELP_QUESTION_LIMIT = 4000
47
48
 
49
+ # Cached in place of nil when the issue tracker has nothing to say, so that
50
+ # "nothing" is remembered for the TTL like any other answer.
51
+ NO_PLATFORM_DATA = "none"
52
+
48
53
  # The initializer every "not enabled" notice points at. A path, not prose.
49
54
  INITIALIZER_PATH = "config/initializers/rails_error_dashboard.rb"
50
55
 
@@ -86,11 +91,18 @@ module RailsErrorDashboard
86
91
  # on out-of-range pages (e.g. ?page=999999) so application_controller.rb's
87
92
  # rescue_from can redirect to a valid page. Without this, pagy 43.x silently
88
93
  # returns an empty result and users see "All clear!" instead of their data.
89
- @pagy, @errors = pagy(:offset, errors_query, limit: params[:per_page] || 25, raise_range_error: true)
94
+ @pagy, @errors = pagy(:offset, errors_query, limit: per_page_param, raise_range_error: true)
90
95
 
91
96
  # Get dashboard stats using Query (pass application filter)
92
97
  @stats = Queries::DashboardStats.call(application_id: @current_application_id)
93
98
 
99
+ # Which Turbo streams this page listens to: its application's (or the
100
+ # global ones), and new-row prepends only when a new row belongs on top.
101
+ @live_streams = Services::ErrorBroadcaster.streams_for_view(
102
+ application_id: @current_application_id,
103
+ filtered: live_prepend_unsafe?
104
+ )
105
+
94
106
  # Get filter options using Query (pass application filter)
95
107
  filter_options = Queries::FilterOptions.call(application_id: @current_application_id)
96
108
  @error_types = filter_options[:error_types]
@@ -130,8 +142,9 @@ module RailsErrorDashboard
130
142
  # Phase 3: Workflow Integration Actions (via Commands)
131
143
 
132
144
  def assign
133
- @error = Commands::AssignError.call(params[:id], assigned_to: params[:assigned_to])
134
- redirect_to error_path(@error, **app_context_params)
145
+ result = Commands::AssignError.call(params[:id], assigned_to: params[:assigned_to])
146
+ flash[:alert] = red_t("red.flash.assign.blank") unless result[:success]
147
+ redirect_to error_path(result[:error], **app_context_params)
135
148
  end
136
149
 
137
150
  def unassign
@@ -140,13 +153,19 @@ module RailsErrorDashboard
140
153
  end
141
154
 
142
155
  def update_priority
143
- @error = Commands::UpdateErrorPriority.call(params[:id], priority_level: params[:priority_level])
144
- redirect_to error_path(@error, **app_context_params)
156
+ result = Commands::UpdateErrorPriority.call(params[:id], priority_level: params[:priority_level])
157
+ flash[:alert] = red_t("red.flash.priority.invalid") unless result[:success]
158
+ redirect_to error_path(result[:error], **app_context_params)
145
159
  end
146
160
 
147
161
  def snooze
148
- @error = Commands::SnoozeError.call(params[:id], hours: params[:hours].to_i, reason: params[:reason])
149
- redirect_to error_path(@error, **app_context_params)
162
+ # The raw value, not to_i: "abc".to_i is 0 and a nested parameter has no
163
+ # to_i at all. The command decides what a number of hours is.
164
+ result = Commands::SnoozeError.call(params[:id], hours: params[:hours], reason: params[:reason])
165
+ unless result[:success]
166
+ flash[:alert] = red_t("red.flash.snooze.invalid_hours", max: Commands::SnoozeError::MAX_SNOOZE_HOURS)
167
+ end
168
+ redirect_to error_path(result[:error], **app_context_params)
150
169
  end
151
170
 
152
171
  def unsnooze
@@ -166,7 +185,19 @@ module RailsErrorDashboard
166
185
 
167
186
  def update_status
168
187
  result = Commands::UpdateErrorStatus.call(params[:id], status: params[:status], comment: params[:comment])
169
- redirect_to error_path(result[:error], **app_context_params)
188
+ error = result[:error]
189
+
190
+ # The command refuses some transitions; saying nothing made a refused
191
+ # change look like a successful one.
192
+ if result[:success]
193
+ flash[:notice] = red_t("red.flash.status.updated", status: status_flash_label(error.status))
194
+ elsif result[:reason] == :invalid_transition
195
+ flash[:alert] = red_t("red.flash.status.invalid_transition",
196
+ from: status_flash_label(error.status), to: status_flash_label(params[:status]))
197
+ else
198
+ flash[:alert] = red_t("red.flash.status.unknown")
199
+ end
200
+ redirect_to error_path(error, **app_context_params)
170
201
  end
171
202
 
172
203
  def create_issue
@@ -361,8 +392,12 @@ module RailsErrorDashboard
361
392
  result = Queries::ReleaseTimeline.call(days, application_id: @current_application_id)
362
393
  all_releases = result[:releases]
363
394
  @summary = result[:summary]
395
+ # Taken from every release, before pagination: the summary cards describe
396
+ # the whole period, not the page in view.
397
+ @current_release = all_releases.find { |r| r[:current] }
398
+ @problematic_release_count = all_releases.count { |r| r[:problematic] }
364
399
 
365
- @pagy, @releases = pagy(:offset, all_releases, limit: params[:per_page] || 25)
400
+ @pagy, @releases = pagy(:offset, all_releases, limit: per_page_param)
366
401
  end
367
402
 
368
403
  def storms
@@ -378,7 +413,7 @@ module RailsErrorDashboard
378
413
  all_entries = result[:entries]
379
414
  @summary = result[:summary]
380
415
 
381
- @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
416
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
382
417
  end
383
418
 
384
419
  def deprecations
@@ -398,7 +433,7 @@ module RailsErrorDashboard
398
433
  @total_count = all_deprecations.sum { |d| d[:count] }
399
434
  @affected_count = all_deprecations.flat_map { |d| d[:error_ids] }.uniq.size
400
435
 
401
- @pagy, @deprecations = pagy(:offset, all_deprecations, limit: params[:per_page] || 25)
436
+ @pagy, @deprecations = pagy(:offset, all_deprecations, limit: per_page_param)
402
437
  end
403
438
 
404
439
  def n_plus_one_summary
@@ -418,7 +453,7 @@ module RailsErrorDashboard
418
453
  @total_count = all_patterns.sum { |p| p[:count] }
419
454
  @affected_count = all_patterns.flat_map { |p| p[:error_ids] }.uniq.size
420
455
 
421
- @pagy, @patterns = pagy(:offset, all_patterns, limit: params[:per_page] || 25)
456
+ @pagy, @patterns = pagy(:offset, all_patterns, limit: per_page_param)
422
457
  end
423
458
 
424
459
  def cache_health_summary
@@ -439,7 +474,7 @@ module RailsErrorDashboard
439
474
  @avg_hit_rate = non_nil_rates.any? ? (non_nil_rates.sum / non_nil_rates.size).round(1) : nil
440
475
  @total_cache_ops = all_entries.sum { |e| e[:reads] + e[:writes] }
441
476
 
442
- @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
477
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
443
478
  end
444
479
 
445
480
  def job_health_summary
@@ -459,7 +494,7 @@ module RailsErrorDashboard
459
494
  @total_failed = all_entries.sum { |e| e[:failed] || e[:errored] || 0 }
460
495
  @adapters_detected = all_entries.map { |e| e[:adapter] }.uniq
461
496
 
462
- @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
497
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
463
498
  end
464
499
 
465
500
  def database_health_summary
@@ -490,7 +525,7 @@ module RailsErrorDashboard
490
525
  @total_dead = all_entries.sum { |e| e[:dead] }
491
526
  @total_waiting = all_entries.sum { |e| e[:waiting] }
492
527
 
493
- @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
528
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
494
529
  end
495
530
 
496
531
  def swallowed_exceptions
@@ -515,7 +550,7 @@ module RailsErrorDashboard
515
550
  @total_rescue_count = all_entries.sum { |e| e[:rescue_count] }
516
551
  @total_raise_count = all_entries.sum { |e| e[:raise_count] }
517
552
 
518
- @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
553
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
519
554
  end
520
555
 
521
556
  def rack_attack_summary
@@ -545,7 +580,7 @@ module RailsErrorDashboard
545
580
  # never silently understated.
546
581
  @overflow_count = result[:overflow_count].to_i
547
582
 
548
- @pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
583
+ @pagy, @events = pagy(:offset, all_events, limit: per_page_param)
549
584
  end
550
585
 
551
586
  def actioncable_health_summary
@@ -566,7 +601,7 @@ module RailsErrorDashboard
566
601
  @total_events = all_channels.sum { |c| c[:total_events] }
567
602
  @total_rejections = all_channels.sum { |c| c[:rejection_count] }
568
603
 
569
- @pagy, @channels = pagy(:offset, all_channels, limit: params[:per_page] || 25)
604
+ @pagy, @channels = pagy(:offset, all_channels, limit: per_page_param)
570
605
  end
571
606
 
572
607
  def llm_health_summary
@@ -586,7 +621,7 @@ module RailsErrorDashboard
586
621
  @totals = result[:totals]
587
622
  all_models = result[:models]
588
623
 
589
- @pagy, @models = pagy(:offset, all_models, limit: params[:per_page] || 25)
624
+ @pagy, @models = pagy(:offset, all_models, limit: per_page_param)
590
625
  end
591
626
 
592
627
  def activestorage_health_summary
@@ -607,7 +642,7 @@ module RailsErrorDashboard
607
642
  @total_operations = all_services.sum { |s| s[:total_operations] }
608
643
  @errors_with_storage = all_services.sum { |s| s[:error_count] }
609
644
 
610
- @pagy, @services = pagy(:offset, all_services, limit: params[:per_page] || 25)
645
+ @pagy, @services = pagy(:offset, all_services, limit: per_page_param)
611
646
  end
612
647
 
613
648
  def diagnostic_dumps
@@ -620,8 +655,10 @@ module RailsErrorDashboard
620
655
  scope = DiagnosticDump.recent
621
656
  scope = scope.where(application_id: @current_application_id) if @current_application_id.present?
622
657
  @total_dumps = scope.count
658
+ # The newest dump overall; the "latest" cards must not change with the page.
659
+ @latest_dump = scope.first
623
660
 
624
- @pagy, @dumps = pagy(:offset, scope, limit: params[:per_page] || 25)
661
+ @pagy, @dumps = pagy(:offset, scope, limit: per_page_param)
625
662
  end
626
663
 
627
664
  def create_diagnostic_dump
@@ -755,15 +792,30 @@ module RailsErrorDashboard
755
792
  # a request like ?days=99999999 would scan the full table on every health
756
793
  # query, defeating index pruning and burning CPU.
757
794
  def days_param(default:)
758
- raw = params[:days].presence || default
795
+ # days[x]=1 and days[]=7 are not numbers; to_i on them raised.
796
+ raw = params[:days]
797
+ raw = default unless raw.is_a?(String) && raw.present?
759
798
  raw.to_i.clamp(1, 365)
760
799
  end
761
800
 
762
801
  def set_application_context
802
+ # application_id[x]=1 is not an id. Left in params, the nested value reached
803
+ # url_for in the layout ("unable to convert unpermitted parameters to hash")
804
+ # and every page answered 500. An array of ids is still allowed.
805
+ params.delete(:application_id) if params[:application_id].is_a?(ActionController::Parameters)
806
+
763
807
  @current_application_id = params[:application_id].presence
764
808
  @applications = Application.ordered_by_name.pluck(:name, :id)
765
809
  end
766
810
 
811
+ # True when a brand-new error cannot simply be put on top of this list: any
812
+ # filter other than the application is active (the row may not match it), a
813
+ # sort is chosen, or this is not the first page.
814
+ def live_prepend_unsafe?
815
+ other_filters = FILTERABLE_PARAMS - [ :application_id ]
816
+ other_filters.any? { |key| params[key].present? } || params[:page].to_s.to_i > 1
817
+ end
818
+
767
819
  # Preserves the application_id param across redirects
768
820
  def app_context_params
769
821
  @current_application_id.present? ? { application_id: @current_application_id } : {}
@@ -777,6 +829,12 @@ module RailsErrorDashboard
777
829
  [ "red", kind, error.external_issue_provider, repo.presence || "-", error.external_issue_number ].join("/")
778
830
  end
779
831
 
832
+ # The status labels the index and show pages already use; an unlabelled
833
+ # value ("new" has no key) falls back to the raw text.
834
+ def status_flash_label(status)
835
+ red_t("red.errors.row.status.#{status}", default: status.to_s.tr("_", " "))
836
+ end
837
+
780
838
  def fetch_platform_issue(error)
781
839
  return nil unless error.external_issue_url.present? && error.external_issue_number.present?
782
840
  return nil unless RailsErrorDashboard.configuration.enable_issue_tracking
@@ -784,13 +842,25 @@ module RailsErrorDashboard
784
842
  # The repository belongs in the key: two repositories sharing an issue
785
843
  # number would otherwise read each other's cached state.
786
844
  cache_key = issue_cache_key("issue_state", error)
787
- Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
788
- client = Services::IssueTrackerClient.for_error(error)
789
- return nil unless client
790
-
791
- result = client.fetch_issue(number: error.external_issue_number)
792
- result[:success] ? result : nil
845
+ #
846
+ # The negative result is cached too, as NO_PLATFORM_DATA rather than nil.
847
+ # `return nil unless client` used to leave the METHOD, so the block never
848
+ # finished and nothing was written; a raising client skipped the write the
849
+ # same way. Either meant one API attempt (and its timeout) per page view.
850
+ cached = Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
851
+ fetch_issue_state(error) || NO_PLATFORM_DATA
793
852
  end
853
+ cached == NO_PLATFORM_DATA ? nil : cached
854
+ rescue => e
855
+ nil
856
+ end
857
+
858
+ def fetch_issue_state(error)
859
+ client = Services::IssueTrackerClient.for_error(error)
860
+ return nil unless client
861
+
862
+ result = client.fetch_issue(number: error.external_issue_number)
863
+ result[:success] ? result : nil
794
864
  rescue => e
795
865
  nil
796
866
  end
@@ -801,13 +871,20 @@ module RailsErrorDashboard
801
871
 
802
872
  # Cache for 60 seconds to avoid API hammering on page refreshes
803
873
  cache_key = issue_cache_key("issue_comments", error)
874
+ # (an empty list is cached like any other answer -- see fetch_platform_issue)
804
875
  Rails.cache.fetch(cache_key, expires_in: 60.seconds) do
805
- client = Services::IssueTrackerClient.for_error(error)
806
- return [] unless client
876
+ fetch_issue_comments(error)
877
+ end || []
878
+ rescue => e
879
+ []
880
+ end
807
881
 
808
- result = client.fetch_comments(number: error.external_issue_number, per_page: 20)
809
- result[:success] ? result[:comments] : []
810
- end
882
+ def fetch_issue_comments(error)
883
+ client = Services::IssueTrackerClient.for_error(error)
884
+ return [] unless client
885
+
886
+ result = client.fetch_comments(number: error.external_issue_number, per_page: 20)
887
+ result[:success] ? Array(result[:comments]) : []
811
888
  rescue => e
812
889
  []
813
890
  end
@@ -295,6 +295,9 @@ module RailsErrorDashboard
295
295
  status: "new",
296
296
  reopened_at: Time.current
297
297
  )
298
+ # Someone reopened the issue on the forge: a user action, so the cached
299
+ # stat cards are invalidated now rather than at their TTL.
300
+ Services::AnalyticsCacheManager.clear
298
301
  end
299
302
  end
300
303
  end
@@ -37,7 +37,39 @@ module RailsErrorDashboard
37
37
  value.to_json.gsub("</", '<\/').html_safe
38
38
  end
39
39
 
40
- # Returns Bootstrap color class for error severity
40
+ # Returns the value only when it is an absolute http(s) URL, otherwise nil.
41
+ # Every URL that comes from a stored column or a third-party API goes
42
+ # through this before it becomes an href, an img src or a window.open
43
+ # target. ERB escaping and link_to stop attribute breakout; neither rejects
44
+ # a scheme such as "javascript:". Same rule as the write path
45
+ # (Commands::LinkExistingIssue), so a row stored before that check existed
46
+ # is still inert when rendered.
47
+ def safe_external_url(value)
48
+ value if Services::UrlSafety.http_url?(value)
49
+ end
50
+
51
+ DEFAULT_LABEL_COLOR = "#6c757d"
52
+
53
+ # A forge label colour, safe to put in a style attribute. Anything that is
54
+ # not 3 or 6 hex digits becomes neutral grey: ERB escaping keeps a value
55
+ # inside the attribute but does not stop it being CSS ("fff;position:fixed").
56
+ def safe_label_color(raw)
57
+ return DEFAULT_LABEL_COLOR unless raw.is_a?(String)
58
+
59
+ hex = raw.delete_prefix("#")
60
+ hex.match?(/\A(?:\h{6}|\h{3})\z/) ? "##{hex}" : DEFAULT_LABEL_COLOR
61
+ end
62
+
63
+ # Black or white, whichever reads on the given background. Takes the output
64
+ # of safe_label_color, never the raw label value.
65
+ def label_text_color(background)
66
+ hex = background.to_s.delete_prefix("#")
67
+ return "#fff" unless hex.match?(/\A(?:\h{6}|\h{3})\z/)
68
+
69
+ hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
70
+ hex.scan(/../).sum { |pair| pair.to_i(16) } > 382 ? "#000" : "#fff"
71
+ end
72
+
41
73
  # Uses Catppuccin Mocha colors in dark theme via CSS variables
42
74
  # @param severity [Symbol] The severity level (:critical, :high, :medium, :low, :info)
43
75
  # @return [String] Bootstrap color class (danger, warning, info, secondary)
@@ -226,6 +258,19 @@ module RailsErrorDashboard
226
258
  nil
227
259
  end
228
260
 
261
+ # Parses a timestamp that came from somewhere RED does not control (an issue
262
+ # tracker's API). nil for anything that is not a date: a blank, free text, a
263
+ # number, a nested value. Never raises -- one odd comment must not take the
264
+ # error page down with it.
265
+ def safe_parse_time(value)
266
+ return value if value.is_a?(Time) || value.is_a?(DateTime)
267
+ return nil unless value.is_a?(String) && value.present?
268
+
269
+ (Time.zone || Time).parse(value)
270
+ rescue ArgumentError, TypeError, RangeError
271
+ nil
272
+ end
273
+
229
274
  # Renders a relative time ("3 hours ago") that updates automatically
230
275
  # @param time [Time, DateTime, nil] The timestamp to display
231
276
  # @param fallback [String] Text to show if time is nil
@@ -175,6 +175,11 @@ module RailsErrorDashboard
175
175
  }
176
176
  end
177
177
  end
178
+ rescue => e
179
+ # Source context is a nicety. A cache store that is down must not take
180
+ # the error page with it -- the same rule read_coverage_for_file follows.
181
+ Rails.logger.error("[RailsErrorDashboard] read_source_code failed: #{e.class}: #{e.message}")
182
+ nil
178
183
  end
179
184
 
180
185
  # Read coverage data for a file when coverage tracking is active
@@ -204,6 +209,9 @@ module RailsErrorDashboard
204
209
  reader = Services::GitBlameReader.new(frame[:file_path], frame[:line_number])
205
210
  reader.read_blame
206
211
  end
212
+ rescue => e
213
+ Rails.logger.error("[RailsErrorDashboard] read_git_blame failed: #{e.class}: #{e.message}")
214
+ nil
207
215
  end
208
216
 
209
217
  # Generate GitHub/GitLab/Bitbucket link for a frame
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Concerns
5
+ # Delivery of a plain, one-sentence message that is ABOUT the dashboard's
6
+ # own behaviour rather than about one error: "a storm is in progress",
7
+ # "further new-error notifications are suppressed". The per-error jobs
8
+ # build rich payloads from an ErrorLog; these messages have no row.
9
+ #
10
+ # Shared by StormNotificationJob and NotificationBurstSummaryJob.
11
+ module PlainChannelMessage
12
+ private
13
+
14
+ # Slack takes {text:}, Discord {content:}, custom webhooks a
15
+ # machine-readable event. Email and PagerDuty are not used: neither has
16
+ # a sensible shape for a message that is not an incident.
17
+ def deliver_plain_message(message, webhook_payload)
18
+ config = RailsErrorDashboard.configuration
19
+ delivered = false
20
+
21
+ if config.enable_slack_notifications && config.slack_webhook_url.present?
22
+ post_json(config.slack_webhook_url, { text: message })
23
+ delivered = true
24
+ end
25
+
26
+ if config.enable_discord_notifications && config.discord_webhook_url.present?
27
+ post_json(config.discord_webhook_url, { content: message })
28
+ delivered = true
29
+ end
30
+
31
+ if config.enable_webhook_notifications && config.webhook_urls.present?
32
+ config.webhook_urls.each { |url| post_json(url, webhook_payload) }
33
+ delivered = true
34
+ end
35
+
36
+ # An email-only or PagerDuty-only deployment has no channel for this.
37
+ # Say so where an operator can find it rather than dropping it silently.
38
+ unless delivered
39
+ Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord or webhook channel is enabled; message not sent: #{message}")
40
+ end
41
+ end
42
+
43
+ def t(key, locale, **options)
44
+ RailsErrorDashboard::I18nStore.translate(key, locale: locale, **options)
45
+ end
46
+
47
+ def app_name(config)
48
+ config.application_name || ENV["APPLICATION_NAME"] ||
49
+ (defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
50
+ end
51
+
52
+ def post_json(url, payload)
53
+ if defined?(HTTParty)
54
+ HTTParty.post(url, body: payload.to_json,
55
+ headers: { "Content-Type" => "application/json" }, timeout: 10)
56
+ else
57
+ uri = URI(url)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = uri.scheme == "https"
60
+ http.open_timeout = 5
61
+ http.read_timeout = 10
62
+ request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
63
+ request.body = payload.to_json
64
+ http.request(request)
65
+ end
66
+ rescue => e
67
+ Rails.logger.error("[RailsErrorDashboard] #{self.class.name.demodulize} post failed: #{e.message}")
68
+ end
69
+ end
70
+ end
71
+ end