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
@@ -12,6 +12,7 @@ module RailsErrorDashboard
12
12
  class_option :slack, type: :boolean, default: false, desc: "Enable Slack notifications"
13
13
  class_option :email, type: :boolean, default: false, desc: "Enable email notifications"
14
14
  class_option :discord, type: :boolean, default: false, desc: "Enable Discord notifications"
15
+ class_option :telegram, type: :boolean, default: false, desc: "Enable Telegram notifications"
15
16
  class_option :pagerduty, type: :boolean, default: false, desc: "Enable PagerDuty notifications"
16
17
  class_option :webhooks, type: :boolean, default: false, desc: "Enable webhook notifications"
17
18
  # Performance options
@@ -71,13 +72,13 @@ module RailsErrorDashboard
71
72
  @selected_features = {}
72
73
 
73
74
  # =====================================================================
74
- # QUESTION 1: Notifications (gated — one y/N opens 5 sub-questions)
75
+ # QUESTION 1: Notifications (gated — one y/N opens 6 sub-questions)
75
76
  # =====================================================================
76
- notification_keys = %i[slack email discord pagerduty webhooks]
77
+ notification_keys = %i[slack email discord telegram pagerduty webhooks]
77
78
  any_notification_cli_flag = notification_keys.any? { |k| options[k] }
78
79
 
79
80
  say "[1/3] Notifications [background/dashboard only — zero request overhead]", :cyan
80
- say " Alert your team via Slack, email, Discord, PagerDuty, or webhooks.", :white
81
+ say " Alert your team via Slack, email, Discord, Telegram, PagerDuty, or webhooks.", :white
81
82
 
82
83
  if any_notification_cli_flag
83
84
  # Individual CLI flags passed — respect them, skip the gate prompt
@@ -90,6 +91,7 @@ module RailsErrorDashboard
90
91
  { key: :slack, name: "Slack", hint: "SLACK_WEBHOOK_URL" },
91
92
  { key: :email, name: "Email", hint: "ERROR_NOTIFICATION_EMAILS" },
92
93
  { key: :discord, name: "Discord", hint: "DISCORD_WEBHOOK_URL" },
94
+ { key: :telegram, name: "Telegram", hint: "TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID" },
93
95
  { key: :pagerduty, name: "PagerDuty", hint: "PAGERDUTY_INTEGRATION_KEY" },
94
96
  { key: :webhooks, name: "Webhooks", hint: "WEBHOOK_URLS" }
95
97
  ]
@@ -294,6 +296,7 @@ module RailsErrorDashboard
294
296
  @enable_slack = @selected_features&.dig(:slack) || options[:slack]
295
297
  @enable_email = @selected_features&.dig(:email) || options[:email]
296
298
  @enable_discord = @selected_features&.dig(:discord) || options[:discord]
299
+ @enable_telegram = @selected_features&.dig(:telegram) || options[:telegram]
297
300
  @enable_pagerduty = @selected_features&.dig(:pagerduty) || options[:pagerduty]
298
301
  @enable_webhooks = @selected_features&.dig(:webhooks) || options[:webhooks]
299
302
 
@@ -417,6 +420,7 @@ module RailsErrorDashboard
417
420
  notification_features << "Slack" if @enable_slack
418
421
  notification_features << "Email" if @enable_email
419
422
  notification_features << "Discord" if @enable_discord
423
+ notification_features << "Telegram" if @enable_telegram
420
424
  notification_features << "PagerDuty" if @enable_pagerduty
421
425
  notification_features << "Webhooks" if @enable_webhooks
422
426
 
@@ -481,6 +485,7 @@ module RailsErrorDashboard
481
485
  say " → Set SLACK_WEBHOOK_URL in .env", :yellow if @enable_slack
482
486
  say " → Set ERROR_NOTIFICATION_EMAILS in .env", :yellow if @enable_email
483
487
  say " → Set DISCORD_WEBHOOK_URL in .env", :yellow if @enable_discord
488
+ say " → Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env", :yellow if @enable_telegram
484
489
  say " → Set PAGERDUTY_INTEGRATION_KEY in .env", :yellow if @enable_pagerduty
485
490
  say " → Set WEBHOOK_URLS in .env", :yellow if @enable_webhooks
486
491
  if @enable_async_logging
@@ -569,7 +574,7 @@ module RailsErrorDashboard
569
574
  system_health: true, # ~1ms per error — GC, memory, threads at exact error moment
570
575
  error_sampling: true, # 50% sampling on non-critical errors — halves storage, still shows patterns
571
576
  # OFF — require credentials, config, or niche use case
572
- slack: false, email: false, discord: false, pagerduty: false, webhooks: false,
577
+ slack: false, email: false, discord: false, telegram: false, pagerduty: false, webhooks: false,
573
578
  source_code_integration: false,
574
579
  git_blame: false,
575
580
  swallowed_exceptions: false,
@@ -41,11 +41,14 @@ RailsErrorDashboard.configure do |config|
41
41
  # warden, session, request, params, cookies, redirect_to, etc.
42
42
  # Return truthy to allow access, falsy to deny (403 Forbidden).
43
43
  #
44
- # NOTE: Devise helpers (current_user, authenticate_user!) are NOT available
45
- # because the engine controller inherits from ActionController::Base, not your
46
- # app's ApplicationController. Use `warden` directly instead.
47
- #
48
- # Devise/Warden example (recommended):
44
+ # Devise helpers (current_user, user_signed_in?, authenticate_user!) work:
45
+ # Devise adds them to ActionController::Base, which the engine's controller
46
+ # inherits. Methods defined only in your app's ApplicationController do not:
47
+ # the lambda raises and access is denied (logged as "authenticate_with lambda
48
+ # raised").
49
+ #
50
+ # Devise examples:
51
+ # config.authenticate_with = -> { current_user&.admin? }
49
52
  # config.authenticate_with = -> { warden.authenticated? }
50
53
  #
51
54
  # Warden with redirect to login:
@@ -127,6 +130,21 @@ RailsErrorDashboard.configure do |config|
127
130
  config.enable_discord_notifications = false
128
131
  # config.discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
129
132
 
133
+ <% end -%>
134
+ <% if @enable_telegram -%>
135
+ # Telegram Notifications - ENABLED
136
+ config.enable_telegram_notifications = true
137
+ config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
138
+ config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
139
+ # To disable: Set config.enable_telegram_notifications = false
140
+
141
+ <% else -%>
142
+ # Telegram Notifications - DISABLED
143
+ # To enable: Set config.enable_telegram_notifications = true and configure a bot token and chat id
144
+ config.enable_telegram_notifications = false
145
+ # config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
146
+ # config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
147
+
130
148
  <% end -%>
131
149
  <% if @enable_pagerduty -%>
132
150
  # PagerDuty Integration - ENABLED (critical errors only)
@@ -145,6 +163,9 @@ RailsErrorDashboard.configure do |config|
145
163
  # Generic Webhook Notifications - ENABLED
146
164
  config.enable_webhook_notifications = true
147
165
  config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
166
+ # Sign every webhook (HMAC-SHA256 over timestamp + body) so receivers can
167
+ # verify it came from this app. Generate with: ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
168
+ # config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"]
148
169
  # To disable: Set config.enable_webhook_notifications = false
149
170
 
150
171
  <% else -%>
@@ -152,6 +173,7 @@ RailsErrorDashboard.configure do |config|
152
173
  # To enable: Set config.enable_webhook_notifications = true and configure webhook URLs
153
174
  config.enable_webhook_notifications = false
154
175
  # config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
176
+ # config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"] # HMAC-SHA256 signature headers
155
177
 
156
178
  <% end -%>
157
179
  # Dashboard base URL (used in notification links)
@@ -389,6 +411,12 @@ RailsErrorDashboard.configure do |config|
389
411
  config.enable_system_health = false
390
412
 
391
413
  <% end -%>
414
+ # Occurrence Context History (NEW in 0.16.0)
415
+ # Each occurrence keeps its own breadcrumbs, system health and variables, so
416
+ # any of an error's recent occurrences can be opened, not only the latest.
417
+ # Kept for the newest N occurrences per error; 0 stops storing them.
418
+ config.occurrence_context_limit = 25
419
+
392
420
  <% if @enable_swallowed_exceptions -%>
393
421
  # Swallowed Exception Detection - ENABLED
394
422
  # Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
@@ -408,6 +436,14 @@ RailsErrorDashboard.configure do |config|
408
436
  # config.swallowed_exception_threshold = 0.95
409
437
 
410
438
  <% end -%>
439
+ # Missing-Translation Tracking - DISABLED
440
+ # Counts every I18n key your app looked up and did not have, by locale, with
441
+ # the first call site, on its own dashboard page (/errors/missing_translations).
442
+ # Wraps I18n.exception_handler and delegates to the handler you already had —
443
+ # the one place RED touches global I18n state, which is why it is opt-in.
444
+ # To enable: Set config.enable_missing_translation_tracking = true
445
+ config.enable_missing_translation_tracking = false
446
+
411
447
  <% if @enable_diagnostic_dump -%>
412
448
  # Diagnostic Dump - ENABLED
413
449
  # On-demand system state snapshot via rake task or dashboard button
@@ -29,10 +29,33 @@ module RailsErrorDashboard
29
29
  error.update!(
30
30
  assigned_to: assignee.truncate(MAX_ASSIGNEE_LENGTH, omission: ""),
31
31
  assigned_at: Time.current,
32
- status: "in_progress" # Auto-transition to in_progress when assigned
32
+ **status_attributes(error)
33
33
  )
34
+ # The stat cards are cached; a reopened error must show up at once.
35
+ Services::AnalyticsCacheManager.clear if error.saved_change_to_resolved?
36
+
34
37
  { success: true, error: error }
35
38
  end
39
+
40
+ private
41
+
42
+ # Assigning means someone is working on it, so the error moves to
43
+ # in_progress. Doing that to a resolved error used to leave
44
+ # resolved: true and resolved_at behind: a row that was in_progress to
45
+ # the eye, resolved to the scopes, and matched by neither the unresolved
46
+ # nor the resolved lookup, so its next recurrence opened a new row.
47
+ # Assigning a resolved error reopens it. wont_fix is sticky (H-6): the
48
+ # assignee is recorded and the status stays.
49
+ def status_attributes(error)
50
+ return {} if error.status == "wont_fix"
51
+
52
+ attrs = { status: "in_progress" }
53
+ if error.resolved? || error.status == "resolved"
54
+ attrs[:resolved] = false
55
+ attrs[:resolved_at] = nil
56
+ end
57
+ attrs
58
+ end
36
59
  end
37
60
  end
38
61
  end
@@ -23,6 +23,7 @@ module RailsErrorDashboard
23
23
 
24
24
  muted_count = 0
25
25
  failed_ids = []
26
+ failures = {}
26
27
  muted_errors = []
27
28
 
28
29
  errors.each do |error|
@@ -33,11 +34,18 @@ module RailsErrorDashboard
33
34
  muted_by: @muted_by,
34
35
  muted_reason: @reason
35
36
  )
37
+ # The comment a single mute writes, so the reason shows in the thread
38
+ if @reason.present?
39
+ error.comments.create!(author_name: @muted_by || "System", body: "Muted notifications: #{@reason}")
40
+ end
36
41
  muted_count += 1
37
42
  muted_errors << error
38
43
  rescue => e
39
44
  failed_ids << error.id
40
- RailsErrorDashboard::Logger.error("Failed to mute error #{error.id}: #{e.message}")
45
+ failures[error.id] = "#{e.class}: #{e.message}"
46
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
47
+ # this is the only record of why the row failed
48
+ Rails.logger.warn("[RailsErrorDashboard] Failed to mute error #{error.id}: #{e.class}: #{e.message}")
41
49
  end
42
50
  end
43
51
 
@@ -50,7 +58,8 @@ module RailsErrorDashboard
50
58
  count: muted_count,
51
59
  total: @error_ids.size,
52
60
  failed_ids: failed_ids,
53
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.mute", count: failed_ids.size) ]
61
+ failures: failures,
62
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.mute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
54
63
  }
55
64
  rescue => e
56
65
  RailsErrorDashboard::Logger.error("Batch mute failed: #{e.message}")
@@ -24,6 +24,7 @@ module RailsErrorDashboard
24
24
 
25
25
  resolved_count = 0
26
26
  failed_ids = []
27
+ failures = {}
27
28
 
28
29
  resolved_errors = []
29
30
 
@@ -40,21 +41,33 @@ module RailsErrorDashboard
40
41
  resolved_errors << error
41
42
  rescue => e
42
43
  failed_ids << error.id
43
- RailsErrorDashboard::Logger.error("Failed to resolve error #{error.id}: #{e.message}")
44
+ failures[error.id] = "#{e.class}: #{e.message}"
45
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
46
+ # this is the only record of why the row failed
47
+ Rails.logger.warn("[RailsErrorDashboard] Failed to resolve error #{error.id}: #{e.class}: #{e.message}")
44
48
  end
45
49
  end
46
50
 
47
- # Dispatch plugin event for batch resolved errors
48
51
  Services::AnalyticsCacheManager.clear if resolved_count.positive?
49
52
 
53
+ # Dispatch plugin event for batch resolved errors
50
54
  PluginRegistry.dispatch(:on_errors_batch_resolved, resolved_errors) if resolved_errors.any?
51
55
 
56
+ # Each error resolved here is as resolved as one resolved on its own
57
+ # page: the host's error_resolved callbacks (the issue tracker closes
58
+ # the linked issue from one) and the instrumentation event fire per
59
+ # error. Only the plugin event is batch-shaped, by design.
60
+ resolved_errors.each do |error|
61
+ Services::ResolutionCallbacks.call(error, resolved_by: @resolved_by_name)
62
+ end
63
+
52
64
  {
53
65
  success: failed_ids.empty?,
54
66
  count: resolved_count,
55
67
  total: @error_ids.size,
56
68
  failed_ids: failed_ids,
57
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.resolve", count: failed_ids.size) ]
69
+ failures: failures,
70
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.resolve", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
58
71
  }
59
72
  rescue => e
60
73
  RailsErrorDashboard::Logger.error("Batch resolve failed: #{e.message}")
@@ -21,6 +21,7 @@ module RailsErrorDashboard
21
21
 
22
22
  unmuted_count = 0
23
23
  failed_ids = []
24
+ failures = {}
24
25
  unmuted_errors = []
25
26
 
26
27
  errors.each do |error|
@@ -35,7 +36,10 @@ module RailsErrorDashboard
35
36
  unmuted_errors << error
36
37
  rescue => e
37
38
  failed_ids << error.id
38
- RailsErrorDashboard::Logger.error("Failed to unmute error #{error.id}: #{e.message}")
39
+ failures[error.id] = "#{e.class}: #{e.message}"
40
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
41
+ # this is the only record of why the row failed
42
+ Rails.logger.warn("[RailsErrorDashboard] Failed to unmute error #{error.id}: #{e.class}: #{e.message}")
39
43
  end
40
44
  end
41
45
 
@@ -48,7 +52,8 @@ module RailsErrorDashboard
48
52
  count: unmuted_count,
49
53
  total: @error_ids.size,
50
54
  failed_ids: failed_ids,
51
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.unmute", count: failed_ids.size) ]
55
+ failures: failures,
56
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.unmute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
52
57
  }
53
58
  rescue => e
54
59
  RailsErrorDashboard::Logger.error("Batch unmute failed: #{e.message}")
@@ -230,6 +230,12 @@ module RailsErrorDashboard
230
230
  return {} unless incoming.include?("\n") || incoming.length > stored.length
231
231
 
232
232
  upgrade = { backtrace: @attributes[:backtrace] }
233
+ # The source goes with the backtrace it describes: a client's backtrace
234
+ # replacing a server one must make the row client, and the reverse. A
235
+ # caller that names none is RED's own server-side code.
236
+ if ErrorLog.column_names.include?("backtrace_source")
237
+ upgrade[:backtrace_source] = @attributes[:backtrace_source].presence || "server"
238
+ end
233
239
  # The row is no longer a reconstructed exemplar: it now carries a real
234
240
  # stack from a real capture, so it must stop describing itself as
235
241
  # "minimal".
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: upsert a snapshot of buffered missing-translation counts.
6
+ #
7
+ # Receives { "locale\x1Fkey" => [count, source] } from
8
+ # MissingTranslationTracker and adds each count to its (locale, key,
9
+ # application) row, creating the row on first sight. find_or_initialize_by
10
+ # + save! rather than a raw upsert, for cross-database compatibility; a
11
+ # concurrent first insert of the same key is retried once as a read.
12
+ #
13
+ # Runs off the request path (executor to_complete, at_exit). Never raises:
14
+ # a failed row is logged at debug and the rest of the snapshot still lands.
15
+ class FlushMissingTranslations
16
+ def self.call(counts:)
17
+ new(counts: counts).call
18
+ end
19
+
20
+ def initialize(counts:)
21
+ @counts = counts || {}
22
+ end
23
+
24
+ def call
25
+ return if @counts.empty?
26
+ return unless MissingTranslation.table_exists?
27
+
28
+ now = Time.current
29
+ app_id = current_application_id
30
+
31
+ @counts.each do |buffer_key, (count, source)|
32
+ # A key can be built from user input, so invalid bytes are possible.
33
+ locale, translation_key = Services::MissingTranslationTracker.parse_key(
34
+ Services::EncodingSanitizer.scrub(buffer_key.to_s)
35
+ )
36
+ next if locale.blank? || translation_key.blank? || count.to_i <= 0
37
+
38
+ upsert(locale: locale, translation_key: translation_key,
39
+ source: source, count: count.to_i, app_id: app_id, now: now)
40
+ end
41
+ rescue => e
42
+ RailsErrorDashboard::Logger.debug(
43
+ "[RailsErrorDashboard] FlushMissingTranslations failed: #{e.class} - #{e.message}"
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ # Insert on first sight; otherwise an atomic SQL increment. A
50
+ # read-modify-write (load miss_count, add, save) would lose increments
51
+ # when two threads flush the same hot key at once, and with per-thread
52
+ # buffers flushing every few seconds that is the common case, not a race
53
+ # worth ignoring.
54
+ def upsert(locale:, translation_key:, source:, count:, app_id:, now:)
55
+ identity = { locale: locale, translation_key: translation_key, application_id: app_id }
56
+ clean_source = Services::EncodingSanitizer.scrub(source.to_s).presence
57
+
58
+ record = MissingTranslation.find_by(identity)
59
+ if record
60
+ increment(record, count: count, source: clean_source, now: now)
61
+ else
62
+ begin
63
+ MissingTranslation.create!(identity.merge(
64
+ miss_count: count, source: clean_source, first_seen_at: now, last_seen_at: now
65
+ ))
66
+ rescue ActiveRecord::RecordNotUnique
67
+ # Another thread inserted the row between our read and our insert:
68
+ # it exists now, so add to it. A NULL application_id lets both
69
+ # inserts through on every database; the query merges those rows.
70
+ record = MissingTranslation.find_by(identity)
71
+ raise unless record
72
+
73
+ increment(record, count: count, source: clean_source, now: now)
74
+ end
75
+ end
76
+ rescue => e
77
+ RailsErrorDashboard::Logger.debug(
78
+ "[RailsErrorDashboard] FlushMissingTranslations.upsert failed for #{locale}.#{translation_key}: #{e.class} - #{e.message}"
79
+ )
80
+ end
81
+
82
+ def increment(record, count:, source:, now:)
83
+ updates = [ "miss_count = miss_count + ?, last_seen_at = ?", count, now ]
84
+ # First-write-wins: the first call site seen is the one worth showing,
85
+ # and overwriting it on every flush would make the column flicker
86
+ # between callers of the same key. Filled in only while still blank.
87
+ if record.source.blank? && source
88
+ updates = [ "miss_count = miss_count + ?, last_seen_at = ?, source = ?", count, now, source ]
89
+ end
90
+ MissingTranslation.where(id: record.id).update_all(updates)
91
+ end
92
+
93
+ def current_application_id
94
+ # The name capture files errors under, so these rows join that app
95
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
96
+ rescue => e
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
@@ -83,10 +83,8 @@ module RailsErrorDashboard
83
83
  end
84
84
 
85
85
  def current_application_id
86
- app_name = RailsErrorDashboard.configuration.application_name
87
- return nil unless app_name.present?
88
-
89
- Application.find_by(name: app_name)&.id
86
+ # The name capture files errors under, so these rows join that app
87
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
90
88
  rescue => e
91
89
  nil
92
90
  end
@@ -391,6 +391,12 @@ module RailsErrorDashboard
391
391
  if ErrorLog.column_names.include?("context_captured_at")
392
392
  create_attrs[:context_captured_at] = create_attrs[:occurred_at]
393
393
  end
394
+ # The backtrace above is the exemplar's first frame, so it is a
395
+ # client's when the counted event was a client report. An entry from
396
+ # a release that did not record the source reads as server.
397
+ if ErrorLog.column_names.include?("backtrace_source")
398
+ create_attrs[:backtrace_source] = entry["capture_source"] == "client" ? "client" : "server"
399
+ end
394
400
  begin
395
401
  # requires_new opens a SAVEPOINT: on PostgreSQL a failed INSERT aborts
396
402
  # its transaction, and every later statement -- including the recovery
@@ -498,12 +504,7 @@ module RailsErrorDashboard
498
504
  end
499
505
 
500
506
  def resolve_application
501
- # Same chain LogError uses — app name is process-global
502
- app_name = RailsErrorDashboard.configuration.application_name ||
503
- ENV["APPLICATION_NAME"] ||
504
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
505
- "Rails Application"
506
- Application.find_or_create_by_name(app_name)
507
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
507
508
  end
508
509
 
509
510
  # Record the interval whose per-event timing was lost.
@@ -90,10 +90,8 @@ module RailsErrorDashboard
90
90
  end
91
91
 
92
92
  def current_application_id
93
- app_name = RailsErrorDashboard.configuration.application_name
94
- return nil unless app_name.present?
95
-
96
- Application.find_by(name: app_name)&.id
93
+ # The name capture files errors under, so these rows join that app
94
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
97
95
  rescue => e
98
96
  nil
99
97
  end
@@ -433,7 +433,7 @@ module RailsErrorDashboard
433
433
  error_type: reported_error_type || @exception.class.name,
434
434
  message: @exception.message,
435
435
  backtrace: truncated_backtrace,
436
- user_id: error_context.user_id,
436
+ user_id: ErrorLog.storable_user_id(error_context.user_id),
437
437
  request_url: error_context.request_url,
438
438
  request_params: error_context.request_params,
439
439
  user_agent: error_context.user_agent,
@@ -513,6 +513,13 @@ module RailsErrorDashboard
513
513
  attributes[:environment] = resolve_environment
514
514
  end
515
515
 
516
+ # Where the backtrace this row is created with came from. Not part of
517
+ # the fingerprint: a client report and a server capture of one error
518
+ # stay one group.
519
+ if ErrorLog.column_names.include?("backtrace_source")
520
+ attributes[:backtrace_source] = capture_source
521
+ end
522
+
516
523
  # Neutralise invalid bytes BEFORE filtering: the filter runs regexes,
517
524
  # which raise on an invalid string, and PostgreSQL rejects the INSERT.
518
525
  attributes = Services::EncodingSanitizer.scrub_deep(attributes)
@@ -654,8 +661,12 @@ module RailsErrorDashboard
654
661
  occurrence_columns = ErrorOccurrence.column_names
655
662
  occurrence_attrs[:app_version] = attributes[:app_version] if occurrence_columns.include?("app_version")
656
663
  occurrence_attrs[:git_sha] = attributes[:git_sha] if occurrence_columns.include?("git_sha")
664
+ occurrence_attrs[:capture_source] = capture_source if occurrence_columns.include?("capture_source")
657
665
  occurrence_attrs = Services::EncodingSanitizer.scrub_deep(occurrence_attrs)
658
- ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
666
+ occurrence = ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
667
+ # This occurrence's own copy of the context the row just got. Never
668
+ # raises, so a failure here is never logged as the occurrence's.
669
+ RecordOccurrenceContext.call(occurrence, attributes) if occurrence.persisted?
659
670
  rescue => e
660
671
  RailsErrorDashboard::Logger.error("Failed to create error occurrence: #{e.message}")
661
672
  end
@@ -799,12 +810,7 @@ module RailsErrorDashboard
799
810
  end
800
811
 
801
812
  def find_or_create_application
802
- app_name = RailsErrorDashboard.configuration.application_name ||
803
- ENV["APPLICATION_NAME"] ||
804
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
805
- "Rails Application"
806
-
807
- Application.find_or_create_by_name(app_name)
813
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
808
814
  rescue => e
809
815
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to find/create application: #{e.message}")
810
816
  # Fallback: try to find any application or create default
@@ -1006,6 +1012,12 @@ module RailsErrorDashboard
1006
1012
 
1007
1013
  # Symbol or string key: the async job round-trips the context through the
1008
1014
  # queue serializer, which turns symbol keys into strings.
1015
+ # "client" for a ManualErrorReporter report, "server" for every other
1016
+ # capture. Only the exact marker ManualErrorReporter sets counts.
1017
+ def capture_source
1018
+ context_value(:_capture_source) == "client" ? "client" : "server"
1019
+ end
1020
+
1009
1021
  def context_value(key)
1010
1022
  return nil unless @context.is_a?(Hash)
1011
1023
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Keeps one occurrence's moment-of-failure context, then trims the error's
6
+ # stored contexts to the newest config.occurrence_context_limit.
7
+ #
8
+ # The values are the JSON strings LogError has just written to the error
9
+ # row (breadcrumbs, system health, local and instance variables), so an
10
+ # occurrence holds exactly what the row would, filtered the same way. A
11
+ # capture with none of them (a storm-protection :lite capture, or every
12
+ # feature off) stores nothing.
13
+ #
14
+ # Runs inside the capture: it never raises.
15
+ class RecordOccurrenceContext
16
+ PIECES = %i[breadcrumbs system_health local_variables instance_variables].freeze
17
+
18
+ # @param occurrence [ErrorOccurrence] the occurrence just created
19
+ # @param attributes [Hash] the attributes LogError wrote to the error row
20
+ def self.call(occurrence, attributes)
21
+ new(occurrence, attributes).call
22
+ end
23
+
24
+ def initialize(occurrence, attributes)
25
+ @occurrence = occurrence
26
+ @attributes = attributes
27
+ end
28
+
29
+ def call
30
+ limit = RailsErrorDashboard.configuration.occurrence_context_limit.to_i
31
+ return if limit <= 0
32
+
33
+ values = PIECES.index_with { |piece| @attributes[piece].presence }.compact
34
+ return if values.empty?
35
+
36
+ # An app that updated the gem but has not migrated yet
37
+ return unless ErrorOccurrenceContext.table_exists?
38
+
39
+ ErrorOccurrenceContext.create!(
40
+ values.merge(error_occurrence_id: @occurrence.id, error_log_id: @occurrence.error_log_id, created_at: Time.current)
41
+ )
42
+ trim(limit)
43
+ rescue => e
44
+ RailsErrorDashboard::Logger.error(
45
+ "[RailsErrorDashboard] Failed to record occurrence context: #{e.class}: #{e.message}"
46
+ )
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ # Everything past the newest `limit`, normally the one the insert pushed out
53
+ def trim(limit)
54
+ stale = ErrorOccurrenceContext.where(error_log_id: @occurrence.error_log_id)
55
+ .order(id: :desc).offset(limit).pluck(:id)
56
+ ErrorOccurrenceContext.where(id: stale).delete_all if stale.any?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -31,21 +31,8 @@ module RailsErrorDashboard
31
31
  # Dispatch plugin event for resolved error
32
32
  PluginRegistry.dispatch(:on_error_resolved, error)
33
33
 
34
- # Trigger notification callbacks
35
- RailsErrorDashboard.configuration.notification_callbacks[:error_resolved].each do |callback|
36
- callback.call(error)
37
- rescue => e
38
- RailsErrorDashboard::Logger.error("Error in error_resolved callback: #{e.message}")
39
- end
40
-
41
- # Emit ActiveSupport::Notifications instrumentation event
42
- ActiveSupport::Notifications.instrument("error_resolved.rails_error_dashboard", {
43
- error_log: error,
44
- error_id: error.id,
45
- error_type: error.error_type,
46
- resolved_by: @resolution_data[:resolved_by_name],
47
- resolved_at: error.resolved_at
48
- })
34
+ # Host callbacks (issue-tracker close) and the instrumentation event.
35
+ Services::ResolutionCallbacks.call(error, resolved_by: @resolution_data[:resolved_by_name])
49
36
 
50
37
  error
51
38
  end
@@ -44,6 +44,13 @@ module RailsErrorDashboard
44
44
  # The stat cards are cached; a user action must show up at once.
45
45
  Services::AnalyticsCacheManager.clear
46
46
 
47
+ # Moving to "resolved" through the dropdown is a resolution like any
48
+ # other: the linked issue closes and subscribers hear about it.
49
+ if @status == "resolved"
50
+ PluginRegistry.dispatch(:on_error_resolved, error)
51
+ Services::ResolutionCallbacks.call(error, resolved_by: error.assigned_to)
52
+ end
53
+
47
54
  { success: true, error: error }
48
55
  end
49
56