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.
- checksums.yaml +4 -4
- data/README.md +12 -5
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +43 -12
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +11 -1
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
- data/config/locales/de.yml +68 -5
- data/config/locales/en.yml +78 -4
- data/config/locales/es.yml +68 -5
- data/config/locales/fr.yml +68 -5
- data/config/locales/it.yml +68 -5
- data/config/locales/ja.yml +68 -4
- data/config/locales/pl.yml +68 -5
- data/config/locales/pt-BR.yml +68 -5
- data/config/locales/ru.yml +68 -5
- data/config/locales/uk.yml +68 -5
- data/config/locales/zh-CN.yml +68 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +42 -1
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +12 -0
- 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
|
-
|
|
123
|
+
root_prefix = File.join(File.expand_path(Rails.root), "")
|
|
116
124
|
|
|
117
|
-
# Must be within Rails.root
|
|
118
|
-
|
|
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
|