rails_error_dashboard 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -5
  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 +2 -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 +11 -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 +68 -5
  31. data/config/locales/en.yml +78 -4
  32. data/config/locales/es.yml +68 -5
  33. data/config/locales/fr.yml +68 -5
  34. data/config/locales/it.yml +68 -5
  35. data/config/locales/ja.yml +68 -4
  36. data/config/locales/pl.yml +68 -5
  37. data/config/locales/pt-BR.yml +68 -5
  38. data/config/locales/ru.yml +68 -5
  39. data/config/locales/uk.yml +68 -5
  40. data/config/locales/zh-CN.yml +68 -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/20261010000001_create_missing_translations.rb +50 -0
  44. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  45. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
  46. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  47. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  48. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  49. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  50. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  51. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  52. data/lib/rails_error_dashboard/configuration.rb +42 -1
  53. data/lib/rails_error_dashboard/engine.rb +42 -1
  54. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  55. data/lib/rails_error_dashboard/plugin.rb +19 -3
  56. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  57. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  58. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  59. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  60. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  61. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  62. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  63. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  64. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  65. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  66. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  67. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  68. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  69. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  70. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  71. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  72. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  73. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  74. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  75. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  76. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  77. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  78. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  79. data/lib/rails_error_dashboard/version.rb +1 -1
  80. data/lib/rails_error_dashboard.rb +12 -0
  81. metadata +21 -2
@@ -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
@@ -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
@@ -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)
@@ -57,6 +57,12 @@ module RailsErrorDashboard
57
57
  # Strip everything before /lib/ruby/ (Ruby stdlib)
58
58
  elsif line.include?("/lib/ruby/")
59
59
  line.sub(%r{^.*/lib/ruby/}, "ruby/")
60
+ # A frame under Rails.root is that path relative to it, whatever the
61
+ # root is called. The /app/ heuristic below cannot tell a root named
62
+ # /app from the app/ directory: /app/lib/foo.rb became app/lib/foo.rb,
63
+ # which the source reader then looked for at /app/app/lib/foo.rb.
64
+ elsif (root = rails_root_prefix) && line.start_with?(root)
65
+ line.delete_prefix(root)
60
66
  # Strip everything before /app/ (application code)
61
67
  elsif line.include?("/app/")
62
68
  line.sub(%r{^.*/app/}, "app/")
@@ -69,6 +75,15 @@ module RailsErrorDashboard
69
75
  end
70
76
  # Keep public — used by LogError for cause chain backtrace shortening too.
71
77
 
78
+ # "#{Rails.root}/", or nil outside Rails.
79
+ def self.rails_root_prefix
80
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
81
+
82
+ "#{Rails.root}/"
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
72
87
  # Calculate a signature hash from backtrace for fuzzy similarity matching
73
88
  # Extracts file paths and method names, ignoring line numbers,
74
89
  # then produces an order-independent SHA256 digest.
@@ -9,6 +9,7 @@ module RailsErrorDashboard
9
9
  # @example
10
10
  # BaselineAlertPayloadBuilder.slack_payload(error_log, anomaly_data)
11
11
  # BaselineAlertPayloadBuilder.discord_payload(error_log, anomaly_data)
12
+ # BaselineAlertPayloadBuilder.telegram_payload(error_log, anomaly_data)
12
13
  # BaselineAlertPayloadBuilder.webhook_payload(error_log, anomaly_data)
13
14
  class BaselineAlertPayloadBuilder
14
15
  ANOMALY_EMOJIS = {
@@ -140,6 +141,30 @@ module RailsErrorDashboard
140
141
  }
141
142
  end
142
143
 
144
+ # Build the Telegram sendMessage payload for a baseline anomaly. HTML
145
+ # parse mode with every value escaped; see TelegramPayloadBuilder for
146
+ # why HTML and not MarkdownV2.
147
+ # @return [Hash] sendMessage payload without chat_id
148
+ def self.telegram_payload(error_log, anomaly_data, locale: I18nStore::DEFAULT_LOCALE)
149
+ tg = TelegramPayloadBuilder
150
+ lines = [
151
+ tg.bold(NotificationHelpers.t("red.notifications.baseline_alert.heading", locale)),
152
+ tg.field(:application, NotificationHelpers.app_name(error_log), locale),
153
+ tg.field(:error_type, error_log.error_type, locale),
154
+ tg.field(:platform, error_log.platform || NotificationHelpers.unknown(locale), locale),
155
+ telegram_field(:severity, anomaly_level(anomaly_data[:level], locale), locale),
156
+ telegram_field(:std_devs, std_devs_text(anomaly_data, locale), locale),
157
+ telegram_field(:threshold, threshold_text(anomaly_data, locale), locale),
158
+ telegram_field(:baseline_type, anomaly_data[:baseline_type] || NotificationHelpers.not_available(locale), locale),
159
+ tg.field(:message, NotificationHelpers.truncate_message(error_log.message, 200), locale, code: true),
160
+ "",
161
+ tg.link(NotificationHelpers.dashboard_url(error_log),
162
+ NotificationHelpers.t("red.notifications.error_alert.view_in_dashboard", locale))
163
+ ]
164
+
165
+ tg.html_payload(lines.join("\n"))
166
+ end
167
+
143
168
  # Build generic webhook payload for baseline anomaly
144
169
  # @param error_log [ErrorLog] The error
145
170
  # @param anomaly_data [Hash] Anomaly information
@@ -179,6 +204,11 @@ module RailsErrorDashboard
179
204
  NotificationHelpers.t("red.notifications.baseline_alert.labels.#{label}", locale)
180
205
  end
181
206
 
207
+ # Telegram's "<b>Label:</b> value" shape, for a baseline-only label.
208
+ def self.telegram_field(label, value, locale)
209
+ "#{TelegramPayloadBuilder.bold("#{baseline_label(label, locale)}:")} #{TelegramPayloadBuilder.escape(value)}"
210
+ end
211
+
182
212
  # Slack's bold-label-newline-value shape, for a baseline-only label.
183
213
  def self.baseline_field(label, value, locale)
184
214
  "*#{baseline_label(label, locale)}:*\n#{value}"