rails_error_dashboard 0.14.4 → 0.15.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 +12 -5
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
- 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 +26 -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 +43 -12
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +11 -1
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
- 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/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
- data/config/locales/de.yml +68 -5
- data/config/locales/en.yml +78 -4
- data/config/locales/es.yml +68 -5
- data/config/locales/fr.yml +68 -5
- data/config/locales/it.yml +68 -5
- data/config/locales/ja.yml +68 -4
- data/config/locales/pl.yml +68 -5
- data/config/locales/pt-BR.yml +68 -5
- data/config/locales/ru.yml +68 -5
- data/config/locales/uk.yml +68 -5
- data/config/locales/zh-CN.yml +68 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
- 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 +42 -1
- data/lib/rails_error_dashboard/engine.rb +42 -1
- 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/boot_errors.rb +76 -0
- 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/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 +15 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +85 -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 -9
- 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/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- 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/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +12 -0
- metadata +21 -2
|
@@ -127,6 +127,21 @@ RailsErrorDashboard.configure do |config|
|
|
|
127
127
|
config.enable_discord_notifications = false
|
|
128
128
|
# config.discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
129
129
|
|
|
130
|
+
<% end -%>
|
|
131
|
+
<% if @enable_telegram -%>
|
|
132
|
+
# Telegram Notifications - ENABLED
|
|
133
|
+
config.enable_telegram_notifications = true
|
|
134
|
+
config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
135
|
+
config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
136
|
+
# To disable: Set config.enable_telegram_notifications = false
|
|
137
|
+
|
|
138
|
+
<% else -%>
|
|
139
|
+
# Telegram Notifications - DISABLED
|
|
140
|
+
# To enable: Set config.enable_telegram_notifications = true and configure a bot token and chat id
|
|
141
|
+
config.enable_telegram_notifications = false
|
|
142
|
+
# config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
143
|
+
# config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
144
|
+
|
|
130
145
|
<% end -%>
|
|
131
146
|
<% if @enable_pagerduty -%>
|
|
132
147
|
# PagerDuty Integration - ENABLED (critical errors only)
|
|
@@ -145,6 +160,9 @@ RailsErrorDashboard.configure do |config|
|
|
|
145
160
|
# Generic Webhook Notifications - ENABLED
|
|
146
161
|
config.enable_webhook_notifications = true
|
|
147
162
|
config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
163
|
+
# Sign every webhook (HMAC-SHA256 over timestamp + body) so receivers can
|
|
164
|
+
# verify it came from this app. Generate with: ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
|
|
165
|
+
# config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"]
|
|
148
166
|
# To disable: Set config.enable_webhook_notifications = false
|
|
149
167
|
|
|
150
168
|
<% else -%>
|
|
@@ -152,6 +170,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
152
170
|
# To enable: Set config.enable_webhook_notifications = true and configure webhook URLs
|
|
153
171
|
config.enable_webhook_notifications = false
|
|
154
172
|
# config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
173
|
+
# config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"] # HMAC-SHA256 signature headers
|
|
155
174
|
|
|
156
175
|
<% end -%>
|
|
157
176
|
# Dashboard base URL (used in notification links)
|
|
@@ -408,6 +427,14 @@ RailsErrorDashboard.configure do |config|
|
|
|
408
427
|
# config.swallowed_exception_threshold = 0.95
|
|
409
428
|
|
|
410
429
|
<% end -%>
|
|
430
|
+
# Missing-Translation Tracking - DISABLED
|
|
431
|
+
# Counts every I18n key your app looked up and did not have, by locale, with
|
|
432
|
+
# the first call site, on its own dashboard page (/errors/missing_translations).
|
|
433
|
+
# Wraps I18n.exception_handler and delegates to the handler you already had —
|
|
434
|
+
# the one place RED touches global I18n state, which is why it is opt-in.
|
|
435
|
+
# To enable: Set config.enable_missing_translation_tracking = true
|
|
436
|
+
config.enable_missing_translation_tracking = false
|
|
437
|
+
|
|
411
438
|
<% if @enable_diagnostic_dump -%>
|
|
412
439
|
# Diagnostic Dump - ENABLED
|
|
413
440
|
# 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
|
|
@@ -44,11 +44,19 @@ module RailsErrorDashboard
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Dispatch plugin event for batch resolved errors
|
|
48
47
|
Services::AnalyticsCacheManager.clear if resolved_count.positive?
|
|
49
48
|
|
|
49
|
+
# Dispatch plugin event for batch resolved errors
|
|
50
50
|
PluginRegistry.dispatch(:on_errors_batch_resolved, resolved_errors) if resolved_errors.any?
|
|
51
51
|
|
|
52
|
+
# Each error resolved here is as resolved as one resolved on its own
|
|
53
|
+
# page: the host's error_resolved callbacks (the issue tracker closes
|
|
54
|
+
# the linked issue from one) and the instrumentation event fire per
|
|
55
|
+
# error. Only the plugin event is batch-shaped, by design.
|
|
56
|
+
resolved_errors.each do |error|
|
|
57
|
+
Services::ResolutionCallbacks.call(error, resolved_by: @resolved_by_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
{
|
|
53
61
|
success: failed_ids.empty?,
|
|
54
62
|
count: resolved_count,
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
app_name = RailsErrorDashboard.configuration.application_name
|
|
95
|
+
return nil unless app_name.present?
|
|
96
|
+
|
|
97
|
+
Application.find_by(name: app_name)&.id
|
|
98
|
+
rescue => e
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
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,
|
|
@@ -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
|
|
|
@@ -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)
|
|
@@ -231,6 +243,12 @@ module RailsErrorDashboard
|
|
|
231
243
|
attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
|
|
232
244
|
attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
|
|
233
245
|
|
|
246
|
+
# Missing-translation tracking — counts the HOST app's I18n misses by
|
|
247
|
+
# locale and key in their own table. Wraps I18n.exception_handler
|
|
248
|
+
# (delegating to the handler the app already had); the one place RED
|
|
249
|
+
# touches global I18n state, so it is opt-in.
|
|
250
|
+
attr_accessor :enable_missing_translation_tracking # Master switch (default: false)
|
|
251
|
+
|
|
234
252
|
# ActionCable event tracking (requires enable_breadcrumbs = true)
|
|
235
253
|
attr_accessor :enable_actioncable_tracking # Master switch (default: false)
|
|
236
254
|
# ActiveStorage event tracking (requires enable_breadcrumbs = true)
|
|
@@ -309,6 +327,12 @@ module RailsErrorDashboard
|
|
|
309
327
|
@discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
310
328
|
@enable_discord_notifications = false
|
|
311
329
|
|
|
330
|
+
# Telegram notification settings
|
|
331
|
+
@enable_telegram_notifications = false
|
|
332
|
+
@telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
333
|
+
@telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
334
|
+
@telegram_api_base_url = ENV.fetch("TELEGRAM_API_BASE_URL", "https://api.telegram.org")
|
|
335
|
+
|
|
312
336
|
# PagerDuty notification settings (critical errors only)
|
|
313
337
|
@pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
|
|
314
338
|
@enable_pagerduty_notifications = false
|
|
@@ -316,6 +340,10 @@ module RailsErrorDashboard
|
|
|
316
340
|
# Generic webhook settings (array of URLs)
|
|
317
341
|
@webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
318
342
|
@enable_webhook_notifications = false
|
|
343
|
+
# nil leaves outbound webhooks unsigned, as before. Secret-only (no
|
|
344
|
+
# separate "enable" flag): a secret with nothing to apply it to is the
|
|
345
|
+
# one combination that has no sensible meaning.
|
|
346
|
+
@webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"].presence
|
|
319
347
|
|
|
320
348
|
# Scheduled digest defaults - OFF by default (opt-in)
|
|
321
349
|
@enable_scheduled_digests = false
|
|
@@ -483,6 +511,10 @@ module RailsErrorDashboard
|
|
|
483
511
|
# roughly one write per thread per interval.
|
|
484
512
|
@rack_attack_flush_interval = 5 # Seconds between DB flushes
|
|
485
513
|
|
|
514
|
+
# Missing-translation tracking - OFF by default (opt-in). Persists to its
|
|
515
|
+
# own table; does NOT require breadcrumbs.
|
|
516
|
+
@enable_missing_translation_tracking = false
|
|
517
|
+
|
|
486
518
|
# ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
487
519
|
@enable_actioncable_tracking = false
|
|
488
520
|
# ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
@@ -810,6 +842,15 @@ module RailsErrorDashboard
|
|
|
810
842
|
errors << "discord_webhook_url is required when enable_discord_notifications is true"
|
|
811
843
|
end
|
|
812
844
|
|
|
845
|
+
if enable_telegram_notifications
|
|
846
|
+
if telegram_bot_token.to_s.strip.empty?
|
|
847
|
+
errors << "telegram_bot_token is required when enable_telegram_notifications is true"
|
|
848
|
+
end
|
|
849
|
+
if telegram_chat_id.to_s.strip.empty?
|
|
850
|
+
errors << "telegram_chat_id is required when enable_telegram_notifications is true"
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
|
|
813
854
|
if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
|
|
814
855
|
errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
|
|
815
856
|
end
|
|
@@ -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!
|
|
@@ -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
|