rails_error_dashboard 0.14.3 → 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 +45 -17
- 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 +10 -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 +24 -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 +72 -5
- data/config/locales/en.yml +83 -4
- data/config/locales/es.yml +72 -5
- data/config/locales/fr.yml +72 -5
- data/config/locales/it.yml +72 -5
- data/config/locales/ja.yml +72 -4
- data/config/locales/pl.yml +72 -5
- data/config/locales/pt-BR.yml +72 -5
- data/config/locales/ru.yml +72 -5
- data/config/locales/uk.yml +72 -5
- data/config/locales/zh-CN.yml +72 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
- 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 +6 -3
- 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 +75 -2
- data/lib/rails_error_dashboard/engine.rb +52 -5
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
- data/lib/rails_error_dashboard/plugin.rb +49 -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 +11 -17
- 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/exception_filter.rb +4 -1
- 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/solid_queue_config_check.rb +47 -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
- data/lib/tasks/error_dashboard.rake +43 -2
- metadata +21 -2
|
@@ -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,
|
|
@@ -878,8 +878,11 @@ module RailsErrorDashboard
|
|
|
878
878
|
# Return if no anomaly detected
|
|
879
879
|
return unless anomaly[:anomaly]
|
|
880
880
|
|
|
881
|
-
#
|
|
882
|
-
|
|
881
|
+
# baseline_alert_severities lists ERROR severities (critical/high/
|
|
882
|
+
# medium/low, as validated), not anomaly levels. Comparing it with
|
|
883
|
+
# anomaly[:level] (elevated/high/critical) skipped every anomaly below
|
|
884
|
+
# threshold + 1 sigma, and :medium/:low never matched at all.
|
|
885
|
+
return unless config.baseline_alert_severities.include?(error_log.severity)
|
|
883
886
|
|
|
884
887
|
# Enqueue alert job (which will handle throttling)
|
|
885
888
|
BaselineAlertJob.perform_later(error_log.id, anomaly, ApplicationJob.enqueue_locale)
|
|
@@ -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
|
|
|
@@ -8,6 +8,15 @@ module RailsErrorDashboard
|
|
|
8
8
|
DEFAULT_DASHBOARD_USERNAME = "gandalf"
|
|
9
9
|
DEFAULT_DASHBOARD_PASSWORD = "youshallnotpass"
|
|
10
10
|
|
|
11
|
+
# Settings that hold credentials. #inspect masks them, so printing the
|
|
12
|
+
# configuration in a console (IRB shows results with pp, which uses
|
|
13
|
+
# #inspect) or pasting it into an issue doesn't leak them.
|
|
14
|
+
SECRET_ATTRIBUTES = %i[
|
|
15
|
+
dashboard_password slack_webhook_url discord_webhook_url pagerduty_integration_key
|
|
16
|
+
webhook_urls webhook_signing_secret issue_tracker_token issue_webhook_secret llm_api_key
|
|
17
|
+
telegram_bot_token
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
11
20
|
# Dashboard authentication (always required)
|
|
12
21
|
attr_accessor :dashboard_username
|
|
13
22
|
attr_accessor :dashboard_password
|
|
@@ -38,6 +47,14 @@ module RailsErrorDashboard
|
|
|
38
47
|
attr_accessor :discord_webhook_url
|
|
39
48
|
attr_accessor :enable_discord_notifications
|
|
40
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
|
+
|
|
41
58
|
# PagerDuty notifications (critical errors only)
|
|
42
59
|
attr_accessor :pagerduty_integration_key
|
|
43
60
|
attr_accessor :enable_pagerduty_notifications
|
|
@@ -45,6 +62,9 @@ module RailsErrorDashboard
|
|
|
45
62
|
# Generic webhook notifications
|
|
46
63
|
attr_accessor :webhook_urls
|
|
47
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
|
|
48
68
|
|
|
49
69
|
# Scheduled digests (daily/weekly summary emails)
|
|
50
70
|
attr_accessor :enable_scheduled_digests # Master switch (default: false)
|
|
@@ -223,6 +243,12 @@ module RailsErrorDashboard
|
|
|
223
243
|
attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
|
|
224
244
|
attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 5)
|
|
225
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
|
+
|
|
226
252
|
# ActionCable event tracking (requires enable_breadcrumbs = true)
|
|
227
253
|
attr_accessor :enable_actioncable_tracking # Master switch (default: false)
|
|
228
254
|
# ActiveStorage event tracking (requires enable_breadcrumbs = true)
|
|
@@ -301,6 +327,12 @@ module RailsErrorDashboard
|
|
|
301
327
|
@discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
302
328
|
@enable_discord_notifications = false
|
|
303
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
|
+
|
|
304
336
|
# PagerDuty notification settings (critical errors only)
|
|
305
337
|
@pagerduty_integration_key = ENV["PAGERDUTY_INTEGRATION_KEY"]
|
|
306
338
|
@enable_pagerduty_notifications = false
|
|
@@ -308,6 +340,10 @@ module RailsErrorDashboard
|
|
|
308
340
|
# Generic webhook settings (array of URLs)
|
|
309
341
|
@webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
310
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
|
|
311
347
|
|
|
312
348
|
# Scheduled digest defaults - OFF by default (opt-in)
|
|
313
349
|
@enable_scheduled_digests = false
|
|
@@ -351,7 +387,7 @@ module RailsErrorDashboard
|
|
|
351
387
|
|
|
352
388
|
# Rate limiting defaults
|
|
353
389
|
@enable_rate_limiting = false # OFF by default (opt-in)
|
|
354
|
-
@rate_limit_per_minute =
|
|
390
|
+
@rate_limit_per_minute = 300 # Requests per minute per IP, per dashboard path
|
|
355
391
|
|
|
356
392
|
# Enhanced metrics defaults
|
|
357
393
|
@app_version = ENV["APP_VERSION"]
|
|
@@ -380,7 +416,7 @@ module RailsErrorDashboard
|
|
|
380
416
|
# Baseline alert defaults
|
|
381
417
|
@enable_baseline_alerts = false # OFF by default (opt-in)
|
|
382
418
|
@baseline_alert_threshold_std_devs = ENV.fetch("BASELINE_ALERT_THRESHOLD", "2.0").to_f
|
|
383
|
-
@baseline_alert_severities = [ :critical, :high ] # Alert
|
|
419
|
+
@baseline_alert_severities = [ :critical, :high ] # Alert only for errors of these severities
|
|
384
420
|
@baseline_alert_cooldown_minutes = ENV.fetch("BASELINE_ALERT_COOLDOWN", "120").to_i
|
|
385
421
|
|
|
386
422
|
# Source code integration defaults - OFF by default (opt-in)
|
|
@@ -475,6 +511,10 @@ module RailsErrorDashboard
|
|
|
475
511
|
# roughly one write per thread per interval.
|
|
476
512
|
@rack_attack_flush_interval = 5 # Seconds between DB flushes
|
|
477
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
|
+
|
|
478
518
|
# ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
479
519
|
@enable_actioncable_tracking = false
|
|
480
520
|
# ActiveStorage event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
@@ -522,6 +562,30 @@ module RailsErrorDashboard
|
|
|
522
562
|
initialize
|
|
523
563
|
end
|
|
524
564
|
|
|
565
|
+
# Whether config/database.yml has the entry config.database names for this
|
|
566
|
+
# environment. Without it the engine skips connects_to and RED uses the
|
|
567
|
+
# main database, where its tables usually don't exist, so nothing is
|
|
568
|
+
# recorded. The engine and error_dashboard:verify both ask this.
|
|
569
|
+
#
|
|
570
|
+
# @param env_name [String] the Rails environment
|
|
571
|
+
# @return [Boolean]
|
|
572
|
+
def separate_database_entry?(env_name = Rails.env)
|
|
573
|
+
name = (database || :error_dashboard).to_s
|
|
574
|
+
ActiveRecord::Base.configurations.configs_for(env_name: env_name.to_s).any? { |config| config.name == name }
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Like Object#inspect, with every credential in SECRET_ATTRIBUTES replaced
|
|
578
|
+
# by [FILTERED] when set. Unset ones still show nil or [], which is useful
|
|
579
|
+
# when checking why a channel stays off.
|
|
580
|
+
def inspect
|
|
581
|
+
attributes = instance_variables.map do |ivar|
|
|
582
|
+
value = instance_variable_get(ivar)
|
|
583
|
+
secret = SECRET_ATTRIBUTES.include?(ivar.to_s.delete_prefix("@").to_sym) && value.present?
|
|
584
|
+
"#{ivar}=#{secret ? '[FILTERED]' : value.inspect}"
|
|
585
|
+
end
|
|
586
|
+
"#<#{self.class.name} #{attributes.join(', ')}>"
|
|
587
|
+
end
|
|
588
|
+
|
|
525
589
|
# Validate configuration values
|
|
526
590
|
# Raises ConfigurationError if any validation fails
|
|
527
591
|
# Logs warnings for non-fatal issues (e.g., Ruby version incompatibilities)
|
|
@@ -778,6 +842,15 @@ module RailsErrorDashboard
|
|
|
778
842
|
errors << "discord_webhook_url is required when enable_discord_notifications is true"
|
|
779
843
|
end
|
|
780
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
|
+
|
|
781
854
|
if enable_pagerduty_notifications && (pagerduty_integration_key.nil? || pagerduty_integration_key.strip.empty?)
|
|
782
855
|
errors << "pagerduty_integration_key is required when enable_pagerduty_notifications is true"
|
|
783
856
|
end
|
|
@@ -11,10 +11,11 @@ module RailsErrorDashboard
|
|
|
11
11
|
|
|
12
12
|
# Guard: skip connects_to if the database config doesn't exist yet in database.yml.
|
|
13
13
|
# This happens during `rails generate` when the initializer was just created but
|
|
14
|
-
# the user hasn't added the database.yml entry yet.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
# the user hasn't added the database.yml entry yet. Logged at error level:
|
|
15
|
+
# RED then uses the main database, and unless that has RED's tables no
|
|
16
|
+
# error is recorded. error_dashboard:verify reports it as FAILED.
|
|
17
|
+
unless RailsErrorDashboard.configuration.separate_database_entry?
|
|
18
|
+
Rails.logger.error "[Rails Error Dashboard] Separate database '#{database_name}' is not configured in database.yml for the '#{Rails.env}' environment. Skipping connects_to: errors are not recorded until it is added. See https://github.com/AnjanJ/rails_error_dashboard/blob/main/docs/guides/DATABASE_OPTIONS.md"
|
|
18
19
|
next
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -47,6 +48,22 @@ module RailsErrorDashboard
|
|
|
47
48
|
end
|
|
48
49
|
end
|
|
49
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
|
+
|
|
50
67
|
# Validate configuration after initialization
|
|
51
68
|
initializer "rails_error_dashboard.validate_config", after: :load_config_initializers do
|
|
52
69
|
config.after_initialize do
|
|
@@ -76,6 +93,11 @@ module RailsErrorDashboard
|
|
|
76
93
|
# configured: then it is never consulted.
|
|
77
94
|
RailsErrorDashboard.detected_git_sha if RailsErrorDashboard.configuration.git_sha.blank?
|
|
78
95
|
|
|
96
|
+
# One error-level line if Solid Queue runs RED's jobs with a
|
|
97
|
+
# config/queue.yml that never would (no dispatcher, no worker for RED's
|
|
98
|
+
# queues). Reads the file once; never raises. The dashboard shows a banner.
|
|
99
|
+
RailsErrorDashboard::Services::SolidQueueConfigCheck.log_current_problems
|
|
100
|
+
|
|
79
101
|
if RailsErrorDashboard.configuration.enable_error_subscriber
|
|
80
102
|
Rails.error.subscribe(RailsErrorDashboard::ErrorReporter.new)
|
|
81
103
|
end
|
|
@@ -127,6 +149,28 @@ module RailsErrorDashboard
|
|
|
127
149
|
at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
|
|
128
150
|
end
|
|
129
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
|
+
|
|
130
174
|
# Drain the storm count buffer at the end of every request and job, and
|
|
131
175
|
# again at process exit.
|
|
132
176
|
#
|
|
@@ -218,7 +262,10 @@ module RailsErrorDashboard
|
|
|
218
262
|
end
|
|
219
263
|
end
|
|
220
264
|
|
|
221
|
-
# 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.
|
|
222
269
|
if RailsErrorDashboard.configuration.enable_crash_capture
|
|
223
270
|
RailsErrorDashboard::Services::CrashCapture.import!
|
|
224
271
|
RailsErrorDashboard::Services::CrashCapture.enable!
|
|
@@ -2,12 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Middleware
|
|
5
|
-
# Rate limiting middleware for Rails Error Dashboard routes
|
|
6
|
-
#
|
|
5
|
+
# Rate limiting middleware for Rails Error Dashboard routes: at most
|
|
6
|
+
# config.rate_limit_per_minute requests per IP per path, per minute.
|
|
7
7
|
class RateLimiter
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
DASHBOARD_LIMIT = { limit: 300, period: 60 }.freeze # 300 req/min
|
|
8
|
+
PERIOD = 60 # seconds
|
|
9
|
+
DEFAULT_LIMIT = 300 # requests per PERIOD, when rate_limit_per_minute isn't a positive number
|
|
11
10
|
|
|
12
11
|
def initialize(app)
|
|
13
12
|
@app = app
|
|
@@ -22,16 +21,14 @@ module RailsErrorDashboard
|
|
|
22
21
|
# Only apply rate limiting to error dashboard routes
|
|
23
22
|
return @app.call(env) unless error_dashboard_route?(request.path)
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
limit_config = find_limit_config(request.path)
|
|
27
|
-
return @app.call(env) unless limit_config
|
|
24
|
+
limit_config = { limit: configured_limit, period: PERIOD }
|
|
28
25
|
|
|
29
26
|
# Check rate limit
|
|
30
|
-
key = rate_limit_key(request)
|
|
27
|
+
key = rate_limit_key(request, limit_config)
|
|
31
28
|
current_count = @cache.read(key).to_i
|
|
32
29
|
|
|
33
30
|
if current_count >= limit_config[:limit]
|
|
34
|
-
return
|
|
31
|
+
return html_rate_limit_response(limit_config)
|
|
35
32
|
end
|
|
36
33
|
|
|
37
34
|
# Increment counter with expiration
|
|
@@ -46,55 +43,36 @@ module RailsErrorDashboard
|
|
|
46
43
|
RailsErrorDashboard.configuration.enable_rate_limiting
|
|
47
44
|
end
|
|
48
45
|
|
|
46
|
+
# nil passes validation, and a limit of 0 would refuse every request.
|
|
47
|
+
def configured_limit
|
|
48
|
+
limit = RailsErrorDashboard.configuration.rate_limit_per_minute.to_i
|
|
49
|
+
limit.positive? ? limit : DEFAULT_LIMIT
|
|
50
|
+
end
|
|
51
|
+
|
|
49
52
|
def engine_mount_path
|
|
50
53
|
@engine_mount_path ||= RailsErrorDashboard.configuration.engine_mount_path
|
|
51
54
|
rescue
|
|
52
55
|
"/red"
|
|
53
56
|
end
|
|
54
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.
|
|
55
61
|
def error_dashboard_route?(path)
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
mount = engine_mount_path.to_s.chomp("/")
|
|
63
|
+
return true if mount.empty?
|
|
58
64
|
|
|
59
|
-
|
|
60
|
-
if path.start_with?("#{engine_mount_path}/api")
|
|
61
|
-
API_LIMIT
|
|
62
|
-
else
|
|
63
|
-
DASHBOARD_LIMIT
|
|
64
|
-
end
|
|
65
|
+
path == mount || path.start_with?("#{mount}/")
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
def rate_limit_key(request)
|
|
68
|
-
# Key format: rate_limit:IP:
|
|
68
|
+
def rate_limit_key(request, limit_config)
|
|
69
|
+
# Key format: rate_limit:IP:path:time_window
|
|
69
70
|
# Time window ensures keys expire and reset
|
|
70
|
-
limit_config = find_limit_config(request.path)
|
|
71
71
|
time_window = Time.now.to_i / limit_config[:period]
|
|
72
72
|
|
|
73
73
|
"rate_limit:#{request.ip}:#{request.path}:#{time_window}"
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
def rate_limit_response(request, limit_config)
|
|
77
|
-
# Return JSON for API requests, HTML for dashboard
|
|
78
|
-
if request.path.start_with?("#{engine_mount_path}/api")
|
|
79
|
-
json_rate_limit_response(limit_config)
|
|
80
|
-
else
|
|
81
|
-
html_rate_limit_response(limit_config)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def json_rate_limit_response(limit_config)
|
|
86
|
-
[
|
|
87
|
-
429,
|
|
88
|
-
{
|
|
89
|
-
"Content-Type" => "application/json",
|
|
90
|
-
"Retry-After" => limit_config[:period].to_s,
|
|
91
|
-
"X-RateLimit-Limit" => limit_config[:limit].to_s,
|
|
92
|
-
"X-RateLimit-Period" => "#{limit_config[:period]} seconds"
|
|
93
|
-
},
|
|
94
|
-
[ { error: "Rate limit exceeded. Please try again later." }.to_json ]
|
|
95
|
-
]
|
|
96
|
-
end
|
|
97
|
-
|
|
98
76
|
def html_rate_limit_response(limit_config)
|
|
99
77
|
body = <<~HTML
|
|
100
78
|
<!DOCTYPE html>
|
|
@@ -54,12 +54,42 @@ module RailsErrorDashboard
|
|
|
54
54
|
# Override in subclass to handle event
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# Called when a resolved error occurs again and is reopened
|
|
58
|
+
# @param error_log [ErrorLog] The reopened error log
|
|
59
|
+
def on_error_reopened(error_log)
|
|
60
|
+
# Override in subclass to handle event
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
# Called when an error is resolved
|
|
58
64
|
# @param error_log [ErrorLog] The resolved error log
|
|
59
65
|
def on_error_resolved(error_log)
|
|
60
66
|
# Override in subclass to handle event
|
|
61
67
|
end
|
|
62
68
|
|
|
69
|
+
# Called when an error is muted
|
|
70
|
+
# @param error_log [ErrorLog] The muted error log
|
|
71
|
+
def on_error_muted(error_log)
|
|
72
|
+
# Override in subclass to handle event
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Called when an error is unmuted
|
|
76
|
+
# @param error_log [ErrorLog] The unmuted error log
|
|
77
|
+
def on_error_unmuted(error_log)
|
|
78
|
+
# Override in subclass to handle event
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Called when errors are batch muted
|
|
82
|
+
# @param error_logs [Array<ErrorLog>] The muted error logs
|
|
83
|
+
def on_errors_batch_muted(error_logs)
|
|
84
|
+
# Override in subclass to handle event
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Called when errors are batch unmuted
|
|
88
|
+
# @param error_logs [Array<ErrorLog>] The unmuted error logs
|
|
89
|
+
def on_errors_batch_unmuted(error_logs)
|
|
90
|
+
# Override in subclass to handle event
|
|
91
|
+
end
|
|
92
|
+
|
|
63
93
|
# Called when errors are batch resolved
|
|
64
94
|
# @param error_logs [Array<ErrorLog>] The resolved error logs
|
|
65
95
|
def on_errors_batch_resolved(error_logs)
|
|
@@ -91,11 +121,27 @@ module RailsErrorDashboard
|
|
|
91
121
|
|
|
92
122
|
send(method_name, *args)
|
|
93
123
|
rescue => e
|
|
94
|
-
# Log plugin failures but never propagate - plugins must not break the app
|
|
95
|
-
|
|
96
|
-
|
|
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}")
|
|
97
129
|
RailsErrorDashboard::Logger.error(e.backtrace&.first(10)&.join("\n")) if e.backtrace
|
|
98
130
|
nil # Explicitly return nil, never raise
|
|
99
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
|
|
100
146
|
end
|
|
101
147
|
end
|