rails_error_dashboard 0.14.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -8
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +97 -4
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  7. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  8. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +63 -16
  9. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  10. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  11. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  12. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  13. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  14. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  15. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +31 -0
  16. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  17. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  18. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  19. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  20. data/app/models/rails_error_dashboard/error_log.rb +54 -20
  21. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  22. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  23. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  24. data/app/views/layouts/rails_error_dashboard.html.erb +14 -2
  25. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  26. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +12 -3
  27. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  28. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  29. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  30. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  31. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  32. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  33. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  34. data/app/views/rails_error_dashboard/errors/overview.html.erb +10 -7
  35. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
  36. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  37. data/app/views/rails_error_dashboard/errors/settings.html.erb +23 -8
  38. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  39. data/config/locales/de.yml +87 -5
  40. data/config/locales/en.yml +99 -4
  41. data/config/locales/es.yml +88 -5
  42. data/config/locales/fr.yml +88 -5
  43. data/config/locales/it.yml +88 -5
  44. data/config/locales/ja.yml +86 -4
  45. data/config/locales/pl.yml +89 -5
  46. data/config/locales/pt-BR.yml +88 -5
  47. data/config/locales/ru.yml +89 -5
  48. data/config/locales/uk.yml +89 -5
  49. data/config/locales/zh-CN.yml +86 -4
  50. data/config/routes.rb +6 -0
  51. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  52. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  53. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  54. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  55. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  56. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  57. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +41 -5
  58. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  59. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +16 -3
  61. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  62. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  63. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +101 -0
  64. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  65. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  66. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  67. data/lib/rails_error_dashboard/commands/log_error.rb +20 -8
  68. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  69. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  70. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  71. data/lib/rails_error_dashboard/configuration.rb +61 -4
  72. data/lib/rails_error_dashboard/engine.rb +42 -1
  73. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  74. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  75. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  76. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  77. data/lib/rails_error_dashboard/plugin.rb +19 -3
  78. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  79. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  80. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  81. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  82. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  83. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  84. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  85. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  86. data/lib/rails_error_dashboard/queries/platform_comparison.rb +14 -6
  87. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  88. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  89. data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -7
  90. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  91. data/lib/rails_error_dashboard/services/crash_capture.rb +90 -2
  92. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  93. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  94. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -14
  95. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  96. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  97. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  98. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  99. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  100. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  101. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  102. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  103. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  104. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  105. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  106. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  107. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  108. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  109. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  110. data/lib/rails_error_dashboard/version.rb +1 -1
  111. data/lib/rails_error_dashboard.rb +28 -0
  112. data/lib/tasks/error_dashboard.rake +2 -3
  113. metadata +29 -2
@@ -16,6 +16,13 @@ module RailsErrorDashboard
16
16
  # puts "#{result[:error].error_type} occurred #{result[:frequency]} times together"
17
17
  # end
18
18
  class CoOccurringErrors
19
+ # The newest occurrences of the target error that are compared. This
20
+ # renders on every error page while the feature is on, and it used to run
21
+ # one window query per lifetime occurrence: 50k occurrences, 50k queries.
22
+ TARGET_OCCURRENCES = 500
23
+ # At most this many occurrences of other errors are read for those windows
24
+ NEARBY_OCCURRENCES = 20_000
25
+
19
26
  # Find co-occurring errors
20
27
  #
21
28
  # @param error_log_id [Integer] ID of target error
@@ -34,53 +41,72 @@ module RailsErrorDashboard
34
41
  @limit = limit.to_i
35
42
  end
36
43
 
44
+ # Four queries however many occurrences there are: the target exists, its
45
+ # newest occurrence times, other errors' occurrences in those windows, and
46
+ # the co-occurring errors themselves.
37
47
  def find_co_occurring
38
- target_error = ErrorLog.find_by(id: @error_log_id)
39
- return [] unless target_error
40
-
41
- # Get all occurrences of the target error
42
- target_occurrences = ErrorOccurrence.where(error_log_id: @error_log_id)
43
- return [] if target_occurrences.empty?
44
-
45
- # For each occurrence, find other errors in the time window
46
- co_occurrence_data = Hash.new { |h, k| h[k] = { count: 0, delays: [] } }
48
+ return [] unless ErrorLog.exists?(id: @error_log_id)
47
49
 
48
- target_occurrences.find_each do |occurrence|
49
- window = @window_minutes.minutes
50
- start_time = occurrence.occurred_at - window
51
- end_time = occurrence.occurred_at + window
50
+ targets = ErrorOccurrence.where(error_log_id: @error_log_id)
51
+ .order(occurred_at: :desc).limit(TARGET_OCCURRENCES).pluck(:occurred_at).sort
52
+ return [] if targets.empty?
52
53
 
53
- # Find other error occurrences in this time window
54
- nearby_occurrences = ErrorOccurrence
55
- .in_time_window(start_time, end_time)
56
- .where.not(error_log_id: @error_log_id)
57
- .includes(:error_log)
54
+ window = @window_minutes.minutes
55
+ stats = Hash.new { |h, k| h[k] = { count: 0, delay_sum: 0.0 } }
58
56
 
59
- nearby_occurrences.each do |nearby|
60
- error_log_id = nearby.error_log_id
61
- co_occurrence_data[error_log_id][:count] += 1
57
+ nearby_occurrences(merged_windows(targets, window)).each do |error_log_id, occurred_at|
58
+ # Each target occurrence within the window of this one is a pair, as
59
+ # before. Delay is negative when this error came first.
60
+ index = targets.bsearch_index { |target| target >= occurred_at - window }
61
+ next unless index
62
62
 
63
- # Calculate delay (negative = before, positive = after target error)
64
- delay = (nearby.occurred_at - occurrence.occurred_at).to_f
65
- co_occurrence_data[error_log_id][:delays] << delay
63
+ while index < targets.size && targets[index] <= occurred_at + window
64
+ stats[error_log_id][:count] += 1
65
+ stats[error_log_id][:delay_sum] += (occurred_at - targets[index]).to_f
66
+ index += 1
66
67
  end
67
68
  end
68
69
 
69
- # Filter by minimum frequency and build results
70
- results = co_occurrence_data.select { |_id, data| data[:count] >= @min_frequency }.map do |error_log_id, data|
71
- error = ErrorLog.find(error_log_id)
72
- avg_delay = data[:delays].sum / data[:delays].size
70
+ frequent = stats.select { |_id, data| data[:count] >= @min_frequency }
71
+ errors = ErrorLog.where(id: frequent.keys).index_by(&:id)
73
72
 
74
- {
75
- error: error,
76
- frequency: data[:count],
77
- avg_delay_seconds: avg_delay.round(2)
78
- }
73
+ results = frequent.filter_map do |error_log_id, data|
74
+ error = errors[error_log_id] or next
75
+ { error: error, frequency: data[:count], avg_delay_seconds: (data[:delay_sum] / data[:count]).round(2) }
79
76
  end
80
77
 
81
78
  # Sort by frequency (most common first) and limit results
82
79
  results.sort_by { |r| -r[:frequency] }.first(@limit)
83
80
  end
81
+
82
+ private
83
+
84
+ # The windows around each target time, overlapping ones merged, so a burst
85
+ # of target occurrences becomes one range
86
+ def merged_windows(targets, window)
87
+ targets.each_with_object([]) do |target, ranges|
88
+ from = target - window
89
+ to = target + window
90
+ if ranges.any? && from <= ranges.last[1]
91
+ ranges.last[1] = to
92
+ else
93
+ ranges << [ from, to ]
94
+ end
95
+ end
96
+ end
97
+
98
+ # [error_log_id, occurred_at] for other errors' occurrences in any window
99
+ def nearby_occurrences(ranges)
100
+ column = "#{ErrorOccurrence.table_name}.occurred_at"
101
+ in_a_window = ranges.map { "(#{column} BETWEEN ? AND ?)" }.join(" OR ")
102
+
103
+ ErrorOccurrence
104
+ .where.not(error_log_id: @error_log_id)
105
+ .where(in_a_window, *ranges.flatten)
106
+ .order(occurred_at: :desc)
107
+ .limit(NEARBY_OCCURRENCES)
108
+ .pluck(:error_log_id, :occurred_at)
109
+ end
84
110
  end
85
111
  end
86
112
  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
@@ -159,26 +159,18 @@ module RailsErrorDashboard
159
159
  def filter_by_severity(query)
160
160
  return query unless @filters[:severity].present?
161
161
 
162
- # Map severity levels to error types
163
- error_types = case @filters[:severity].to_sym
164
- when :critical
165
- Services::SeverityClassifier::CRITICAL_ERROR_TYPES
166
- when :high
167
- Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
168
- when :medium
169
- 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]))
170
168
  when :low
171
169
  # Low severity = everything NOT in the other categories
172
- all_categorized = Services::SeverityClassifier::CRITICAL_ERROR_TYPES +
173
- Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
174
- Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
175
- # Use NOT IN to filter out categorized errors
176
- return query.where.not(error_type: all_categorized)
170
+ query.where.not(error_type: Services::SeverityClassifier.categorized_error_types)
177
171
  else
178
- return query
172
+ query
179
173
  end
180
-
181
- query.where(error_type: error_types)
182
174
  end
183
175
 
184
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
@@ -160,18 +160,25 @@ module RailsErrorDashboard
160
160
  def platform_stability_scores
161
161
  platforms = base_scope.distinct.pluck(:platform).compact
162
162
  error_rates = error_rate_by_platform
163
+ # A score compares platforms. With fewer than two that have errors in
164
+ # the window there is nothing to compare against, so none is rated (a
165
+ # lone platform used to come out Critical every time). The count is of
166
+ # platforms with errors in the window: the list above is every
167
+ # platform ever seen.
168
+ return {} if error_rates.size < 2
163
169
  resolution_times = resolution_time_by_platform
164
170
 
165
- # Find max values for normalization
166
- max_errors = error_rates.values.max || 1
171
+ total_errors = error_rates.values.sum
167
172
  max_resolution_time = resolution_times.values.compact.max || 1
168
173
 
169
174
  platforms.each_with_object({}) do |platform, result|
170
175
  error_count = error_rates[platform] || 0
171
176
  avg_resolution = resolution_times[platform] || 0
172
177
 
173
- # Normalize to 0-1 scale (inverted - lower is better)
174
- error_score = 1.0 - (error_count.to_f / max_errors)
178
+ # This platform's share of all errors, inverted. Against the busiest
179
+ # platform instead, the busiest always scored 0 here and so was always
180
+ # Critical.
181
+ error_score = total_errors.positive? ? 1.0 - (error_count.to_f / total_errors) : 1.0
175
182
  resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
176
183
 
177
184
  # Weight: 70% error count, 30% resolution time
@@ -252,9 +259,10 @@ module RailsErrorDashboard
252
259
  critical_errors: critical_errors,
253
260
  unresolved_errors: unresolved_errors,
254
261
  resolution_rate: resolution_rate,
255
- stability_score: stability_scores[platform] || 0,
262
+ # nil and :not_rated when there are fewer than two platforms
263
+ stability_score: stability_scores[platform],
256
264
  error_velocity: velocity, # Positive = increasing, negative = decreasing
257
- health_status: determine_health_status(stability_scores[platform] || 0, velocity)
265
+ health_status: stability_scores.key?(platform) ? determine_health_status(stability_scores[platform], velocity) : :not_rated
258
266
  }
259
267
  end
260
268
  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)