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.
- checksums.yaml +4 -4
- data/README.md +19 -8
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +54 -20
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +87 -5
- data/config/locales/en.yml +99 -4
- data/config/locales/es.yml +88 -5
- data/config/locales/fr.yml +88 -5
- data/config/locales/it.yml +88 -5
- data/config/locales/ja.yml +86 -4
- data/config/locales/pl.yml +89 -5
- data/config/locales/pt-BR.yml +88 -5
- data/config/locales/ru.yml +89 -5
- data/config/locales/uk.yml +89 -5
- data/config/locales/zh-CN.yml +86 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +61 -4
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +28 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- 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
|
|
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
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# app's ApplicationController
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
35
|
-
|
|
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
|
|