rails_error_dashboard 0.14.4 → 0.16.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -8
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  7. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  8. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
  9. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  11. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  12. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  13. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  14. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  15. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
  16. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  17. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  18. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  19. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  20. data/app/models/rails_error_dashboard/error_log.rb +54 -20
  21. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  22. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  23. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  24. data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
  25. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  26. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
  27. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  28. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  29. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  30. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  31. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  32. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  33. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  34. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  35. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  36. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  37. data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
  38. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  39. data/config/locales/de.yml +87 -5
  40. data/config/locales/en.yml +99 -4
  41. data/config/locales/es.yml +88 -5
  42. data/config/locales/fr.yml +88 -5
  43. data/config/locales/it.yml +88 -5
  44. data/config/locales/ja.yml +86 -4
  45. data/config/locales/pl.yml +89 -5
  46. data/config/locales/pt-BR.yml +88 -5
  47. data/config/locales/ru.yml +89 -5
  48. data/config/locales/uk.yml +89 -5
  49. data/config/locales/zh-CN.yml +86 -4
  50. data/config/routes.rb +6 -0
  51. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  52. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  53. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  54. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  55. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  56. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  57. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
  58. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  59. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
  61. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  62. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  63. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
  64. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  65. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  66. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  67. data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
  68. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  69. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  70. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  71. data/lib/rails_error_dashboard/configuration.rb +61 -4
  72. data/lib/rails_error_dashboard/engine.rb +42 -1
  73. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  74. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  75. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  76. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  77. data/lib/rails_error_dashboard/plugin.rb +19 -3
  78. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  79. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  80. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  81. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  82. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  83. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  84. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  85. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  86. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  87. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  88. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  89. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
  90. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  91. data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
  92. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  93. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  94. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
  95. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  96. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  97. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  98. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  99. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  100. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  101. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  102. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  103. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  104. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  105. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  106. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  107. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  108. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  109. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  110. data/lib/rails_error_dashboard/version.rb +1 -1
  111. data/lib/rails_error_dashboard.rb +28 -0
  112. data/lib/tasks/error_dashboard.rake +2 -3
  113. metadata +29 -2
@@ -11,9 +11,10 @@ module RailsErrorDashboard
11
11
  module PlainChannelMessage
12
12
  private
13
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.
14
+ # Slack takes {text:}, Discord {content:}, Telegram a plain sendMessage,
15
+ # custom webhooks a machine-readable event. Email and PagerDuty are not
16
+ # used: neither has a sensible shape for a message that is not an
17
+ # incident.
17
18
  def deliver_plain_message(message, webhook_payload)
18
19
  config = RailsErrorDashboard.configuration
19
20
  delivered = false
@@ -28,15 +29,20 @@ module RailsErrorDashboard
28
29
  delivered = true
29
30
  end
30
31
 
32
+ if Services::TelegramDelivery.configured?(config)
33
+ post_telegram(message)
34
+ delivered = true
35
+ end
36
+
31
37
  if config.enable_webhook_notifications && config.webhook_urls.present?
32
- config.webhook_urls.each { |url| post_json(url, webhook_payload) }
38
+ config.webhook_urls.each { |url| post_webhook(url, webhook_payload) }
33
39
  delivered = true
34
40
  end
35
41
 
36
42
  # An email-only or PagerDuty-only deployment has no channel for this.
37
43
  # Say so where an operator can find it rather than dropping it silently.
38
44
  unless delivered
39
- Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord or webhook channel is enabled; message not sent: #{message}")
45
+ Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord, Telegram or webhook channel is enabled; message not sent: #{message}")
40
46
  end
41
47
  end
42
48
 
@@ -49,6 +55,27 @@ module RailsErrorDashboard
49
55
  (defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
50
56
  end
51
57
 
58
+ # Custom webhooks go through WebhookDelivery so they are signed when
59
+ # webhook_signing_secret is set. Slack and Discord stay on post_json:
60
+ # their payloads are not ours to sign. Same per-URL rescue as post_json,
61
+ # so one unreachable receiver does not stop the next.
62
+ def post_webhook(url, payload)
63
+ Services::WebhookDelivery.post(url, payload)
64
+ rescue => e
65
+ Rails.logger.error("[RailsErrorDashboard] #{self.class.name.demodulize} post failed: #{e.message}")
66
+ end
67
+
68
+ # No parse_mode: the sentence is sent verbatim, so an app name with a
69
+ # "<" in it cannot break Telegram's HTML parser. The token sits in the
70
+ # request URL, hence the redaction before logging.
71
+ def post_telegram(message)
72
+ Services::TelegramDelivery.send_message(message)
73
+ rescue => e
74
+ Rails.logger.error(
75
+ "[RailsErrorDashboard] #{self.class.name.demodulize} Telegram post failed: #{Services::TelegramDelivery.redact(e.message)}"
76
+ )
77
+ end
78
+
52
79
  def post_json(url, payload)
53
80
  if defined?(HTTParty)
54
81
  HTTParty.post(url, body: payload.to_json,
@@ -59,7 +86,7 @@ module RailsErrorDashboard
59
86
  http.use_ssl = uri.scheme == "https"
60
87
  http.open_timeout = 5
61
88
  http.read_timeout = 10
62
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
89
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
63
90
  request.body = payload.to_json
64
91
  http.request(request)
65
92
  end
@@ -32,7 +32,7 @@ module RailsErrorDashboard
32
32
  http.use_ssl = uri.scheme == "https"
33
33
  http.open_timeout = 5
34
34
  http.read_timeout = 10
35
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
35
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
36
36
  request.body = payload.to_json
37
37
  http.request(request)
38
38
  end
@@ -44,7 +44,7 @@ module RailsErrorDashboard
44
44
  http.use_ssl = uri.scheme == "https"
45
45
  http.open_timeout = 5
46
46
  http.read_timeout = 10
47
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
47
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
48
48
  request.body = payload.to_json
49
49
  http.request(request)
50
50
  end
@@ -44,6 +44,11 @@ module RailsErrorDashboard
44
44
  def self.delete_with_dependents(scope)
45
45
  ids = scope.select(:id)
46
46
 
47
+ # Each occurrence's stored context. Named here for the same reason as the
48
+ # event counts below: delete_all fires no :dependent callback.
49
+ if ErrorOccurrenceContext.table_exists?
50
+ ErrorOccurrenceContext.where(error_log_id: ids).in_batches(of: 1000).delete_all
51
+ end
47
52
  ErrorOccurrence.where(error_log_id: ids).in_batches(of: 1000).delete_all
48
53
  # Hour buckets for storm-shed events. Listed explicitly because delete_all
49
54
  # does not fire the has_many :dependent callback on ErrorLog -- without
@@ -70,6 +75,7 @@ module RailsErrorDashboard
70
75
  # errors — clean them up BEFORE the early return below, which fires
71
76
  # whenever no error logs happen to be expired.
72
77
  cleanup_rack_attack_events(cutoff)
78
+ cleanup_missing_translations(cutoff)
73
79
  cleanup_storm_flush_batches(cutoff)
74
80
  cleanup_diagnostic_dumps(cutoff)
75
81
  cleanup_swallowed_exceptions(cutoff)
@@ -211,6 +217,31 @@ module RailsErrorDashboard
211
217
  )
212
218
  end
213
219
 
220
+ # Expire missing-translation rows not seen since the cutoff. A key that
221
+ # is still missing is still being seen, so it keeps its row (and its
222
+ # first_seen_at); only keys that stopped missing age out.
223
+ #
224
+ # Not gated on the feature flag: rows collected while tracking was on
225
+ # must still age out after it is turned off.
226
+ def cleanup_missing_translations(cutoff)
227
+ return unless MissingTranslation.table_exists?
228
+
229
+ deleted = 0
230
+ MissingTranslation.where("last_seen_at < ?", cutoff).in_batches(of: 1000) do |batch|
231
+ deleted += batch.delete_all
232
+ end
233
+
234
+ if deleted > 0
235
+ RailsErrorDashboard::Logger.info(
236
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} missing translations"
237
+ )
238
+ end
239
+ rescue => e
240
+ RailsErrorDashboard::Logger.debug(
241
+ "[RailsErrorDashboard] Missing translation retention cleanup failed: #{e.class} - #{e.message}"
242
+ )
243
+ end
244
+
214
245
  # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
215
246
  # failure here (e.g. table not yet migrated) never blocks error cleanup.
216
247
  def cleanup_rack_attack_events(cutoff)
@@ -12,11 +12,16 @@ module RailsErrorDashboard
12
12
  class ScheduledDigestJob < ApplicationJob
13
13
  queue_as :default
14
14
 
15
+ # @param period [String, Symbol, nil] "daily" or "weekly"; nil takes
16
+ # config.digest_frequency, which was shown on the Settings page but
17
+ # read by nothing until now.
15
18
  # @param locale [String, nil] resolved at enqueue time. nil for jobs
16
19
  # enqueued by a pre-Phase-4 version still draining from the queue.
17
- def perform(period: "daily", application_id: nil, locale: nil)
20
+ def perform(period: nil, application_id: nil, locale: nil)
18
21
  return unless RailsErrorDashboard.configuration.enable_scheduled_digests
19
22
 
23
+ period ||= RailsErrorDashboard.configuration.digest_frequency || :daily
24
+
20
25
  recipients = effective_recipients
21
26
  return if recipients.blank?
22
27
 
@@ -32,7 +32,7 @@ module RailsErrorDashboard
32
32
  http.open_timeout = 5 # 5 seconds to establish connection
33
33
  http.read_timeout = 10 # 10 seconds to read response
34
34
 
35
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
35
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
36
36
  request.body = Services::SlackPayloadBuilder.call(error_log, locale: locale).to_json
37
37
 
38
38
  response = http.request(request)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Job to send error notifications to a Telegram chat via the Bot API
5
+ class TelegramErrorNotificationJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ # @param locale [String, nil] resolved at enqueue time.
9
+ def perform(error_log_id, locale = nil)
10
+ return unless Services::TelegramDelivery.credentials?
11
+
12
+ error_log = ErrorLog.find(error_log_id)
13
+ payload = Services::TelegramPayloadBuilder.call(error_log, locale: job_locale(locale))
14
+
15
+ Services::TelegramDelivery.post(payload)
16
+ rescue StandardError => e
17
+ # The bot token is in the request URL; an exception that quotes it must
18
+ # not reach the log as-is.
19
+ Rails.logger.error(
20
+ "[RailsErrorDashboard] Failed to send Telegram notification: #{Services::TelegramDelivery.redact(e.message)}"
21
+ )
22
+ Rails.logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
23
+ end
24
+ end
25
+ end
@@ -31,13 +31,12 @@ module RailsErrorDashboard
31
31
 
32
32
  def send_webhook(url, payload, error_log)
33
33
  headers = {
34
- "Content-Type" => "application/json",
35
- "User-Agent" => "RailsErrorDashboard/1.0",
36
34
  "X-Error-Dashboard-Event" => "error.created",
37
35
  "X-Error-Dashboard-ID" => error_log.id.to_s
38
36
  }
39
37
 
40
- response = post_json(url, payload, headers)
38
+ # Serialises, signs (when webhook_signing_secret is set) and posts.
39
+ response = Services::WebhookDelivery.post(url, payload, headers: headers)
41
40
 
42
41
  unless response_success?(response)
43
42
  Rails.logger.warn("[RailsErrorDashboard] Webhook failed for #{url}: #{response_code(response)}")
@@ -46,21 +45,6 @@ module RailsErrorDashboard
46
45
  Rails.logger.error("[RailsErrorDashboard] Webhook error for #{url}: #{e.message}")
47
46
  end
48
47
 
49
- def post_json(url, payload, headers)
50
- if defined?(HTTParty)
51
- HTTParty.post(url, body: payload.to_json, headers: headers, timeout: 10)
52
- else
53
- uri = URI(url)
54
- http = Net::HTTP.new(uri.host, uri.port)
55
- http.use_ssl = uri.scheme == "https"
56
- http.open_timeout = 5
57
- http.read_timeout = 10
58
- request = Net::HTTP::Post.new(uri.path, headers)
59
- request.body = payload.to_json
60
- http.request(request)
61
- end
62
- end
63
-
64
48
  def response_success?(response)
65
49
  if response.respond_to?(:success?)
66
50
  response.success?
@@ -36,6 +36,8 @@ module RailsErrorDashboard
36
36
 
37
37
  # Association for tracking individual error occurrences
38
38
  has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
39
+ # Deleted before the occurrences, without loading their payloads
40
+ has_many :occurrence_contexts, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete_all
39
41
 
40
42
  # Hour buckets for storm-shed events. delete_all, not destroy: these rows
41
43
  # are pure counters with no callbacks, and a group can own one per hour.
@@ -179,6 +181,14 @@ module RailsErrorDashboard
179
181
  Services::SeverityClassifier.critical?(error_type)
180
182
  end
181
183
 
184
+ # Whether the backtrace this row shows was sent by a client through
185
+ # ManualErrorReporter. Its frames describe code that ran on the client, so
186
+ # View Source, git blame and the Markdown formatter read no file for them.
187
+ # nil (a row from before the column, or an app not yet migrated) is server.
188
+ def client_backtrace?
189
+ has_attribute?(:backtrace_source) && backtrace_source == "client"
190
+ end
191
+
182
192
  # Check if error is recent (< 1 hour)
183
193
  def recent?
184
194
  occurred_at >= 1.hour.ago
@@ -204,6 +214,13 @@ module RailsErrorDashboard
204
214
  Commands::LogError.call(exception, context)
205
215
  end
206
216
 
217
+ # nil rather than a wrong user: an integer column casts "3f2a9c1e-..." to 3
218
+ def self.storable_user_id(user_id)
219
+ return user_id unless type_for_attribute("user_id").type == :integer
220
+
221
+ Integer(user_id.to_s, 10, exception: false)
222
+ end
223
+
207
224
  # Mark error as resolved (delegates to Command)
208
225
  def resolve!(resolution_data = {})
209
226
  Commands::ResolveError.call(id, resolution_data)
@@ -332,14 +349,7 @@ module RailsErrorDashboard
332
349
  []
333
350
  end
334
351
 
335
- lines.first(20).map do |line|
336
- # Extract file path and method name, ignore line numbers
337
- if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
338
- "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
339
- elsif line =~ %r{([^/]+\.rb)}
340
- Regexp.last_match(1)
341
- end
342
- end.compact.uniq
352
+ lines.first(20).map { |line| Services::BacktraceProcessor.frame_key(line) }.compact.uniq
343
353
  end
344
354
 
345
355
  # Calculate backtrace signature — delegates to Service
@@ -415,13 +425,8 @@ module RailsErrorDashboard
415
425
  return {} unless RailsErrorDashboard.configuration.enable_occurrence_patterns
416
426
  return {} unless defined?(Services::PatternDetector)
417
427
 
418
- timestamps = self.class
419
- .where(error_type: error_type, platform: platform)
420
- .where("occurred_at >= ?", days.days.ago)
421
- .pluck(:occurred_at)
422
-
423
428
  Services::PatternDetector.analyze_cyclical_pattern(
424
- timestamps: timestamps,
429
+ timestamps: pattern_timestamps(days: days),
425
430
  days: days
426
431
  )
427
432
  end
@@ -433,13 +438,42 @@ module RailsErrorDashboard
433
438
  return [] unless RailsErrorDashboard.configuration.enable_occurrence_patterns
434
439
  return [] unless defined?(Services::PatternDetector)
435
440
 
436
- timestamps = self.class
437
- .where(error_type: error_type, platform: platform)
438
- .where("occurred_at >= ?", days.days.ago)
439
- .order(:occurred_at)
440
- .pluck(:occurred_at)
441
+ Services::PatternDetector.detect_bursts(timestamps: pattern_timestamps(days: days))
442
+ end
443
+
444
+ # The most recent occurrence timestamps a Pattern Insights sample reads.
445
+ # A grouped row holds one occurred_at for hundreds of events, so the
446
+ # pattern used to be computed from one timestamp per error: the per-event
447
+ # rows are what a daily or weekly rhythm is visible in. Capped so an error
448
+ # with a million occurrences does not load a million Time objects to
449
+ # bucket them by hour; the newest PATTERN_SAMPLE_LIMIT are enough for a
450
+ # distribution and for the bursts of the last week.
451
+ PATTERN_SAMPLE_LIMIT = 10_000
452
+
453
+ def pattern_timestamps(days:)
454
+ since = days.days.ago
455
+ grouped = self.class.where(error_type: error_type, platform: platform)
456
+
457
+ if occurrence_rows_available?
458
+ timestamps = ErrorOccurrence.where(error_log_id: grouped.select(:id))
459
+ .where("occurred_at >= ?", since)
460
+ .order(occurred_at: :desc)
461
+ .limit(PATTERN_SAMPLE_LIMIT)
462
+ .pluck(:occurred_at)
463
+ .reverse
464
+ return timestamps if timestamps.any?
465
+ end
441
466
 
442
- Services::PatternDetector.detect_bursts(timestamps: timestamps)
467
+ # No per-event rows: an install from before the occurrences table, or
468
+ # rows captured while a storm was being shed. The grouped rows are the
469
+ # only timestamps there are.
470
+ grouped.where("occurred_at >= ?", since).order(:occurred_at).pluck(:occurred_at)
471
+ end
472
+
473
+ def occurrence_rows_available?
474
+ defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
475
+ rescue StandardError
476
+ false
443
477
  end
444
478
 
445
479
  private
@@ -10,6 +10,7 @@ module RailsErrorDashboard
10
10
  self.table_name = "rails_error_dashboard_error_occurrences"
11
11
 
12
12
  belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
13
+ has_one :context, class_name: "RailsErrorDashboard::ErrorOccurrenceContext", dependent: :delete
13
14
 
14
15
  # Only define user association if User model exists
15
16
  if defined?(::User)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # The moment-of-failure context of one occurrence: breadcrumbs, system
5
+ # health, local and instance variables, the same JSON strings the error row
6
+ # holds for its latest capture. Commands::RecordOccurrenceContext writes them
7
+ # and keeps the newest config.occurrence_context_limit per error.
8
+ class ErrorOccurrenceContext < ErrorLogsRecord
9
+ self.table_name = "rails_error_dashboard_error_occurrence_contexts"
10
+
11
+ belongs_to :error_occurrence, class_name: "RailsErrorDashboard::ErrorOccurrence"
12
+ belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
13
+ end
14
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # A translation key the host app looked up and did not have, counted per
5
+ # (locale, key, application).
6
+ #
7
+ # Rows are written by Commands::FlushMissingTranslations from the tracker's
8
+ # in-memory buffer, never one per lookup. Inherits ErrorLogsRecord so
9
+ # separate-database routing applies.
10
+ class MissingTranslation < ErrorLogsRecord
11
+ self.table_name = "rails_error_dashboard_missing_translations"
12
+
13
+ # Not a translation key: a synthetic row holding the misses the tracker
14
+ # dropped because its per-thread buffer was full, kept so the totals the
15
+ # dashboard shows are never silently understated. Stored under a locale
16
+ # no real lookup uses. The tracker owns the values; aliased here so the
17
+ # writer and the scopes below cannot drift apart.
18
+ OVERFLOW_KEY = Services::MissingTranslationTracker::OVERFLOW_KEY
19
+ OVERFLOW_LOCALE = Services::MissingTranslationTracker::OVERFLOW_LOCALE
20
+
21
+ belongs_to :application, optional: true
22
+
23
+ validates :locale, presence: true
24
+ validates :translation_key, presence: true
25
+ validates :miss_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
26
+
27
+ scope :for_application, ->(app_id) { where(application_id: app_id) }
28
+ scope :seen_since, ->(time) { where("last_seen_at >= ?", time) }
29
+ scope :overflow, -> { where(translation_key: OVERFLOW_KEY) }
30
+ scope :real, -> { where.not(translation_key: OVERFLOW_KEY) }
31
+
32
+ # The table may not be migrated yet on a host that upgraded the gem before
33
+ # running the migration. Everything that touches this model on a request
34
+ # path checks first and must never raise.
35
+ def self.table_exists?
36
+ connection.table_exists?(table_name)
37
+ rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
38
+ raise
39
+ rescue StandardError
40
+ false
41
+ end
42
+ end
43
+ end
@@ -1156,6 +1156,12 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1156
1156
  <% if RailsErrorDashboard.configuration.enable_storm_protection %>
1157
1157
  <% diag_items << { path: storms_errors_path(nav_params), icon: 'bi-cloud-lightning-rain', label: red_t("red.nav.storms") } %>
1158
1158
  <% end %>
1159
+ <% if RailsErrorDashboard.configuration.enable_missing_translation_tracking %>
1160
+ <% diag_items << { path: missing_translations_errors_path(nav_params), icon: 'bi-translate', label: red_t("red.nav.missing_translations") } %>
1161
+ <% end %>
1162
+ <% if RailsErrorDashboard.configuration.enable_crash_capture %>
1163
+ <% diag_items << { path: boot_errors_errors_path(nav_params), icon: 'bi-power', label: red_t("red.nav.boot_errors") } %>
1164
+ <% end %>
1159
1165
 
1160
1166
  <% if diag_items.any? %>
1161
1167
  <div style="margin-bottom: var(--space-2);" id="navDiagSection">
@@ -1552,7 +1558,11 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
1552
1558
  });
1553
1559
 
1554
1560
  if (typeof hljs.lineNumbersBlock === 'function') {
1555
- codeBlocks.forEach(function(block) { hljs.lineNumbersBlock(block); });
1561
+ // The gutter shows the file's own line numbers: the excerpt starts at
1562
+ // data-start-line, not at 1.
1563
+ codeBlocks.forEach(function(block) {
1564
+ hljs.lineNumbersBlock(block, { startFrom: parseInt(block.dataset.startLine) || 1 });
1565
+ });
1556
1566
  } else if (typeof hljs.initLineNumbersOnLoad === 'function') {
1557
1567
  hljs.initLineNumbersOnLoad();
1558
1568
  }
@@ -2504,7 +2514,9 @@ document.addEventListener('DOMContentLoaded', function() {
2504
2514
  case 'toggle-next':
2505
2515
  var nextEl = actionEl.nextElementSibling;
2506
2516
  if (nextEl) {
2507
- var isHidden = nextEl.style.display === 'none' || nextEl.style.display === '';
2517
+ // The computed display: an open element usually has no inline display
2518
+ // at all, and reading '' as hidden made the first click a no-op.
2519
+ var isHidden = window.getComputedStyle(nextEl).display === 'none';
2508
2520
  nextEl.style.display = isHidden ? (actionEl.dataset.redDisplay || 'block') : 'none';
2509
2521
  var chev = actionEl.querySelector('.chev');
2510
2522
  if (chev) {
@@ -0,0 +1,45 @@
1
+ <!-- Captured occurrences: the ones whose context is still stored -->
2
+ <% if occurrences.any? %>
3
+ <div class="card mb-4" id="section-captured-occurrences">
4
+ <div class="card-header">
5
+ <h5 class="mb-0">
6
+ <i class="bi bi-camera"></i> <%= red_t("red.errors.captured_occurrences.title") %>
7
+ </h5>
8
+ <small class="text-muted"><%= red_t("red.errors.captured_occurrences.hint") %></small>
9
+ </div>
10
+ <div class="card-body p-0">
11
+ <div class="table-responsive">
12
+ <table class="table table-hover mb-0">
13
+ <thead class="table-light">
14
+ <tr>
15
+ <th><%= red_t("red.errors.captured_occurrences.column_occurrence") %></th>
16
+ <th><%= red_t("red.errors.captured_occurrences.column_occurred_at") %></th>
17
+ <th><%= red_t("red.errors.captured_occurrences.column_version") %></th>
18
+ <th></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% occurrences.each do |occurrence| %>
23
+ <tr>
24
+ <td>
25
+ <code>#<%= occurrence.id %></code>
26
+ <% if occurrence.id == current_id %>
27
+ <span class="badge bg-info text-dark ms-1"><%= red_t("red.errors.captured_occurrences.viewing") %></span>
28
+ <% end %>
29
+ </td>
30
+ <td><%= local_time(occurrence.occurred_at) %></td>
31
+ <td>
32
+ <% version = occurrence.app_version.presence || occurrence.git_sha.to_s.first(7).presence %>
33
+ <small class="text-muted"><%= version || red_t("red.common.not_available") %></small>
34
+ </td>
35
+ <td class="text-end">
36
+ <%= link_to red_t("red.errors.captured_occurrences.view"), error_path(error, occurrence: occurrence.id, **app_context), class: "btn btn-sm btn-outline-primary" %>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <% end %>
@@ -1,7 +1,9 @@
1
1
  <%# The locale is part of the cache key: this fragment now contains
2
2
  translated text, and without it the first locale to render an error
3
3
  would be served to every other one. %>
4
- <% cache [error, 'error_details_v2', red_locale] do %>
4
+ <%# Plus the source-integration configuration and a TTL, and not at all
5
+ while coverage is live: see BacktraceHelper#source_view_cache_key. %>
6
+ <% cache_if source_view_cacheable?, [error, 'error_details_v3', red_locale, source_view_cache_key, error.client_backtrace?], **source_view_cache_options do %>
5
7
  <div class="card mb-4" id="section-error-info">
6
8
  <div class="card-header bg-danger text-white">
7
9
  <div class="d-flex justify-content-between align-items-center">
@@ -106,6 +108,8 @@
106
108
  <% if error.backtrace.present? %>
107
109
  <% frames = parse_backtrace(error.backtrace) %>
108
110
  <% app_frames = filter_app_code(frames) %>
111
+ <%# A client's frames describe code that ran on the client: no file is read for them %>
112
+ <% source_viewable = RailsErrorDashboard.configuration.enable_source_code_integration && !error.client_backtrace? %>
109
113
  <% framework_frames = filter_framework_code(frames) %>
110
114
 
111
115
  <% if app_frames.any? %>
@@ -120,6 +124,11 @@
120
124
  </div>
121
125
  </div>
122
126
  <div class="card-body p-0">
127
+ <% if RailsErrorDashboard.configuration.enable_source_code_integration && error.client_backtrace? %>
128
+ <div class="alert alert-info m-3 mb-0" id="client-backtrace-note" style="font-size: 12px;">
129
+ <i class="bi bi-phone"></i> <%= red_t("red.errors.info.backtrace.client_report_note") %>
130
+ </div>
131
+ <% end %>
123
132
  <div class="code-block p-3" style="max-height: none; overflow-y: visible; overflow-x: auto;">
124
133
  <% app_frames.each_with_index do |frame, index| %>
125
134
  <div class="backtrace-frame-wrapper mb-2">
@@ -131,7 +140,7 @@
131
140
  <%= frame_icon(frame[:category]) %> <%= frame[:short_path] %>:<%= frame[:line_number] %>
132
141
  </span> in <span class="backtrace-method-name">`<%= frame[:method_name] %>'</span>
133
142
  </div>
134
- <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
143
+ <% if source_viewable && frame[:category] == :app %>
135
144
  <button class="btn btn-sm btn-outline-secondary" type="button"
136
145
  data-bs-toggle="collapse"
137
146
  data-bs-target="#source-code-<%= index %>"
@@ -143,7 +152,7 @@
143
152
  </div>
144
153
 
145
154
  <!-- Collapsible source code (only for app frames) -->
146
- <% if RailsErrorDashboard.configuration.enable_source_code_integration && frame[:category] == :app %>
155
+ <% if source_viewable && frame[:category] == :app %>
147
156
  <div class="collapse" id="source-code-<%= index %>">
148
157
  <%= render "source_code", frame: frame, error: error, index: index %>
149
158
  </div>
@@ -0,0 +1,12 @@
1
+ <% if occurrence_context %>
2
+ <%# Only the context cards follow ?occurrence=; the rest of the page is the error row's latest capture %>
3
+ <div class="alert alert-info mb-3" id="occurrence-banner" style="display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4);">
4
+ <div style="font-size: 12px;">
5
+ <i class="bi bi-clock-history"></i>
6
+ <strong><%= red_t("red.errors.show.occurrence.title", id: occurrence_context.error_occurrence_id) %></strong>
7
+ &middot; <%= local_time(occurrence_context.error_occurrence&.occurred_at || occurrence_context.created_at) %>
8
+ <div><%= red_t("red.errors.show.occurrence.body") %></div>
9
+ </div>
10
+ <%= link_to red_t("red.errors.show.occurrence.show_latest"), error_path(error, **app_context), class: "btn btn-sm" %>
11
+ </div>
12
+ <% end %>
@@ -187,8 +187,10 @@
187
187
  </small>
188
188
  <% end %>
189
189
  </div>
190
+ <%# data-red-action, not data-turbo-confirm: the layout runs with
191
+ Turbo off and loads no UJS, so nothing read the old attribute. %>
190
192
  <%= button_to unassign_error_path(error), method: :post, class: "btn btn-sm btn-outline-secondary",
191
- data: { turbo_confirm: red_t("red.errors.sidebar.remove_assignment") } do %>
193
+ form: { data: { red_action: "confirm-submit", red_confirm_message: red_t("red.errors.sidebar.remove_assignment") } } do %>
192
194
  <i class="bi bi-x"></i>
193
195
  <% end %>
194
196
  </div>
@@ -20,17 +20,11 @@
20
20
  </div>
21
21
 
22
22
  <% if repo_link %>
23
- <%
24
- # Repository host names are brands and stay English; only the
25
- # sentence around the name is translated.
26
- repo_host = if repo_link.include?("github") then "GitHub"
27
- elsif repo_link.include?("gitlab") then "GitLab"
28
- else "Bitbucket"
29
- end
30
- %>
23
+ <%# Repository host names are brands and stay English; only the
24
+ sentence around the name is translated. %>
31
25
  <%= link_to repo_link, target: "_blank", rel: "noopener",
32
26
  class: "btn btn-sm btn-primary",
33
- title: red_t("red.errors.source_code.view_on", host: repo_host) do %>
27
+ title: red_t("red.errors.source_code.view_on", host: repository_host_label(repo_link)) do %>
34
28
  <i class="bi bi-box-arrow-up-right"></i> <%= red_t("red.errors.source_code.view_source") %>
35
29
  <% end %>
36
30
  <% end %>
@@ -20,6 +20,34 @@
20
20
  </div>
21
21
  </div>
22
22
 
23
+ <% probe = @storage_probe || {} %>
24
+ <% if probe[:status] && probe[:status] != "skipped" %>
25
+ <% reachable = probe[:status] == "ok" %>
26
+ <div class="card mb-4 border-<%= reachable ? "success" : "danger" %>">
27
+ <div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-2">
28
+ <div>
29
+ <h6 class="mb-1">
30
+ <i class="bi <%= reachable ? "bi-check-circle-fill text-success" : "bi-x-circle-fill text-danger" %> me-1"></i>
31
+ <%= red_t("red.health.activestorage.probe.title") %>
32
+ <span class="badge <%= reachable ? "bg-success" : "bg-danger" %> ms-1">
33
+ <%= reachable ? red_t("red.health.activestorage.probe.reachable") : red_t("red.health.activestorage.probe.unreachable") %>
34
+ </span>
35
+ </h6>
36
+ <small class="text-muted">
37
+ <%# The service name comes from config/storage.yml and the error is an exception class: machine values, shown as-is. %>
38
+ <%= red_t("red.health.activestorage.probe.service", name: probe[:service].presence || "?") %>
39
+ <% if reachable %>
40
+ &middot; <%= red_t("red.health.activestorage.probe.latency", ms: probe[:latency_ms]) %>
41
+ <% else %>
42
+ &middot; <code><%= probe[:error] %></code>
43
+ <% end %>
44
+ </small>
45
+ </div>
46
+ <small class="text-muted"><%= red_t("red.health.activestorage.probe.how") %></small>
47
+ </div>
48
+ </div>
49
+ <% end %>
50
+
23
51
  <% if @unique_services == 0 %>
24
52
  <div class="red-empty-state">
25
53
  <i class="bi bi-cloud-arrow-up display-1 text-success mb-3"></i>