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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -17
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +10 -2
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
  8. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  9. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  10. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  11. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  12. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  13. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
  14. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  15. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  16. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  17. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  18. data/app/models/rails_error_dashboard/error_log.rb +43 -12
  19. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +24 -1
  21. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
  22. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  23. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  24. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  25. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  26. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  28. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  29. data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
  30. data/config/locales/de.yml +72 -5
  31. data/config/locales/en.yml +83 -4
  32. data/config/locales/es.yml +72 -5
  33. data/config/locales/fr.yml +72 -5
  34. data/config/locales/it.yml +72 -5
  35. data/config/locales/ja.yml +72 -4
  36. data/config/locales/pl.yml +72 -5
  37. data/config/locales/pt-BR.yml +72 -5
  38. data/config/locales/ru.yml +72 -5
  39. data/config/locales/uk.yml +72 -5
  40. data/config/locales/zh-CN.yml +72 -4
  41. data/config/routes.rb +6 -0
  42. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  43. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
  44. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  45. data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
  46. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
  47. data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
  48. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  49. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  50. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  51. data/lib/rails_error_dashboard/commands/log_error.rb +6 -3
  52. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  53. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  54. data/lib/rails_error_dashboard/configuration.rb +75 -2
  55. data/lib/rails_error_dashboard/engine.rb +52 -5
  56. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
  57. data/lib/rails_error_dashboard/plugin.rb +49 -3
  58. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  59. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  60. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  61. data/lib/rails_error_dashboard/queries/errors_list.rb +11 -17
  62. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  63. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  64. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  65. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  66. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  67. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  68. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  69. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  70. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  71. data/lib/rails_error_dashboard/services/exception_filter.rb +4 -1
  72. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  73. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  74. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  75. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  76. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  77. data/lib/rails_error_dashboard/services/solid_queue_config_check.rb +47 -0
  78. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  79. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  80. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  81. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  82. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  83. data/lib/rails_error_dashboard/version.rb +1 -1
  84. data/lib/rails_error_dashboard.rb +12 -0
  85. data/lib/tasks/error_dashboard.rake +43 -2
  86. metadata +21 -2
@@ -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
- if plugins.any? { |p| p.name == plugin.name }
21
- RailsErrorDashboard::Logger.warn("Plugin '#{plugin.name}' is already registered, skipping")
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.on_register
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.name == plugin_name }
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.name == plugin_name }
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 plugin.enabled?
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(&:name)
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.name,
80
- version: plugin.version,
81
- description: plugin.description,
82
- enabled: plugin.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
@@ -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
@@ -305,19 +305,11 @@ module RailsErrorDashboard
305
305
  # breakdown makes them agree by construction instead of by luck --
306
306
  # asserted by spec/queries/dashboard_breakdowns_event_volume_spec.rb.
307
307
  def errors_by_severity_7d
308
- critical = Services::SeverityClassifier::CRITICAL_ERROR_TYPES
309
- high = Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
310
- medium = Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
311
-
312
308
  totals = { critical: 0, high: 0, medium: 0, low: 0 }
313
309
  weekly_volume.by_group_attribute(:error_type).each do |error_type, count|
314
- bucket =
315
- if critical.include?(error_type) then :critical
316
- elsif high.include?(error_type) then :high
317
- elsif medium.include?(error_type) then :medium
318
- else :low
319
- end
320
- totals[bucket] += count
310
+ # classify honours custom_severity_rules; the bare constants did not.
311
+ bucket = Services::SeverityClassifier.classify(error_type)
312
+ totals[totals.key?(bucket) ? bucket : :low] += count
321
313
  end
322
314
  totals
323
315
  end
@@ -150,33 +150,27 @@ module RailsErrorDashboard
150
150
  end
151
151
  end
152
152
 
153
+ # RED's own connection: with a separate error database, the main
154
+ # database can use a different adapter.
153
155
  def postgresql?
154
- ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
156
+ ErrorLogsRecord.connection.adapter_name.downcase == "postgresql"
155
157
  end
156
158
 
157
159
  def filter_by_severity(query)
158
160
  return query unless @filters[:severity].present?
159
161
 
160
- # Map severity levels to error types
161
- error_types = case @filters[:severity].to_sym
162
- when :critical
163
- Services::SeverityClassifier::CRITICAL_ERROR_TYPES
164
- when :high
165
- Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
166
- when :medium
167
- Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
162
+ # Severity is not a column: it is classified from error_type, built-in
163
+ # lists plus config.custom_severity_rules. The classifier owns both, so
164
+ # the filter agrees with the badges by construction.
165
+ case @filters[:severity].to_sym
166
+ when :critical, :high, :medium
167
+ query.where(error_type: Services::SeverityClassifier.error_types_for(@filters[:severity]))
168
168
  when :low
169
169
  # Low severity = everything NOT in the other categories
170
- all_categorized = Services::SeverityClassifier::CRITICAL_ERROR_TYPES +
171
- Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
172
- Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
173
- # Use NOT IN to filter out categorized errors
174
- return query.where.not(error_type: all_categorized)
170
+ query.where.not(error_type: Services::SeverityClassifier.categorized_error_types)
175
171
  else
176
- return query
172
+ query
177
173
  end
178
-
179
- query.where(error_type: error_types)
180
174
  end
181
175
 
182
176
  # Phase 3: Workflow filter methods
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: is the error dashboard itself able to do its job right now?
6
+ #
7
+ # "Who watches the watchmen": if the error database is unreachable, or the
8
+ # async queue that carries captures is misconfigured, no error ever reaches
9
+ # the dashboard and nothing else will say so. GET /health answers that
10
+ # question for an uptime monitor.
11
+ #
12
+ # Four checks, each individually rescued so one failing probe never hides
13
+ # the others:
14
+ #
15
+ # database SELECT 1 on the ERROR database (ErrorLogsRecord's
16
+ # pool, so use_separate_database is honoured), plus
17
+ # "are RED's tables there". The one check that can make
18
+ # the whole answer "down".
19
+ # errors last capture time and the last-24h count: cheap,
20
+ # both served by the occurred_at index.
21
+ # queue the Active Job adapter RED's jobs run on, whether
22
+ # captures are async, and the Solid Queue config
23
+ # problems the boot check found (memoised per process).
24
+ # storm_protection the circuit breaker's current state.
25
+ # active_storage one existence check against the host's configured
26
+ # storage service (Services::ActiveStorageProbe).
27
+ # Storage is the app's dependency, not RED's, so a
28
+ # failure here degrades the answer rather than taking
29
+ # it down; "skipped" when ActiveStorage is not set up.
30
+ #
31
+ # Budget: one DB round trip plus two indexed queries. No COUNT(*) over the
32
+ # whole table (unbounded on a large install), no file reads (the Solid
33
+ # Queue check is memoised), nothing external. Never raises.
34
+ class HealthStatus
35
+ OK = "ok"
36
+ DEGRADED = "degraded"
37
+ DOWN = "down"
38
+ # A check that does not apply to this host: neutral for the overall answer.
39
+ SKIPPED = "skipped"
40
+
41
+ def self.call
42
+ new.call
43
+ end
44
+
45
+ def call
46
+ started = monotonic_now
47
+
48
+ database = check_database
49
+ checks = {
50
+ database: database,
51
+ # Skipped when the database is down: the queries would only fail
52
+ # again, slower, against a server that is not answering.
53
+ errors: database[:status] == DOWN ? skipped("database unreachable") : check_errors,
54
+ queue: check_queue,
55
+ storm_protection: check_storm_protection,
56
+ active_storage: check_active_storage
57
+ }
58
+
59
+ {
60
+ status: overall_status(checks),
61
+ version: RailsErrorDashboard::VERSION,
62
+ timestamp: Time.current.utc.iso8601,
63
+ checks: checks,
64
+ duration_ms: elapsed_ms(started)
65
+ }
66
+ rescue StandardError => e
67
+ # Last line of defence: the controller must always have something to
68
+ # render, and a health endpoint that 500s is the wrong kind of alarm.
69
+ {
70
+ status: DOWN,
71
+ version: RailsErrorDashboard::VERSION,
72
+ timestamp: Time.current.utc.iso8601,
73
+ checks: {},
74
+ error: e.class.name
75
+ }
76
+ end
77
+
78
+ # The HTTP status the controller should answer with.
79
+ def self.http_status_for(result)
80
+ result[:status] == DOWN ? 503 : 200
81
+ end
82
+
83
+ private
84
+
85
+ def check_database
86
+ config = RailsErrorDashboard.configuration
87
+ started = monotonic_now
88
+
89
+ adapter = nil
90
+ ErrorLogsRecord.connection_pool.with_connection do |connection|
91
+ adapter = connection.adapter_name
92
+ connection.select_value("SELECT 1")
93
+ end
94
+
95
+ tables_present = ErrorLog.table_exists?
96
+
97
+ {
98
+ status: tables_present ? OK : DOWN,
99
+ adapter: adapter,
100
+ separate_database: config.use_separate_database ? true : false,
101
+ tables_present: tables_present,
102
+ latency_ms: elapsed_ms(started)
103
+ }.tap do |check|
104
+ check[:reason] = "missing_tables" unless tables_present
105
+ end
106
+ rescue StandardError => e
107
+ {
108
+ status: DOWN,
109
+ separate_database: RailsErrorDashboard.configuration.use_separate_database ? true : false,
110
+ # Class only: the message can carry a host name or a connection string.
111
+ error: e.class.name
112
+ }
113
+ end
114
+
115
+ def check_errors
116
+ last = ErrorLog.maximum(:occurred_at)
117
+ {
118
+ status: OK,
119
+ last_error_at: last&.utc&.iso8601,
120
+ last_24h: ErrorLog.where("occurred_at >= ?", 24.hours.ago).count
121
+ }
122
+ rescue StandardError => e
123
+ { status: DEGRADED, error: e.class.name }
124
+ end
125
+
126
+ def check_queue
127
+ config = RailsErrorDashboard.configuration
128
+ problems = Services::SolidQueueConfigCheck.current_problems
129
+
130
+ {
131
+ status: problems.empty? ? OK : DEGRADED,
132
+ async_logging: config.async_logging ? true : false,
133
+ adapter: ApplicationJob.queue_adapter_name.to_s,
134
+ problems: problems
135
+ }
136
+ rescue StandardError => e
137
+ { status: DEGRADED, error: e.class.name }
138
+ end
139
+
140
+ def check_storm_protection
141
+ enabled = RailsErrorDashboard.configuration.enable_storm_protection ? true : false
142
+ state = enabled ? Services::StormProtection::Gate.state.to_s : "closed"
143
+
144
+ {
145
+ # Any state but closed means captures are being shed or merely
146
+ # counted: the dashboard is lagging reality by design, and a monitor
147
+ # should know.
148
+ status: state == "closed" ? OK : DEGRADED,
149
+ enabled: enabled,
150
+ state: state
151
+ }
152
+ rescue StandardError => e
153
+ { status: DEGRADED, error: e.class.name }
154
+ end
155
+
156
+ def check_active_storage
157
+ probe = Services::ActiveStorageProbe.call
158
+ case probe[:status]
159
+ when Services::ActiveStorageProbe::OK
160
+ { status: OK, service: probe[:service], latency_ms: probe[:latency_ms] }
161
+ when Services::ActiveStorageProbe::SKIPPED
162
+ { status: SKIPPED, reason: probe[:reason] }
163
+ else
164
+ { status: DEGRADED, service: probe[:service], error: probe[:error] }
165
+ end
166
+ rescue StandardError => e
167
+ { status: DEGRADED, error: e.class.name }
168
+ end
169
+
170
+ def skipped(reason)
171
+ { status: DEGRADED, skipped: reason }
172
+ end
173
+
174
+ def overall_status(checks)
175
+ statuses = checks.values.map { |c| c[:status] }
176
+ return DOWN if statuses.include?(DOWN)
177
+ return DEGRADED if statuses.include?(DEGRADED)
178
+
179
+ OK
180
+ end
181
+
182
+ def monotonic_now
183
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+
186
+ def elapsed_ms(started)
187
+ ((monotonic_now - started) * 1000).round(2)
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: the host app's missing translation keys, most-missed first.
6
+ #
7
+ # Reads the missing_translations table (indexed SQL, no JSON parsing) for
8
+ # rows last seen inside the window. One entry per (locale, key): the same
9
+ # key missing in two locales is two rows, because fixing one locale file
10
+ # does not fix the other.
11
+ class MissingTranslationSummary
12
+ def self.call(days = 30, application_id: nil)
13
+ new(days, application_id: application_id).call
14
+ end
15
+
16
+ def initialize(days = 30, application_id: nil)
17
+ @days = days
18
+ @application_id = application_id
19
+ @start_date = days.days.ago
20
+ end
21
+
22
+ # @return [Hash] entries: [{locale:, key:, source:, count:, first_seen:, last_seen:}],
23
+ # overflow_count: Integer, locales: [String]
24
+ # One pluck of every in-window row; entries, the locale set and the
25
+ # overflow total all come out of it, so a page view is two statements
26
+ # (table_exists? and this) whatever the row count.
27
+ def call
28
+ return empty unless MissingTranslation.table_exists?
29
+
30
+ merged = {}
31
+ overflow = 0
32
+
33
+ base_query.pluck(:locale, :translation_key, :source, :miss_count, :first_seen_at, :last_seen_at)
34
+ .each do |locale, key, source, count, first_seen, last_seen|
35
+ # Misses the tracker could not attribute to a key because its
36
+ # buffer was full: kept out of the listing (they belong to no
37
+ # key) but totalled, so the page never silently understates.
38
+ if key == MissingTranslation::OVERFLOW_KEY
39
+ overflow += count.to_i
40
+ next
41
+ end
42
+
43
+ # Merged by (locale, key) rather than trusted to be unique: the
44
+ # upsert index includes application_id, and a NULL there (no
45
+ # application_name configured) lets two threads' first writes of
46
+ # the same key both land, on every database. Two rows for one key
47
+ # would read as two problems; summing them reads as one.
48
+ entry = merged[[ locale.to_s, key.to_s ]] ||= {
49
+ locale: locale.to_s, key: key.to_s, source: nil, count: 0, first_seen: nil, last_seen: nil
50
+ }
51
+ entry[:count] += count.to_i
52
+ entry[:source] ||= source.presence
53
+ entry[:first_seen] = [ entry[:first_seen], first_seen ].compact.min
54
+ entry[:last_seen] = [ entry[:last_seen], last_seen ].compact.max
55
+ end
56
+
57
+ entries = merged.values.sort_by { |e| [ -e[:count], -(e[:last_seen]&.to_f || 0) ] }
58
+
59
+ {
60
+ entries: entries,
61
+ overflow_count: overflow,
62
+ locales: entries.map { |e| e[:locale] }.uniq.sort
63
+ }
64
+ rescue => e
65
+ Rails.logger.error("[RailsErrorDashboard] MissingTranslationSummary query failed: #{e.class}: #{e.message}")
66
+ empty
67
+ end
68
+
69
+ private
70
+
71
+ def empty
72
+ { entries: [], overflow_count: 0, locales: [] }
73
+ end
74
+
75
+ def base_query
76
+ scope = MissingTranslation.seen_since(@start_date)
77
+ scope = scope.for_application(@application_id) if @application_id.present?
78
+ scope
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Is the host's ActiveStorage service reachable right now?
6
+ #
7
+ # One existence check for a key that is never stored. The answer itself
8
+ # (false) is irrelevant; the round trip is the test: Disk stats a path,
9
+ # S3/GCS/Azure make one HEAD-style request. A raise (credentials, DNS, a
10
+ # bucket that no longer exists, a disk that is not mounted) means the
11
+ # service is down for the app too, which is what an operator wants to
12
+ # know before the upload errors start.
13
+ #
14
+ # Read by the /health endpoint and the ActiveStorage Health page, both
15
+ # on demand: nothing here runs on the capture path. Never raises.
16
+ class ActiveStorageProbe
17
+ # Namespaced so it cannot collide with a real blob key, which are
18
+ # 28-character base36 tokens.
19
+ PROBE_KEY = "rails_error_dashboard/health-probe"
20
+
21
+ OK = "ok"
22
+ DOWN = "down"
23
+ SKIPPED = "skipped"
24
+
25
+ def self.call
26
+ new.call
27
+ end
28
+
29
+ def call
30
+ return skipped("active_storage_not_loaded") unless defined?(::ActiveStorage)
31
+ # Checked before ActiveStorage::Blob is touched: loading that class
32
+ # reads config/storage.yml and raises when the file is missing, which
33
+ # on a host that never set storage up is "not configured", not "down".
34
+ return skipped("no_service_configured") unless configured?
35
+
36
+ service = storage_service
37
+ return skipped("no_service_configured") if service.nil?
38
+
39
+ started = monotonic_now
40
+ service.exist?(PROBE_KEY)
41
+
42
+ { status: OK, service: service_name(service), latency_ms: elapsed_ms(started) }
43
+ rescue => e
44
+ # Class only: the message can carry a bucket name, a host or a path.
45
+ { status: DOWN, service: safe_service_name(service), error: e.class.name }
46
+ end
47
+
48
+ private
49
+
50
+ def skipped(reason)
51
+ { status: SKIPPED, reason: reason }
52
+ end
53
+
54
+ # The configured service object. A seam: specs replace it with a double
55
+ # rather than stubbing ActiveStorage::Blob, whose class load reads the
56
+ # blobs table's schema (absent in the dummy app).
57
+ def storage_service
58
+ ::ActiveStorage::Blob.service
59
+ end
60
+
61
+ # config.active_storage.service names the service chosen for this
62
+ # environment; nil when the app never configured storage.
63
+ def configured?
64
+ ::Rails.application.config.active_storage.service.present?
65
+ rescue
66
+ false
67
+ end
68
+
69
+ # The name from config/storage.yml ("amazon", "local"), which is what
70
+ # the operator recognises; the class otherwise.
71
+ def service_name(service)
72
+ name = service.respond_to?(:name) ? service.name : nil
73
+ name.presence&.to_s || service.class.name.to_s.demodulize.delete_suffix("Service").downcase
74
+ end
75
+
76
+ def safe_service_name(service)
77
+ service ? service_name(service) : nil
78
+ rescue
79
+ nil
80
+ end
81
+
82
+ def monotonic_now
83
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
84
+ end
85
+
86
+ def elapsed_ms(started)
87
+ ((monotonic_now - started) * 1000).round(2)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -94,15 +94,21 @@ module RailsErrorDashboard
94
94
  end
95
95
 
96
96
  def app_code?(file_path)
97
- # Match /app/ or app/ (shortened paths start without leading /)
98
- return true if file_path.include?("/app/") || file_path.start_with?("app/")
97
+ # A gem's own app/ or lib/ (an engine's models, a gem's lib) is not
98
+ # the host's code: it got the View Source button and a link into the
99
+ # host's repository for a file that is not there.
100
+ return false if gem_code?(file_path) || ruby_code?(file_path)
99
101
 
100
- # Match /lib/ or lib/ but exclude gems and ruby stdlib
101
- lib_path = file_path.include?("/lib/") || file_path.start_with?("lib/")
102
- return false unless lib_path
102
+ # Match /app/ or app/ (shortened paths start without leading /), and
103
+ # the other host directories a Rails.root-relative frame can start with
104
+ return true if file_path.include?("/app/") || file_path.start_with?("app/", "config/", "db/")
103
105
 
104
- !file_path.include?("/gems/") && !file_path.include?("/ruby/") &&
105
- !file_path.start_with?("gems/") && !file_path.start_with?("ruby/")
106
+ # Match /lib/ or lib/ (the host's lib/, gems and stdlib excluded above)
107
+ file_path.include?("/lib/") || file_path.start_with?("lib/")
108
+ end
109
+
110
+ def ruby_code?(file_path)
111
+ file_path.include?("/ruby/") || file_path.start_with?("ruby/")
106
112
  end
107
113
 
108
114
  def gem_code?(file_path)