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,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Counts the HOST app's missing translations in a thread-local buffer and
6
+ # writes them out at the end of the request or job that produced them.
7
+ #
8
+ # WHY A BUFFER: a missing key in a partial rendered inside a loop misses
9
+ # once per iteration, on every request, for every user. Counting is a hash
10
+ # increment; the database sees one upsert per (locale, key) per thread per
11
+ # flush interval, however many times the key missed. Same shape as
12
+ # RackAttackTracker, for the same reason.
13
+ #
14
+ # Misses are fed in by MissingTranslationHandler, which wraps
15
+ # I18n.exception_handler. RED's own dashboard strings never arrive here:
16
+ # I18nStore resolves them in a private backend that never reaches the
17
+ # global handler.
18
+ #
19
+ # STORAGE: Thread#thread_variable_get/set, not Thread.current[]. The
20
+ # latter is fiber-local: a streaming template (render stream: true) runs
21
+ # in a Fiber, so misses counted there would vanish with the fiber and the
22
+ # executor hook on the root fiber would find nothing to write. Thread
23
+ # variables are shared by every fiber of the thread, which is what "per
24
+ # thread" has to mean for the drain point and the record point to agree.
25
+ #
26
+ # SAFETY RULES (HOST_APP_SAFETY.md):
27
+ # - Zero I/O in the record path: a hash lookup, an increment, and (for a
28
+ # key seen for the first time on this thread) one lazy stack scan
29
+ # - Never raises: every public method rescues
30
+ # - Thread-local state, no mutex
31
+ # - Bounded: at most MAX_BUFFERED_KEYS entries per thread; further new keys
32
+ # are counted in an overflow bucket rather than dropped silently
33
+ # - The buffer drains only off the request path (executor to_complete,
34
+ # after the response body is closed) and, for threads still alive, at
35
+ # process exit
36
+ class MissingTranslationTracker
37
+ COUNTS_THREAD_KEY = :red_missing_translation_counts
38
+
39
+ # Monotonic time at which the buffer became non-empty -- the deadline
40
+ # clock, seeded when the first miss lands so a buffer that receives one
41
+ # miss and then goes quiet is still written out (see RackAttackTracker).
42
+ DEADLINE_THREAD_KEY = :red_missing_translation_deadline_at
43
+
44
+ # Column limits from the migration. Truncation happens here so a value
45
+ # never reaches the unique upsert index longer than the column allows.
46
+ MAX_LOCALE_LENGTH = 35
47
+ MAX_KEY_LENGTH = 191
48
+ MAX_SOURCE_LENGTH = 250
49
+
50
+ # Distinct (locale, key) pairs one thread will hold before new keys go
51
+ # to the overflow bucket. A real app has a few dozen missing keys, not
52
+ # hundreds; the cap exists for the pathological case (a key built from
53
+ # user input) so memory stays bounded.
54
+ MAX_BUFFERED_KEYS = 500
55
+
56
+ # Max age of buffered misses before the end-of-request drain writes
57
+ # them. The upper bound on how stale the dashboard page can be, not a
58
+ # per-request cost: a flood still collapses to one write per thread per
59
+ # interval.
60
+ FLUSH_INTERVAL = 5
61
+
62
+ OVERFLOW_KEY = "__overflow__"
63
+ OVERFLOW_LOCALE = "*"
64
+
65
+ # Composite buffer key separator (ASCII unit separator): cannot appear in
66
+ # a locale and will not appear in a sane translation key.
67
+ KEY_SEPARATOR = "\x1F"
68
+
69
+ # This gem's own code directories, wherever the gem lives and whatever
70
+ # its directory is called (a `path:` Gemfile entry, vendor/bundle, a
71
+ # fork). Frames under them are never the host's call site. lib/ and
72
+ # app/ rather than the whole install root: in this gem's own checkout
73
+ # the spec suite sits beside them and is the "host" being tested.
74
+ GEM_ROOTS = [
75
+ "#{File.expand_path('../..', __dir__)}/", # <gem>/lib/
76
+ "#{File.expand_path('../../../app', __dir__)}/" # <gem>/app/
77
+ ].freeze
78
+
79
+ # Frames from other gems, Ruby itself and its internals are not the
80
+ # host's call site either. Only consulted for frames outside Rails.root.
81
+ FOREIGN_FRAME = %r{/gems/|/ruby/\d|<internal:}
82
+
83
+ # How deep host_call_site looks before giving up. The host frame of a
84
+ # view miss sits a handful of frames below the I18n/ActionView ones.
85
+ MAX_FRAMES_SCANNED = 40
86
+
87
+ class << self
88
+ # Record one miss. Called by MissingTranslationHandler on every
89
+ # I18n::MissingTranslation that reaches the global handler.
90
+ #
91
+ # @param locale [String, Symbol]
92
+ # @param key [String] the full dotted key as I18n normalised it
93
+ # @param source [String, nil] call site; resolved from the stack when nil
94
+ # and the key is new to this thread's buffer
95
+ def record(locale:, key:, source: nil)
96
+ return unless enabled?
97
+
98
+ buffer_key = build_key(truncate(locale, MAX_LOCALE_LENGTH), truncate(key, MAX_KEY_LENGTH))
99
+ counts = counts_for(Thread.current)
100
+
101
+ # Seed the deadline the moment the buffer goes from empty to
102
+ # non-empty, so "never older than FLUSH_INTERVAL" holds for a buffer
103
+ # that receives exactly one miss.
104
+ Thread.current.thread_variable_set(DEADLINE_THREAD_KEY, monotonic_now) if counts.empty?
105
+
106
+ entry = counts[buffer_key]
107
+ if entry
108
+ entry[0] += 1
109
+ elsif counts.size >= MAX_BUFFERED_KEYS
110
+ # Bounded memory: the count is kept, the key is not.
111
+ overflow = (counts[overflow_key] ||= [ 0, nil ])
112
+ overflow[0] += 1
113
+ else
114
+ # The stack is read once per new key per thread, not per miss.
115
+ counts[buffer_key] = [ 1, truncate(source || host_call_site, MAX_SOURCE_LENGTH) ]
116
+ end
117
+
118
+ nil
119
+ rescue => e
120
+ RailsErrorDashboard::Logger.debug(
121
+ "[RailsErrorDashboard] MissingTranslationTracker.record failed: #{e.class} - #{e.message}"
122
+ )
123
+ nil
124
+ end
125
+
126
+ # Write this thread's buffer out, synchronously. Clears the buffer
127
+ # first so a failed write cannot double-count on the next flush.
128
+ def flush!
129
+ drain(Thread.current)
130
+ nil
131
+ rescue => e
132
+ RailsErrorDashboard::Logger.debug(
133
+ "[RailsErrorDashboard] MissingTranslationTracker.flush! failed: #{e.class} - #{e.message}"
134
+ )
135
+ nil
136
+ end
137
+
138
+ # Drain this thread's buffer at the end of a request or job, if it has
139
+ # been waiting at least FLUSH_INTERVAL. Registered on
140
+ # Rails.application.executor.to_complete, which fires after the
141
+ # response body is closed -- the client already has its bytes, so this
142
+ # never delays a request. Interval-gated so a flood is one write per
143
+ # thread per interval rather than one per request.
144
+ def flush_if_due!
145
+ return unless enabled?
146
+ return unless flush_due?
147
+
148
+ flush!
149
+ rescue => e
150
+ RailsErrorDashboard::Logger.debug(
151
+ "[RailsErrorDashboard] MissingTranslationTracker.flush_if_due! failed: #{e.class} - #{e.message}"
152
+ )
153
+ nil
154
+ end
155
+
156
+ # Flush every live thread's buffer, not just the caller's. Called from
157
+ # at_exit, where the exiting thread's own buffer is usually empty.
158
+ # Threads that are already gone cannot be drained: Puma joins its
159
+ # worker threads before at_exit runs, so a worker's last
160
+ # FLUSH_INTERVAL seconds of misses go with it (measured, same as the
161
+ # Rack Attack tracker). Rescued per thread so one failing write does
162
+ # not strand the rest.
163
+ def flush_all_threads!
164
+ Thread.list.each do |thread|
165
+ begin
166
+ drain(thread)
167
+ rescue => e
168
+ RailsErrorDashboard::Logger.debug(
169
+ "[RailsErrorDashboard] MissingTranslationTracker.flush_all_threads! skipped a thread: #{e.class} - #{e.message}"
170
+ )
171
+ end
172
+ end
173
+ nil
174
+ rescue => e
175
+ RailsErrorDashboard::Logger.debug(
176
+ "[RailsErrorDashboard] MissingTranslationTracker.flush_all_threads! failed: #{e.class} - #{e.message}"
177
+ )
178
+ nil
179
+ end
180
+
181
+ # Clear thread-local state without persisting (specs, thread teardown).
182
+ def reset!
183
+ Thread.current.thread_variable_set(COUNTS_THREAD_KEY, nil)
184
+ Thread.current.thread_variable_set(DEADLINE_THREAD_KEY, nil)
185
+ nil
186
+ rescue => e
187
+ nil
188
+ end
189
+
190
+ # Non-destructive copy of this thread's buffer: { buffer_key => [count, source] }.
191
+ def buffered_counts
192
+ (Thread.current.thread_variable_get(COUNTS_THREAD_KEY) || {}).transform_values(&:dup)
193
+ rescue => e
194
+ {}
195
+ end
196
+
197
+ # The deadline clock of this thread's buffer, for specs that need to
198
+ # move it rather than wait out the interval.
199
+ def deadline
200
+ Thread.current.thread_variable_get(DEADLINE_THREAD_KEY)
201
+ end
202
+
203
+ def deadline=(value)
204
+ Thread.current.thread_variable_set(DEADLINE_THREAD_KEY, value)
205
+ end
206
+
207
+ # @return [Array<String>] [locale, translation_key]
208
+ def parse_key(buffer_key)
209
+ parts = buffer_key.to_s.split(KEY_SEPARATOR, 2)
210
+ parts.fill("", parts.length, 2 - parts.length)
211
+ end
212
+
213
+ def overflow_key
214
+ @overflow_key ||= build_key(OVERFLOW_LOCALE, OVERFLOW_KEY)
215
+ end
216
+
217
+ # True when the buffer has been waiting at least FLUSH_INTERVAL.
218
+ # Monotonic clock: a wall-clock step backwards would defer the flush
219
+ # indefinitely.
220
+ def flush_due?
221
+ started = deadline
222
+ return false if started.nil?
223
+
224
+ (monotonic_now - started) >= FLUSH_INTERVAL
225
+ rescue => e
226
+ false
227
+ end
228
+
229
+ private
230
+
231
+ def enabled?
232
+ RailsErrorDashboard.configuration.enable_missing_translation_tracking
233
+ rescue => e
234
+ false
235
+ end
236
+
237
+ def counts_for(thread)
238
+ thread.thread_variable_get(COUNTS_THREAD_KEY) ||
239
+ thread.thread_variable_set(COUNTS_THREAD_KEY, {})
240
+ end
241
+
242
+ # Snapshot and clear one thread's buffer, then write the snapshot.
243
+ def drain(thread)
244
+ counts = thread.thread_variable_get(COUNTS_THREAD_KEY)
245
+ return if counts.nil? || counts.empty?
246
+
247
+ snapshot = counts.dup
248
+ counts.clear
249
+ thread.thread_variable_set(DEADLINE_THREAD_KEY, nil)
250
+
251
+ Commands::FlushMissingTranslations.call(counts: snapshot)
252
+ end
253
+
254
+ def build_key(*parts)
255
+ parts.map(&:to_s).join(KEY_SEPARATOR)
256
+ end
257
+
258
+ def monotonic_now
259
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
260
+ end
261
+
262
+ # The host's call site: the first frame under Rails.root, relative to
263
+ # it ("app/views/users/show.html.erb:12" -- compiled templates keep
264
+ # the template path and line). Failing that, the first frame that is
265
+ # neither this gem's nor another gem's nor Ruby's own. Frames are
266
+ # judged by where they are, never by what the directory is called.
267
+ #
268
+ # Thread.each_caller_location (Ruby 3.2+, the gemspec floor) yields
269
+ # frames one at a time from this method's caller outwards and stops
270
+ # the moment the block breaks or the method returns.
271
+ # caller_locations(1, 40) materialised a 40-element array of Location
272
+ # objects for every new key; the host frame usually sits within the
273
+ # first ten, so most of that array was allocated to be thrown away.
274
+ def host_call_site
275
+ root = rails_root
276
+ fallback = nil
277
+ seen = 0
278
+
279
+ Thread.each_caller_location do |frame|
280
+ # Bounded: a pathological stack must not turn into a long scan.
281
+ break if (seen += 1) > MAX_FRAMES_SCANNED
282
+
283
+ path = frame.path.to_s
284
+ next if path.empty? || GEM_ROOTS.any? { |gem_dir| path.start_with?(gem_dir) }
285
+
286
+ if root && path.start_with?(root)
287
+ return "#{path.delete_prefix(root)}:#{frame.lineno}"
288
+ end
289
+
290
+ fallback ||= "#{path}:#{frame.lineno}" unless path.match?(FOREIGN_FRAME)
291
+ end
292
+
293
+ fallback
294
+ rescue => e
295
+ nil
296
+ end
297
+
298
+ def rails_root
299
+ return nil unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
300
+
301
+ "#{Rails.root}/"
302
+ rescue => e
303
+ nil
304
+ end
305
+
306
+ # Truncate to the column limit and strip the separator, so a key that
307
+ # somehow contained it cannot shift the locale into the key column.
308
+ def truncate(value, max)
309
+ s = value.to_s
310
+ s = s.delete(KEY_SEPARATOR) if s.include?(KEY_SEPARATOR)
311
+ s.length > max ? s[0, max] : s
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # What happens after an error has been marked resolved, whichever command
6
+ # did it: the host's `notification_callbacks[:error_resolved]` lambdas
7
+ # (the issue-tracker integration closes the linked issue from one of
8
+ # these) and the `error_resolved.rails_error_dashboard` instrumentation
9
+ # event.
10
+ #
11
+ # WHY ONE PLACE: only ResolveError ran these. An error resolved from the
12
+ # batch toolbar, or through the status dropdown (UpdateErrorStatus),
13
+ # stayed "resolved" on the dashboard with its GitHub or Linear issue still
14
+ # open, and nothing subscribed to the event heard about it.
15
+ #
16
+ # Plugin hooks are deliberately not here: `:on_error_resolved` and
17
+ # `:on_errors_batch_resolved` are separate events in the plugin API, and
18
+ # each command dispatches its own.
19
+ #
20
+ # Each callback is rescued on its own, so one failing host lambda does
21
+ # not stop the next, and never fails the resolution that already happened.
22
+ class ResolutionCallbacks
23
+ def self.call(error_log, resolved_by: nil)
24
+ RailsErrorDashboard.configuration.notification_callbacks[:error_resolved].each do |callback|
25
+ callback.call(error_log)
26
+ rescue => e
27
+ RailsErrorDashboard::Logger.error("Error in error_resolved callback: #{e.message}")
28
+ end
29
+
30
+ ActiveSupport::Notifications.instrument("error_resolved.rails_error_dashboard", {
31
+ error_log: error_log,
32
+ error_id: error_log.id,
33
+ error_type: error_log.error_type,
34
+ resolved_by: resolved_by,
35
+ resolved_at: error_log.resolved_at
36
+ })
37
+ nil
38
+ rescue => e
39
+ RailsErrorDashboard::Logger.error("ResolutionCallbacks failed for error #{error_log&.id}: #{e.message}")
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -19,7 +19,12 @@ module RailsErrorDashboard
19
19
  ActiveRecord::ConnectionNotEstablished
20
20
  Redis::ConnectionError
21
21
  OpenSSL::SSL::SSLError
22
+ Zeitwerk::NameError
23
+ RailsErrorDashboard::TestError
22
24
  ].freeze
25
+ # RailsErrorDashboard::TestError is the Settings page's Send Test Error.
26
+ # The button promises delivery to every channel, and PagerDuty only
27
+ # takes critical errors, so the test error is one.
23
28
 
24
29
  HIGH_SEVERITY_ERROR_TYPES = %w[
25
30
  ActiveRecord::RecordNotFound
@@ -67,6 +72,53 @@ module RailsErrorDashboard
67
72
  def self.critical?(error_type)
68
73
  classify(error_type) == :critical
69
74
  end
75
+
76
+ BUILT_IN_TYPES = {
77
+ critical: CRITICAL_ERROR_TYPES,
78
+ high: HIGH_SEVERITY_ERROR_TYPES,
79
+ medium: MEDIUM_SEVERITY_ERROR_TYPES
80
+ }.freeze
81
+
82
+ # The error types that classify as +severity+, for a SQL IN filter: the
83
+ # built-in list for that level, minus any type a custom rule moves
84
+ # elsewhere, plus any type a custom rule moves here. Filtering on the
85
+ # bare constants showed CustomPaymentError => :critical under "low" and
86
+ # a demoted built-in type still under "critical", while every badge on
87
+ # the page said otherwise.
88
+ #
89
+ # :low has no list of its own (it is "everything else"); callers use
90
+ # `where.not(error_type: categorized_error_types)` for it.
91
+ #
92
+ # @param severity [Symbol, String] :critical, :high or :medium
93
+ # @return [Array<String>]
94
+ def self.error_types_for(severity)
95
+ level = severity.to_sym
96
+ rules = custom_rules
97
+ built_in = BUILT_IN_TYPES.fetch(level, [])
98
+
99
+ (built_in - rules.keys) + rules.select { |_type, sev| sev == level }.keys
100
+ end
101
+
102
+ # Every error type that classifies as something other than :low, so
103
+ # "low" is `where.not(error_type: categorized_error_types)`.
104
+ # @return [Array<String>]
105
+ def self.categorized_error_types
106
+ rules = custom_rules
107
+ built_in = BUILT_IN_TYPES.values.flatten
108
+ promoted = rules.reject { |_type, sev| sev == :low }.keys
109
+ demoted = rules.select { |_type, sev| sev == :low }.keys
110
+
111
+ (built_in - demoted) | promoted
112
+ end
113
+
114
+ # custom_severity_rules with String keys and Symbol values, whatever the
115
+ # initializer used.
116
+ def self.custom_rules
117
+ (RailsErrorDashboard.configuration.custom_severity_rules || {})
118
+ .to_h { |type, sev| [ type.to_s, sev.to_s.to_sym ] }
119
+ rescue StandardError
120
+ {}
121
+ end
70
122
  end
71
123
  end
72
124
  end
@@ -13,6 +13,14 @@ module RailsErrorDashboard
13
13
  MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
14
14
  MAX_CONTEXT_LINES = 50
15
15
 
16
+ # The files a Ruby backtrace can point to: Ruby source and view templates.
17
+ # A frame's path is whatever the stored backtrace says, and the files
18
+ # beside the code (config/*.yml, logs, dotfiles) are where secrets live.
19
+ RUBY_EXTENSIONS = %w[.rb .rake .ru].freeze
20
+ # Templates only under an app/ directory (app/views, app/components, an
21
+ # engine's app/): elsewhere .erb is configuration, as in config/database.yml.erb.
22
+ TEMPLATE_EXTENSIONS = %w[.erb .haml .slim .builder .jbuilder].freeze
23
+
16
24
  attr_reader :file_path, :line_number, :error
17
25
 
18
26
  # Initialize a new source code reader
@@ -112,14 +120,26 @@ module RailsErrorDashboard
112
120
  def validate_path!(path)
113
121
  # Normalize path
114
122
  normalized = File.expand_path(path)
115
- rails_root = File.expand_path(Rails.root)
123
+ root_prefix = File.join(File.expand_path(Rails.root), "")
116
124
 
117
- # Must be within Rails.root
118
- unless normalized.start_with?(rails_root)
125
+ # Must be within Rails.root. The trailing separator matters: without it
126
+ # a root of /app also admitted /app-old.
127
+ unless normalized.start_with?(root_prefix)
119
128
  RailsErrorDashboard::Logger.warn("Path outside Rails.root: #{normalized}")
120
129
  return false
121
130
  end
122
131
 
132
+ # Source files only, and nothing under a dot directory (.git, .bundle)
133
+ segments = normalized.delete_prefix(root_prefix).split("/")
134
+ extension = File.extname(normalized).downcase
135
+ source = RUBY_EXTENSIONS.include?(extension) ||
136
+ (TEMPLATE_EXTENSIONS.include?(extension) && segments[0...-1].include?("app"))
137
+ hidden = segments.any? { |segment| segment.start_with?(".") }
138
+ if !source || hidden
139
+ RailsErrorDashboard::Logger.warn("Not a source file: #{normalized}")
140
+ return false
141
+ end
142
+
123
143
  # Prevent directory traversal
124
144
  if path.include?("..") || normalized.include?("..")
125
145
  RailsErrorDashboard::Logger.warn("Directory traversal attempt: #{path}")
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RailsErrorDashboard
8
+ module Services
9
+ # One sendMessage call to the Telegram Bot API.
10
+ #
11
+ # WHY ONE PLACE: four senders post to Telegram (per-error notifications,
12
+ # storm and burst-summary messages, baseline alerts). The bot token is part
13
+ # of the URL -- https://api.telegram.org/bot<token>/sendMessage -- so any
14
+ # log line that quotes the URL, or an exception message that carries it,
15
+ # would leak the credential. Building the URL and redacting it live here
16
+ # so no caller has to remember either.
17
+ #
18
+ # Transport errors propagate, as in WebhookDelivery: every caller already
19
+ # rescues around its send and logs in its own words. Callers pass the
20
+ # message through .redact before logging it. A non-2xx answer from
21
+ # Telegram is not an exception; it is logged here with Telegram's own
22
+ # description ("Bad Request: chat not found"), which is the line an
23
+ # operator needs when the chat id is wrong.
24
+ class TelegramDelivery
25
+ OPEN_TIMEOUT = 5
26
+ READ_TIMEOUT = 10
27
+ USER_AGENT = "RailsErrorDashboard/1.0"
28
+ REDACTED = "[FILTERED]"
29
+
30
+ class << self
31
+ # Enabled and credentialled: the condition under which a dispatcher
32
+ # fires this channel.
33
+ def configured?(config = RailsErrorDashboard.configuration)
34
+ config.enable_telegram_notifications && credentials?(config)
35
+ end
36
+
37
+ # Token and chat id both present. The jobs check this rather than
38
+ # configured?, like the Discord job checks its URL and not the flag:
39
+ # the dispatcher decided to enqueue; the job only needs the means.
40
+ def credentials?(config = RailsErrorDashboard.configuration)
41
+ config.telegram_bot_token.present? && config.telegram_chat_id.present?
42
+ end
43
+
44
+ # POST a prepared sendMessage payload ({ text:, parse_mode:, ... }).
45
+ # chat_id is configuration, not content, so it is merged here rather
46
+ # than built into every payload.
47
+ #
48
+ # @return [HTTParty::Response, Net::HTTPResponse, nil] nil when there
49
+ # are no credentials to send with
50
+ def post(payload, config: RailsErrorDashboard.configuration)
51
+ return nil unless credentials?(config)
52
+
53
+ body = payload.merge(chat_id: config.telegram_chat_id).to_json
54
+ headers = { "Content-Type" => "application/json", "User-Agent" => USER_AGENT }
55
+ url = send_message_url(config)
56
+
57
+ response =
58
+ if defined?(HTTParty)
59
+ HTTParty.post(url, body: body, headers: headers, timeout: READ_TIMEOUT)
60
+ else
61
+ uri = URI(url)
62
+ http = Net::HTTP.new(uri.host, uri.port)
63
+ http.use_ssl = uri.scheme == "https"
64
+ http.open_timeout = OPEN_TIMEOUT
65
+ http.read_timeout = READ_TIMEOUT
66
+ request = Net::HTTP::Post.new(uri.request_uri, headers)
67
+ request.body = body
68
+ http.request(request)
69
+ end
70
+
71
+ log_rejection(response)
72
+ response
73
+ end
74
+
75
+ # A plain or HTML-formatted message with nothing else to it (storm and
76
+ # burst summaries). parse_mode: nil sends the text verbatim, which is
77
+ # right for a sentence that may contain a "<" from an app name.
78
+ def send_message(text, parse_mode: nil, config: RailsErrorDashboard.configuration)
79
+ payload = { text: text, disable_web_page_preview: true }
80
+ payload[:parse_mode] = parse_mode if parse_mode
81
+
82
+ post(payload, config: config)
83
+ end
84
+
85
+ # The bot token replaced wherever it appears in +string+ (an exception
86
+ # message that quotes the URL, a response body that echoes it).
87
+ def redact(string, config: RailsErrorDashboard.configuration)
88
+ token = config.telegram_bot_token.to_s
89
+ text = string.to_s
90
+ return text if token.empty?
91
+
92
+ text.gsub(token, REDACTED)
93
+ end
94
+
95
+ # The URL with the token replaced, for log lines that need to say
96
+ # where the POST went.
97
+ def redacted_url(config = RailsErrorDashboard.configuration)
98
+ redact(send_message_url(config), config: config)
99
+ end
100
+
101
+ private
102
+
103
+ def send_message_url(config)
104
+ base = config.telegram_api_base_url.to_s.strip
105
+ base = "https://api.telegram.org" if base.empty?
106
+
107
+ "#{base.chomp('/')}/bot#{config.telegram_bot_token}/sendMessage"
108
+ end
109
+
110
+ # Telegram answers 4xx with {"ok":false,"error_code":400,"description":"..."}.
111
+ # The description names the real problem (chat not found, bot was
112
+ # blocked, can't parse entities), so it goes in the log verbatim; the
113
+ # body never contains the token, but it is redacted anyway.
114
+ def log_rejection(response)
115
+ code = response.code.to_i
116
+ return if (200..299).cover?(code)
117
+
118
+ description = begin
119
+ JSON.parse(response.body.to_s)["description"]
120
+ rescue JSON::ParserError, TypeError
121
+ nil
122
+ end
123
+
124
+ Rails.logger.error(
125
+ "[RailsErrorDashboard] Telegram API rejected the message (HTTP #{code}): " \
126
+ "#{redact(description || response.body.to_s[0, 200])}"
127
+ )
128
+ rescue => e
129
+ Rails.logger.error("[RailsErrorDashboard] Telegram API response could not be read: #{e.class}")
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end