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
@@ -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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # The one normal form of a configured repository URL, shared by the View
6
+ # Source links and the issue tracker: no surrounding whitespace, no trailing
7
+ # slashes, no ".git". The slashes go first, or ".git/" keeps its ".git".
8
+ module RepositoryUrl
9
+ # @param url [String, nil]
10
+ # @return [String, nil]
11
+ def self.normalize(url)
12
+ return nil if url.nil?
13
+
14
+ url.to_s.strip.sub(%r{/+\z}, "").delete_suffix(".git").sub(%r{/+\z}, "")
15
+ end
16
+ end
17
+ end
18
+ 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
@@ -80,19 +80,13 @@ module RailsErrorDashboard
80
80
  end
81
81
 
82
82
  # Extract meaningful frames from backtrace
83
- # Format: "file.rb:123:in `method_name`" => "file.rb:method_name"
83
+ # "file.rb:123:in `name'" (Ruby 3.3) or "file.rb:123:in 'Class#name'"
84
+ # (Ruby 3.4) => "file.rb:name", via BacktraceProcessor.frame_key
84
85
  def extract_frames(backtrace)
85
86
  return [] if backtrace.blank?
86
87
 
87
88
  lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
88
- lines.first(20).map do |line| # Only consider first 20 frames
89
- # Extract file path and method name, ignore line numbers
90
- if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
91
- "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
92
- elsif line =~ %r{([^/]+\.rb)}
93
- Regexp.last_match(1)
94
- end
95
- end.compact.uniq
89
+ lines.first(20).map { |line| BacktraceProcessor.frame_key(line) }.compact.uniq # Only consider first 20 frames
96
90
  end
97
91
 
98
92
  # Normalize message for comparison
@@ -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}")
@@ -38,7 +38,8 @@ module RailsErrorDashboard
38
38
  Entry = Struct.new(
39
39
  :error_class, :message, :first_app_frame,
40
40
  :controller_name, :action_name, :custom_hash, :environment,
41
- :opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets
41
+ :opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets,
42
+ :capture_source
42
43
  )
43
44
 
44
45
  # The bucket an instant belongs to, as an epoch second.
@@ -120,6 +121,7 @@ module RailsErrorDashboard
120
121
  # job CANNOT recompute this -- it has to travel, or a storm count
121
122
  # lands on a different row than the full capture path would.
122
123
  "opaque_identity" => entry.opaque_identity,
124
+ "capture_source" => entry.capture_source,
123
125
  "count" => entry.count.value,
124
126
  "first_seen_at" => entry.first_seen_at.iso8601,
125
127
  "last_seen_at" => entry.last_seen_at.iso8601,
@@ -167,7 +169,7 @@ module RailsErrorDashboard
167
169
  parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
168
170
  parts[:opaque_identity],
169
171
  Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current,
170
- Concurrent::Map.new
172
+ Concurrent::Map.new, parts[:capture_source]
171
173
  )
172
174
  end
173
175
  end
@@ -216,7 +218,8 @@ module RailsErrorDashboard
216
218
  error_class: entry["error_class"], message: entry["message"],
217
219
  first_app_frame: entry["first_app_frame"], controller_name: entry["controller_name"],
218
220
  action_name: entry["action_name"], custom_hash: entry["custom_hash"],
219
- environment: entry["environment"], opaque_identity: entry["opaque_identity"]
221
+ environment: entry["environment"], opaque_identity: entry["opaque_identity"],
222
+ capture_source: entry["capture_source"]
220
223
  }
221
224
  end
222
225
 
@@ -279,7 +279,12 @@ module RailsErrorDashboard
279
279
  # Only an EXPLICIT environment from the caller. nil means "the
280
280
  # worker's own environment", resolved at flush time exactly as
281
281
  # LogError resolves it for a full capture.
282
- environment: context[:environment].to_s.strip.presence&.[](0, 64)
282
+ environment: context[:environment].to_s.strip.presence&.[](0, 64),
283
+ # Whose backtrace first_app_frame came from, for the row the flush
284
+ # may build from it. Not part of the identity or the gate key: a
285
+ # client report and a server capture of one error share an entry,
286
+ # which keeps the first event's frame, and so its source.
287
+ capture_source: context[:_capture_source] == "client" ? "client" : "server"
283
288
  )
284
289
  end
285
290