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
|
@@ -13,7 +13,8 @@ module RailsErrorDashboard
|
|
|
13
13
|
# #inspect) or pasting it into an issue doesn't leak them.
|
|
14
14
|
SECRET_ATTRIBUTES = %i[
|
|
15
15
|
dashboard_password slack_webhook_url discord_webhook_url pagerduty_integration_key
|
|
16
|
-
webhook_urls issue_tracker_token issue_webhook_secret llm_api_key
|
|
16
|
+
webhook_urls webhook_signing_secret issue_tracker_token issue_webhook_secret llm_api_key
|
|
17
|
+
telegram_bot_token
|
|
17
18
|
].freeze
|
|
18
19
|
|
|
19
20
|
# Dashboard authentication (always required)
|
|
@@ -46,6 +47,14 @@ module RailsErrorDashboard
|
|
|
46
47
|
attr_accessor :discord_webhook_url
|
|
47
48
|
attr_accessor :enable_discord_notifications
|
|
48
49
|
|
|
50
|
+
# Telegram (Bot API: a bot token from @BotFather and the chat, group or
|
|
51
|
+
# channel the bot posts to). telegram_api_base_url exists for a local Bot
|
|
52
|
+
# API server or a proxy; nearly every install leaves it alone.
|
|
53
|
+
attr_accessor :enable_telegram_notifications
|
|
54
|
+
attr_accessor :telegram_bot_token
|
|
55
|
+
attr_accessor :telegram_chat_id
|
|
56
|
+
attr_accessor :telegram_api_base_url
|
|
57
|
+
|
|
49
58
|
# PagerDuty notifications (critical errors only)
|
|
50
59
|
attr_accessor :pagerduty_integration_key
|
|
51
60
|
attr_accessor :enable_pagerduty_notifications
|
|
@@ -53,6 +62,9 @@ module RailsErrorDashboard
|
|
|
53
62
|
# Generic webhook notifications
|
|
54
63
|
attr_accessor :webhook_urls
|
|
55
64
|
attr_accessor :enable_webhook_notifications
|
|
65
|
+
# When set, every POST to webhook_urls carries an HMAC-SHA256 signature
|
|
66
|
+
# (X-Error-Dashboard-Signature-256) and a timestamp the receiver can verify.
|
|
67
|
+
attr_accessor :webhook_signing_secret
|
|
56
68
|
|
|
57
69
|
# Scheduled digests (daily/weekly summary emails)
|
|
58
70
|
attr_accessor :enable_scheduled_digests # Master switch (default: false)
|
|
@@ -206,6 +218,7 @@ module RailsErrorDashboard
|
|
|
206
218
|
# Instance variable capture from tp.self (receiver object at raise time)
|
|
207
219
|
attr_accessor :enable_instance_variables # Master switch (default: false)
|
|
208
220
|
attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
|
|
221
|
+
attr_accessor :occurrence_context_limit # Occurrences per error whose context is kept (default: 25; 0 = off)
|
|
209
222
|
attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
|
|
210
223
|
|
|
211
224
|
# Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
|
|
@@ -231,6 +244,12 @@ module RailsErrorDashboard
|
|
|
231
244
|
attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
|
|
232
245
|
attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
|
|
233
246
|
|
|
247
|
+
# Missing-translation tracking — counts the HOST app's I18n misses by
|
|
248
|
+
# locale and key in their own table. Wraps I18n.exception_handler
|
|
249
|
+
# (delegating to the handler the app already had); the one place RED
|
|
250
|
+
# touches global I18n state, so it is opt-in.
|
|
251
|
+
attr_accessor :enable_missing_translation_tracking # Master switch (default: false)
|
|
252
|
+
|
|
234
253
|
# ActionCable event tracking (requires enable_breadcrumbs = true)
|
|
235
254
|
attr_accessor :enable_actioncable_tracking # Master switch (default: false)
|
|
236
255
|
# ActiveStorage event tracking (requires enable_breadcrumbs = true)
|
|
@@ -309,6 +328,12 @@ module RailsErrorDashboard
|
|
|
309
328
|
@discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
310
329
|
@enable_discord_notifications = false
|
|
311
330
|
|
|
331
|
+
# Telegram notification settings
|
|
332
|
+
@enable_telegram_notifications = false
|
|
333
|
+
@telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
334
|
+
@telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
335
|
+
@telegram_api_base_url = ENV.fetch("TELEGRAM_API_BASE_URL", "https://api.telegram.org")
|
|
336
|
+
|
|
312
337
|
# PagerDuty notification settings (critical errors only)
|
|
313
338
|
@pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
|
|
314
339
|
@enable_pagerduty_notifications = false
|
|
@@ -316,6 +341,10 @@ module RailsErrorDashboard
|
|
|
316
341
|
# Generic webhook settings (array of URLs)
|
|
317
342
|
@webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
318
343
|
@enable_webhook_notifications = false
|
|
344
|
+
# nil leaves outbound webhooks unsigned, as before. Secret-only (no
|
|
345
|
+
# separate "enable" flag): a secret with nothing to apply it to is the
|
|
346
|
+
# one combination that has no sensible meaning.
|
|
347
|
+
@webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"].presence
|
|
319
348
|
|
|
320
349
|
# Scheduled digest defaults - OFF by default (opt-in)
|
|
321
350
|
@enable_scheduled_digests = false
|
|
@@ -453,6 +482,9 @@ module RailsErrorDashboard
|
|
|
453
482
|
# Instance variable capture defaults - OFF by default (opt-in)
|
|
454
483
|
@enable_instance_variables = false # Capture ivars from tp.self at raise time
|
|
455
484
|
@instance_variable_max_count = 20 # Max ivars per exception
|
|
485
|
+
# Keep the moment-of-failure context of the newest 25 occurrences of each
|
|
486
|
+
# error, not only the latest. 0 keeps none.
|
|
487
|
+
@occurrence_context_limit = 25
|
|
456
488
|
@instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
|
|
457
489
|
|
|
458
490
|
# Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
|
|
@@ -483,6 +515,10 @@ module RailsErrorDashboard
|
|
|
483
515
|
# roughly one write per thread per interval.
|
|
484
516
|
@rack_attack_flush_interval = 5 # Seconds between DB flushes
|
|
485
517
|
|
|
518
|
+
# Missing-translation tracking - OFF by default (opt-in). Persists to its
|
|
519
|
+
# own table; does NOT require breadcrumbs.
|
|
520
|
+
@enable_missing_translation_tracking = false
|
|
521
|
+
|
|
486
522
|
# ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
487
523
|
@enable_actioncable_tracking = false
|
|
488
524
|
# ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
@@ -642,6 +678,13 @@ module RailsErrorDashboard
|
|
|
642
678
|
end
|
|
643
679
|
end
|
|
644
680
|
|
|
681
|
+
# Any other git_branch_strategy links to main. A warning, not an error:
|
|
682
|
+
# a link format is not worth stopping a boot.
|
|
683
|
+
if git_branch_strategy && !%w[commit_sha current_branch main].include?(git_branch_strategy.to_s)
|
|
684
|
+
warnings << "git_branch_strategy must be one of :commit_sha, :current_branch or :main " \
|
|
685
|
+
"(got: #{git_branch_strategy.inspect}). Repository links will point at main."
|
|
686
|
+
end
|
|
687
|
+
|
|
645
688
|
# Validate custom_fingerprint (must respond to .call if set)
|
|
646
689
|
if custom_fingerprint && !custom_fingerprint.respond_to?(:call)
|
|
647
690
|
errors << "custom_fingerprint must respond to .call (lambda, proc, or object with .call method)"
|
|
@@ -680,6 +723,10 @@ module RailsErrorDashboard
|
|
|
680
723
|
errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
|
|
681
724
|
end
|
|
682
725
|
|
|
726
|
+
unless occurrence_context_limit.is_a?(Integer) && occurrence_context_limit >= 0
|
|
727
|
+
errors << "occurrence_context_limit must be a whole number of 0 or more (got: #{occurrence_context_limit.inspect})"
|
|
728
|
+
end
|
|
729
|
+
|
|
683
730
|
# Validate swallowed exception detection settings
|
|
684
731
|
# Auto-disable on Ruby < 3.3 (warn, don't crash)
|
|
685
732
|
if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
|
|
@@ -810,6 +857,15 @@ module RailsErrorDashboard
|
|
|
810
857
|
errors << "discord_webhook_url is required when enable_discord_notifications is true"
|
|
811
858
|
end
|
|
812
859
|
|
|
860
|
+
if enable_telegram_notifications
|
|
861
|
+
if telegram_bot_token.to_s.strip.empty?
|
|
862
|
+
errors << "telegram_bot_token is required when enable_telegram_notifications is true"
|
|
863
|
+
end
|
|
864
|
+
if telegram_chat_id.to_s.strip.empty?
|
|
865
|
+
errors << "telegram_chat_id is required when enable_telegram_notifications is true"
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
|
|
813
869
|
if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
|
|
814
870
|
errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
|
|
815
871
|
end
|
|
@@ -1085,9 +1141,10 @@ module RailsErrorDashboard
|
|
|
1085
1141
|
return nil if effective_issue_tracker_provider == :linear
|
|
1086
1142
|
return nil if git_repository_url.blank?
|
|
1087
1143
|
|
|
1088
|
-
#
|
|
1089
|
-
|
|
1090
|
-
|
|
1144
|
+
# owner/repo from the normalized URL, so ".git", ".git/" and trailing
|
|
1145
|
+
# slashes all name the same repository the View Source links use
|
|
1146
|
+
url = Services::RepositoryUrl.normalize(git_repository_url)
|
|
1147
|
+
url.match(%r{[:/]([^/]+/[^/]+)\z})&.[](1)
|
|
1091
1148
|
end
|
|
1092
1149
|
|
|
1093
1150
|
# Resolve the issue tracker API token (supports string or lambda)
|
|
@@ -48,6 +48,22 @@ module RailsErrorDashboard
|
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Register the crash-capture at_exit hook as soon as the host's
|
|
52
|
+
# config/initializers have run (so enable_crash_capture is known) and
|
|
53
|
+
# BEFORE Rails' :eager_load! finisher. Every engine initializer runs ahead
|
|
54
|
+
# of the finishers, so this is early enough for a Zeitwerk::NameError, a
|
|
55
|
+
# SyntaxError in a model or a raising initializer to be captured; the old
|
|
56
|
+
# place, config.after_initialize, runs AFTER eager loading and was never
|
|
57
|
+
# reached by exactly the crashes a boot check exists for. The import of
|
|
58
|
+
# earlier crash files stays in after_initialize, where the database is.
|
|
59
|
+
initializer "rails_error_dashboard.crash_capture", after: :load_config_initializers do
|
|
60
|
+
next if ENV["SECRET_KEY_BASE_DUMMY"].present?
|
|
61
|
+
|
|
62
|
+
if RailsErrorDashboard.configuration.enable_crash_capture
|
|
63
|
+
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
51
67
|
# Validate configuration after initialization
|
|
52
68
|
initializer "rails_error_dashboard.validate_config", after: :load_config_initializers do
|
|
53
69
|
config.after_initialize do
|
|
@@ -133,6 +149,28 @@ module RailsErrorDashboard
|
|
|
133
149
|
at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
|
|
134
150
|
end
|
|
135
151
|
|
|
152
|
+
# Count the host app's missing translations. The handler wrap is the one
|
|
153
|
+
# place RED touches global I18n state, so it runs only when opted in,
|
|
154
|
+
# and here in after_initialize so the handler the host's own
|
|
155
|
+
# initializers configured is the one being wrapped (and delegated to).
|
|
156
|
+
#
|
|
157
|
+
# Same drain points as Rack Attack tracking: to_complete fires after the
|
|
158
|
+
# response body is closed (never delays a request, safety rule 2) and is
|
|
159
|
+
# interval-gated; at_exit drains whatever buffers are still on live
|
|
160
|
+
# threads. Puma joins its worker threads before at_exit runs, so what a
|
|
161
|
+
# worker held at SIGTERM (at most FLUSH_INTERVAL seconds of misses) is
|
|
162
|
+
# lost with it; the hook still covers the main thread, runners and
|
|
163
|
+
# console sessions. at_exit, not Signal.trap (safety rule 9).
|
|
164
|
+
if RailsErrorDashboard.configuration.enable_missing_translation_tracking
|
|
165
|
+
RailsErrorDashboard::Services::MissingTranslationHandler.install!
|
|
166
|
+
|
|
167
|
+
Rails.application.executor.to_complete do
|
|
168
|
+
RailsErrorDashboard::Services::MissingTranslationTracker.flush_if_due!
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
at_exit { RailsErrorDashboard::Services::MissingTranslationTracker.flush_all_threads! }
|
|
172
|
+
end
|
|
173
|
+
|
|
136
174
|
# Drain the storm count buffer at the end of every request and job, and
|
|
137
175
|
# again at process exit.
|
|
138
176
|
#
|
|
@@ -224,7 +262,10 @@ module RailsErrorDashboard
|
|
|
224
262
|
end
|
|
225
263
|
end
|
|
226
264
|
|
|
227
|
-
# Import crash files from previous process death
|
|
265
|
+
# Import crash files from a previous process death. The at_exit hook
|
|
266
|
+
# itself is registered by the "rails_error_dashboard.crash_capture"
|
|
267
|
+
# initializer above, before eager loading; enable! here is a no-op when
|
|
268
|
+
# that ran and a fallback when the configuration was only set later.
|
|
228
269
|
if RailsErrorDashboard.configuration.enable_crash_capture
|
|
229
270
|
RailsErrorDashboard::Services::CrashCapture.import!
|
|
230
271
|
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
@@ -46,8 +46,10 @@ module RailsErrorDashboard
|
|
|
46
46
|
# Skip duplicate reports from our own middleware when the subscriber
|
|
47
47
|
# already captured this error with full request context above.
|
|
48
48
|
# Without this, async logging enqueues two jobs for one exception —
|
|
49
|
-
# and non-deterministic job ordering can overwrite good data.
|
|
50
|
-
|
|
49
|
+
# and non-deterministic job ordering can overwrite good data. The
|
|
50
|
+
# middleware's report also carries context[:middleware], because Rails
|
|
51
|
+
# 7.0's reporter passes subscribers no source.
|
|
52
|
+
if (source == "rack.middleware" || context[:middleware]) &&
|
|
51
53
|
Thread.current[:rails_error_dashboard_reported_errors]&.include?(error.object_id)
|
|
52
54
|
return
|
|
53
55
|
end
|
|
@@ -49,7 +49,12 @@ module RailsErrorDashboard
|
|
|
49
49
|
# believe they had set a value that was never read.
|
|
50
50
|
# @param source [String, nil] Source identifier (e.g., "frontend", "mobile_app")
|
|
51
51
|
#
|
|
52
|
-
# @return [ErrorLog, nil] The created error log record
|
|
52
|
+
# @return [ErrorLog, nil] The created or updated error log record. nil when
|
|
53
|
+
# no row is written now, or none is returned:
|
|
54
|
+
# - the error is ignored or sampled out (ignored_exceptions, sampling_rate)
|
|
55
|
+
# - storm protection only counts it (:count_only)
|
|
56
|
+
# - async_logging is on: a job writes the row later
|
|
57
|
+
# - logging failed internally (RED never raises into your code)
|
|
53
58
|
#
|
|
54
59
|
# @example Basic usage
|
|
55
60
|
# ManualErrorReporter.report(
|
|
@@ -106,6 +111,11 @@ module RailsErrorDashboard
|
|
|
106
111
|
occurred_at: occurred_at || Time.current
|
|
107
112
|
}.compact # Remove nil values
|
|
108
113
|
|
|
114
|
+
# Marks this as a client report, so nothing reads this app's source files
|
|
115
|
+
# for its backtrace. Set here, after the caller's values, and from no
|
|
116
|
+
# argument: source: is only a label.
|
|
117
|
+
context[:_capture_source] = "client"
|
|
118
|
+
|
|
109
119
|
# severity is deliberately NOT forwarded: nothing downstream reads it,
|
|
110
120
|
# and passing it on would keep the illusion that it does.
|
|
111
121
|
if severity.present?
|
|
@@ -38,15 +38,19 @@ module RailsErrorDashboard
|
|
|
38
38
|
# Report to Rails.error (will be logged by our ErrorReporter)
|
|
39
39
|
# CRITICAL: Wrap in rescue to ensure gem failures don't break the app
|
|
40
40
|
begin
|
|
41
|
-
|
|
41
|
+
options = {
|
|
42
42
|
handled: false,
|
|
43
43
|
severity: :error,
|
|
44
44
|
context: {
|
|
45
45
|
request: ActionDispatch::Request.new(env),
|
|
46
46
|
middleware: true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
# Rails 7.0's ErrorReporter#report takes no source: keyword (7.1
|
|
50
|
+
# added it). Passing one raised ArgumentError there, which the rescue
|
|
51
|
+
# below swallowed, so on 7.0 this middleware reported nothing.
|
|
52
|
+
options[:source] = "rack.middleware" if report_takes_source?
|
|
53
|
+
Rails.error.report(exception, **options)
|
|
50
54
|
rescue => e
|
|
51
55
|
# If error reporting fails, log it but DON'T break the app
|
|
52
56
|
RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Middleware error reporting failed: #{e.class} - #{e.message}")
|
|
@@ -61,6 +65,16 @@ module RailsErrorDashboard
|
|
|
61
65
|
Thread.current[:rails_error_dashboard_reported_errors] = nil
|
|
62
66
|
RailsErrorDashboard::Services::BreadcrumbCollector.clear_buffer
|
|
63
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Asked of the reporter's class, not the object: a stubbed or wrapped
|
|
72
|
+
# report method hides its real keywords.
|
|
73
|
+
def report_takes_source?
|
|
74
|
+
Rails.error.class.instance_method(:report).parameters.any? do |type, name|
|
|
75
|
+
name == :source || type == :keyrest
|
|
76
|
+
end
|
|
77
|
+
end
|
|
64
78
|
end
|
|
65
79
|
end
|
|
66
80
|
end
|
|
@@ -55,8 +55,14 @@ module RailsErrorDashboard
|
|
|
55
55
|
"/red"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# The mount itself or anything below it. A plain start_with? also
|
|
59
|
+
# matched the host's own /redirect when the engine was at /red. An
|
|
60
|
+
# engine mounted at "/" owns every path.
|
|
58
61
|
def error_dashboard_route?(path)
|
|
59
|
-
|
|
62
|
+
mount = engine_mount_path.to_s.chomp("/")
|
|
63
|
+
return true if mount.empty?
|
|
64
|
+
|
|
65
|
+
path == mount || path.start_with?("#{mount}/")
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
def rate_limit_key(request, limit_config)
|
|
@@ -121,11 +121,27 @@ module RailsErrorDashboard
|
|
|
121
121
|
|
|
122
122
|
send(method_name, *args)
|
|
123
123
|
rescue => e
|
|
124
|
-
# Log plugin failures but never propagate - plugins must not break the app
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
# Log plugin failures but never propagate - plugins must not break the app.
|
|
125
|
+
# name and version are plugin code too, so the log line must not depend
|
|
126
|
+
# on them answering.
|
|
127
|
+
RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Plugin '#{safe_label}' failed in #{method_name}: #{e.class} - #{e.message}")
|
|
128
|
+
RailsErrorDashboard::Logger.error("Plugin version: #{safe_version}")
|
|
127
129
|
RailsErrorDashboard::Logger.error(e.backtrace&.first(10)&.join("\n")) if e.backtrace
|
|
128
130
|
nil # Explicitly return nil, never raise
|
|
129
131
|
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def safe_label
|
|
136
|
+
name.to_s
|
|
137
|
+
rescue StandardError, NotImplementedError
|
|
138
|
+
self.class.name
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def safe_version
|
|
142
|
+
version.to_s
|
|
143
|
+
rescue StandardError, NotImplementedError
|
|
144
|
+
"unknown"
|
|
145
|
+
end
|
|
130
146
|
end
|
|
131
147
|
end
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
# Registry for managing plugins
|
|
5
5
|
# Provides plugin registration and event dispatching
|
|
6
|
+
#
|
|
7
|
+
# A plugin is host code running inside RED's own paths, so nothing it does
|
|
8
|
+
# may break them: a `name` that raises (the base class raises
|
|
9
|
+
# NotImplementedError, a ScriptError that `rescue => e` does not catch), an
|
|
10
|
+
# `on_register` that raises, or an `enabled?` that raises is logged and
|
|
11
|
+
# treated as "no", never propagated. A plugin is in the list only once
|
|
12
|
+
# registration has fully succeeded.
|
|
6
13
|
class PluginRegistry
|
|
7
14
|
class << self
|
|
8
15
|
# Get all registered plugins
|
|
@@ -12,26 +19,41 @@ module RailsErrorDashboard
|
|
|
12
19
|
|
|
13
20
|
# Register a plugin
|
|
14
21
|
# @param plugin [Plugin] The plugin instance to register
|
|
22
|
+
# @return [Boolean] true when registered, false when skipped
|
|
23
|
+
# @raise [ArgumentError] not a Plugin, or no usable name
|
|
15
24
|
def register(plugin)
|
|
16
25
|
unless plugin.is_a?(Plugin)
|
|
17
26
|
raise ArgumentError, "Plugin must be an instance of RailsErrorDashboard::Plugin"
|
|
18
27
|
end
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
name = name_of(plugin)
|
|
30
|
+
if name.blank?
|
|
31
|
+
raise ArgumentError, "Plugin #{plugin.class} must implement #name and return a non-blank String"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
if plugins.any? { |p| name_of(p) == name }
|
|
35
|
+
RailsErrorDashboard::Logger.warn("Plugin '#{name}' is already registered, skipping")
|
|
36
|
+
return false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
plugin.on_register
|
|
41
|
+
rescue => e
|
|
42
|
+
RailsErrorDashboard::Logger.error(
|
|
43
|
+
"[RailsErrorDashboard] Plugin '#{name}' failed in on_register: #{e.class} - #{e.message}; not registered"
|
|
44
|
+
)
|
|
22
45
|
return false
|
|
23
46
|
end
|
|
24
47
|
|
|
25
48
|
plugins << plugin
|
|
26
|
-
plugin
|
|
27
|
-
RailsErrorDashboard::Logger.info("Registered plugin: #{plugin.name} (#{plugin.version})")
|
|
49
|
+
RailsErrorDashboard::Logger.info("Registered plugin: #{name} (#{version_of(plugin)})")
|
|
28
50
|
true
|
|
29
51
|
end
|
|
30
52
|
|
|
31
53
|
# Unregister a plugin by name
|
|
32
54
|
# @param plugin_name [String] The name of the plugin to unregister
|
|
33
55
|
def unregister(plugin_name)
|
|
34
|
-
plugins.reject! { |p| p
|
|
56
|
+
plugins.reject! { |p| name_of(p) == plugin_name }
|
|
35
57
|
end
|
|
36
58
|
|
|
37
59
|
# Clear all plugins (useful for testing)
|
|
@@ -43,7 +65,7 @@ module RailsErrorDashboard
|
|
|
43
65
|
# @param plugin_name [String] The name of the plugin
|
|
44
66
|
# @return [Plugin, nil] The plugin instance or nil if not found
|
|
45
67
|
def find(plugin_name)
|
|
46
|
-
plugins.find { |p| p
|
|
68
|
+
plugins.find { |p| name_of(p) == plugin_name }
|
|
47
69
|
end
|
|
48
70
|
|
|
49
71
|
# Dispatch an event to all registered plugins
|
|
@@ -51,7 +73,7 @@ module RailsErrorDashboard
|
|
|
51
73
|
# @param args [Array] Arguments to pass to the event handler
|
|
52
74
|
def dispatch(event_name, *args)
|
|
53
75
|
plugins.each do |plugin|
|
|
54
|
-
next unless
|
|
76
|
+
next unless enabled?(plugin)
|
|
55
77
|
|
|
56
78
|
plugin.safe_execute(event_name, *args)
|
|
57
79
|
end
|
|
@@ -69,20 +91,47 @@ module RailsErrorDashboard
|
|
|
69
91
|
|
|
70
92
|
# Get list of plugin names
|
|
71
93
|
def names
|
|
72
|
-
plugins.map(
|
|
94
|
+
plugins.map { |p| name_of(p) }
|
|
73
95
|
end
|
|
74
96
|
|
|
75
97
|
# Get plugin information for debugging
|
|
76
98
|
def info
|
|
77
99
|
plugins.map do |plugin|
|
|
78
100
|
{
|
|
79
|
-
name: plugin
|
|
80
|
-
version: plugin
|
|
81
|
-
description: plugin.description,
|
|
82
|
-
enabled:
|
|
101
|
+
name: name_of(plugin),
|
|
102
|
+
version: version_of(plugin),
|
|
103
|
+
description: (plugin.description rescue nil),
|
|
104
|
+
enabled: enabled?(plugin)
|
|
83
105
|
}
|
|
84
106
|
end
|
|
85
107
|
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# A plugin's name, or nil when #name is missing or raises.
|
|
112
|
+
def name_of(plugin)
|
|
113
|
+
value = plugin.name
|
|
114
|
+
value.is_a?(String) ? value : value&.to_s
|
|
115
|
+
rescue StandardError, NotImplementedError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def version_of(plugin)
|
|
120
|
+
plugin.version.to_s
|
|
121
|
+
rescue StandardError, NotImplementedError
|
|
122
|
+
"unknown"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# enabled? is host code too. A raise is logged once per dispatch and
|
|
126
|
+
# read as disabled, so the other plugins still run.
|
|
127
|
+
def enabled?(plugin)
|
|
128
|
+
plugin.enabled? ? true : false
|
|
129
|
+
rescue => e
|
|
130
|
+
RailsErrorDashboard::Logger.error(
|
|
131
|
+
"[RailsErrorDashboard] Plugin '#{name_of(plugin) || plugin.class}' failed in enabled?: #{e.class} - #{e.message}; treated as disabled"
|
|
132
|
+
)
|
|
133
|
+
false
|
|
134
|
+
end
|
|
86
135
|
end
|
|
87
136
|
end
|
|
88
137
|
end
|
|
@@ -29,8 +29,6 @@ module RailsErrorDashboard
|
|
|
29
29
|
errors_by_hour: errors_by_hour,
|
|
30
30
|
top_users: top_affected_users,
|
|
31
31
|
resolution_rate: resolution_rate,
|
|
32
|
-
mobile_errors: mobile_errors_count,
|
|
33
|
-
api_errors: api_errors_count,
|
|
34
32
|
pattern_insights: pattern_insights
|
|
35
33
|
}
|
|
36
34
|
end
|
|
@@ -270,16 +268,6 @@ module RailsErrorDashboard
|
|
|
270
268
|
((resolved_count.to_f / total_groups) * 100).round(1)
|
|
271
269
|
end
|
|
272
270
|
|
|
273
|
-
def mobile_errors_count
|
|
274
|
-
Queries::EventVolume.in_window(base_scope.where(platform: [ "iOS", "Android" ]), @start_date)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def api_errors_count
|
|
278
|
-
Queries::EventVolume.in_window(
|
|
279
|
-
base_scope.where("platform IS NULL OR platform = ?", "API"), @start_date
|
|
280
|
-
)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
271
|
# Pattern insights for top error types
|
|
284
272
|
# Analyzes occurrence patterns and bursts for top 5 error types
|
|
285
273
|
def pattern_insights
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: the crashes that happened while the process was still booting.
|
|
6
|
+
#
|
|
7
|
+
# A boot crash (a Zeitwerk::NameError from eager loading, a SyntaxError in
|
|
8
|
+
# a model, an initializer that raised) is imported by CrashCapture with
|
|
9
|
+
# platform "boot_crash" and the file and constant it names, when it names
|
|
10
|
+
# them, in environment_info.boot. One entry per error row, most recent
|
|
11
|
+
# first: the question is "what stopped the last deploy", not a frequency
|
|
12
|
+
# ranking.
|
|
13
|
+
class BootErrors
|
|
14
|
+
PLATFORM = "boot_crash"
|
|
15
|
+
|
|
16
|
+
def self.call(days = 30, application_id: nil)
|
|
17
|
+
new(days, application_id: application_id).call
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(days = 30, application_id: nil)
|
|
21
|
+
@days = days
|
|
22
|
+
@application_id = application_id
|
|
23
|
+
@start_date = days.days.ago
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Hash] entries: [{id:, error_type:, message:, file:, constant:,
|
|
27
|
+
# count:, first_seen:, last_seen:, resolved:}], unresolved_count: Integer
|
|
28
|
+
def call
|
|
29
|
+
rows = base_query
|
|
30
|
+
.order(last_seen_at: :desc, id: :desc)
|
|
31
|
+
.pluck(:id, :error_type, :message, :environment_info, :occurrence_count,
|
|
32
|
+
:first_seen_at, :last_seen_at, :resolved)
|
|
33
|
+
|
|
34
|
+
entries = rows.map do |id, error_type, message, env_info, count, first_seen, last_seen, resolved|
|
|
35
|
+
boot = boot_detail(env_info)
|
|
36
|
+
{
|
|
37
|
+
id: id,
|
|
38
|
+
error_type: error_type.to_s,
|
|
39
|
+
message: message.to_s,
|
|
40
|
+
file: boot["file"].presence,
|
|
41
|
+
constant: boot["constant"].presence,
|
|
42
|
+
count: [ count.to_i, 1 ].max,
|
|
43
|
+
first_seen: first_seen,
|
|
44
|
+
last_seen: last_seen,
|
|
45
|
+
resolved: resolved ? true : false
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
{ entries: entries, unresolved_count: entries.count { |e| !e[:resolved] } }
|
|
50
|
+
rescue => e
|
|
51
|
+
Rails.logger.error("[RailsErrorDashboard] BootErrors query failed: #{e.class}: #{e.message}")
|
|
52
|
+
{ entries: [], unresolved_count: 0 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def base_query
|
|
58
|
+
scope = ErrorLog.where(platform: PLATFORM).where("last_seen_at >= ?", @start_date)
|
|
59
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
60
|
+
scope
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# environment_info is JSON text; the boot detail is optional and the
|
|
64
|
+
# column may hold anything an older row wrote, so every step is guarded.
|
|
65
|
+
def boot_detail(env_info)
|
|
66
|
+
return {} if env_info.blank?
|
|
67
|
+
|
|
68
|
+
parsed = env_info.is_a?(String) ? JSON.parse(env_info) : env_info
|
|
69
|
+
boot = parsed.is_a?(Hash) ? parsed["boot"] : nil
|
|
70
|
+
boot.is_a?(Hash) ? boot : {}
|
|
71
|
+
rescue JSON::ParserError, TypeError
|
|
72
|
+
{}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|