rails_error_dashboard 0.12.1 → 0.13.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/app/controllers/rails_error_dashboard/application_controller.rb +92 -11
- data/app/controllers/rails_error_dashboard/errors_controller.rb +111 -34
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +3 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +46 -1
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +8 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +71 -0
- data/app/jobs/rails_error_dashboard/notification_burst_summary_job.rb +55 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +68 -1
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +8 -44
- data/app/models/rails_error_dashboard/error_baseline.rb +12 -9
- data/app/models/rails_error_dashboard/error_comment.rb +0 -5
- data/app/models/rails_error_dashboard/error_log.rb +9 -3
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/app/models/rails_error_dashboard/error_occurrence.rb +9 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +11 -3
- data/app/views/rails_error_dashboard/errors/_discussion.html.erb +18 -11
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +22 -8
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +15 -6
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +8 -8
- data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +6 -1
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +7 -7
- data/app/views/rails_error_dashboard/errors/releases.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/config/locales/de.yml +28 -0
- data/config/locales/en.yml +35 -0
- data/config/locales/es.yml +28 -0
- data/config/locales/fr.yml +28 -0
- data/config/locales/it.yml +28 -0
- data/config/locales/ja.yml +28 -0
- data/config/locales/pl.yml +28 -0
- data/config/locales/pt-BR.yml +28 -0
- data/config/locales/ru.yml +28 -0
- data/config/locales/uk.yml +28 -0
- data/config/locales/zh-CN.yml +28 -0
- data/db/migrate/20260917000001_add_last_notified_at_to_error_logs.rb +40 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -1
- data/lib/rails_error_dashboard/commands/assign_error.rb +12 -2
- data/lib/rails_error_dashboard/commands/backfill_environments.rb +2 -0
- data/lib/rails_error_dashboard/commands/backfill_resolved_at.rb +42 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -0
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +2 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +39 -7
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +3 -1
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +27 -4
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +5 -2
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +1 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +82 -23
- data/lib/rails_error_dashboard/commands/mute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -0
- data/lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb +104 -0
- data/lib/rails_error_dashboard/commands/snooze_error.rb +34 -10
- data/lib/rails_error_dashboard/commands/unmute_error.rb +1 -0
- data/lib/rails_error_dashboard/commands/update_error_priority.rb +23 -2
- data/lib/rails_error_dashboard/commands/update_error_status.rb +33 -6
- data/lib/rails_error_dashboard/configuration.rb +19 -1
- data/lib/rails_error_dashboard/engine.rb +15 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -3
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +55 -50
- data/lib/rails_error_dashboard/queries/error_correlation.rb +6 -3
- data/lib/rails_error_dashboard/queries/errors_list.rb +15 -2
- data/lib/rails_error_dashboard/queries/similar_errors.rb +1 -1
- data/lib/rails_error_dashboard/services/analytics_cache_manager.rb +43 -18
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +3 -1
- data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +3 -1
- data/lib/rails_error_dashboard/services/codeberg_issue_client.rb +13 -2
- data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +5 -3
- data/lib/rails_error_dashboard/services/encoding_sanitizer.rb +80 -0
- data/lib/rails_error_dashboard/services/error_broadcaster.rb +180 -32
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -3
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +13 -0
- data/lib/rails_error_dashboard/services/exception_filter.rb +55 -0
- data/lib/rails_error_dashboard/services/git_head_reader.rb +102 -0
- data/lib/rails_error_dashboard/services/notification_throttler.rb +172 -28
- data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +47 -1
- data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +59 -3
- data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +25 -2
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +32 -5
- data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +99 -23
- data/lib/rails_error_dashboard/services/url_safety.rb +40 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +11 -2
- data/lib/rails_error_dashboard/value_objects/error_context.rb +3 -1
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +31 -0
- data/lib/tasks/error_dashboard.rake +54 -4
- metadata +10 -2
|
@@ -15,26 +15,71 @@ module RailsErrorDashboard
|
|
|
15
15
|
# are NOT available there. We render via the engine's own controller renderer
|
|
16
16
|
# and pass pre-rendered HTML to the broadcast to ensure route helpers work.
|
|
17
17
|
class ErrorBroadcaster
|
|
18
|
+
# Minimum seconds between two stats broadcasts on one stream, per process.
|
|
19
|
+
# The row broadcasts are cheap (one partial); the stats payload is a full
|
|
20
|
+
# DashboardStats computation, and a capture runs it from inside the host
|
|
21
|
+
# app's request thread. Leading edge: the first event in a window
|
|
22
|
+
# broadcasts, later ones are dropped and corrected by the next window or
|
|
23
|
+
# the next page load.
|
|
24
|
+
STATS_BROADCAST_INTERVAL = 5
|
|
25
|
+
|
|
26
|
+
# Stream names, in ONE place for the view (turbo_stream_from) and for this
|
|
27
|
+
# class. Three kinds, because a Turbo subscription is all-or-nothing per
|
|
28
|
+
# stream and the index needs them separately: a view filtered by platform
|
|
29
|
+
# wants row REPLACES (harmless: they only touch rows already on the page)
|
|
30
|
+
# but not PREPENDS (a new row may not match its filter).
|
|
31
|
+
#
|
|
32
|
+
# Every kind exists globally and per application ("error_list_app_7"), so
|
|
33
|
+
# a view filtered to one application never receives another's rows.
|
|
34
|
+
STREAMS = { list: "error_list", updates: "error_updates", stats: "error_stats" }.freeze
|
|
35
|
+
|
|
36
|
+
# Column visibility (which optional cells a row has) used to be two
|
|
37
|
+
# SELECT DISTINCT scans per broadcast. It changes about never.
|
|
38
|
+
COLUMN_VISIBILITY_TTL = 60.seconds
|
|
39
|
+
|
|
40
|
+
# One throttle entry per stats stream, i.e. per application. Bounded so a
|
|
41
|
+
# host with thousands of applications cannot grow it without limit.
|
|
42
|
+
MAX_THROTTLED_STREAMS = 200
|
|
43
|
+
|
|
44
|
+
THROTTLE_MUTEX = Mutex.new
|
|
45
|
+
private_constant :THROTTLE_MUTEX
|
|
46
|
+
|
|
47
|
+
# @param kind [Symbol] :list (new rows), :updates (row replaces) or :stats
|
|
48
|
+
# @param application_id [Integer, String, nil] nil/blank for the global stream
|
|
49
|
+
# @return [String]
|
|
50
|
+
def self.stream_name(kind, application_id = nil)
|
|
51
|
+
base = STREAMS.fetch(kind)
|
|
52
|
+
return base if application_id.blank?
|
|
53
|
+
|
|
54
|
+
# to_i: the id reaches here from a request parameter in the view.
|
|
55
|
+
"#{base}_app_#{application_id.to_s[/\A\d+\z/].to_i}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The streams a dashboard index page subscribes to.
|
|
59
|
+
# @param application_id [Integer, String, nil] the page's application filter
|
|
60
|
+
# @param filtered [Boolean] any OTHER filter, a sort or a later page is active,
|
|
61
|
+
# so a new row cannot simply be put on top of the list
|
|
62
|
+
# @return [Array<String>]
|
|
63
|
+
def self.streams_for_view(application_id: nil, filtered: false)
|
|
64
|
+
kinds = filtered ? %i[updates stats] : %i[list updates stats]
|
|
65
|
+
kinds.map { |kind| stream_name(kind, application_id) }
|
|
66
|
+
end
|
|
67
|
+
|
|
18
68
|
# Broadcast a new error (prepend to error list + refresh stats)
|
|
19
69
|
# @param error_log [ErrorLog] The newly created error
|
|
20
70
|
def self.broadcast_new(error_log)
|
|
21
71
|
return unless error_log
|
|
22
72
|
return unless available?
|
|
73
|
+
return unless calm?
|
|
23
74
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
33
|
-
"error_list",
|
|
34
|
-
target: "error_list",
|
|
35
|
-
html: html
|
|
36
|
-
)
|
|
37
|
-
broadcast_stats
|
|
75
|
+
each_row_html(error_log) do |application_id, html|
|
|
76
|
+
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
77
|
+
stream_name(:list, application_id),
|
|
78
|
+
target: "error_list",
|
|
79
|
+
html: html
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
broadcast_stats(error_log.application_id)
|
|
38
83
|
rescue => e
|
|
39
84
|
Rails.logger.error("[RailsErrorDashboard] Failed to broadcast new error: #{e.class} - #{e.message}")
|
|
40
85
|
Rails.logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
|
|
@@ -45,37 +90,45 @@ module RailsErrorDashboard
|
|
|
45
90
|
def self.broadcast_update(error_log)
|
|
46
91
|
return unless error_log
|
|
47
92
|
return unless available?
|
|
93
|
+
return unless calm?
|
|
48
94
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
Turbo::StreamsChannel.broadcast_replace_to(
|
|
58
|
-
"error_list",
|
|
59
|
-
target: "error_#{error_log.id}",
|
|
60
|
-
html: html
|
|
61
|
-
)
|
|
62
|
-
broadcast_stats
|
|
95
|
+
each_row_html(error_log) do |application_id, html|
|
|
96
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
97
|
+
stream_name(:updates, application_id),
|
|
98
|
+
target: "error_#{error_log.id}",
|
|
99
|
+
html: html
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
broadcast_stats(error_log.application_id)
|
|
63
103
|
rescue => e
|
|
64
104
|
Rails.logger.error("[RailsErrorDashboard] Failed to broadcast error update: #{e.class} - #{e.message}")
|
|
65
105
|
Rails.logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
|
|
66
106
|
end
|
|
67
107
|
|
|
68
|
-
# Broadcast stats refresh
|
|
69
|
-
|
|
108
|
+
# Broadcast a stats refresh: all-application stats to the global stream,
|
|
109
|
+
# application-scoped stats to that application's stream.
|
|
110
|
+
#
|
|
111
|
+
# ONE event computes at most ONE payload. Each stream is throttled on its
|
|
112
|
+
# own (STATS_BROADCAST_INTERVAL); when both are due, the one that has
|
|
113
|
+
# waited longest goes and the other is served by the next event. Computing
|
|
114
|
+
# both would double the most expensive thing a capture does, and a
|
|
115
|
+
# fixed order would starve the second stream whenever events are rare.
|
|
116
|
+
#
|
|
117
|
+
# @param application_id [Integer, nil] the application of the event, if any
|
|
118
|
+
def self.broadcast_stats(application_id = nil)
|
|
70
119
|
return unless available?
|
|
120
|
+
return unless calm?
|
|
71
121
|
|
|
72
|
-
|
|
122
|
+
scope = claim_stats_window!([ nil, application_id.presence ].uniq)
|
|
123
|
+
return if scope == :none
|
|
124
|
+
|
|
125
|
+
stats = scope ? Queries::DashboardStats.call(application_id: scope) : Queries::DashboardStats.call
|
|
73
126
|
return unless stats.is_a?(Hash) && stats.present?
|
|
74
127
|
|
|
75
128
|
html = render_partial("rails_error_dashboard/errors/stats", stats: stats)
|
|
76
129
|
|
|
77
130
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
78
|
-
|
|
131
|
+
stream_name(:stats, scope),
|
|
79
132
|
target: "dashboard_stats",
|
|
80
133
|
html: html
|
|
81
134
|
)
|
|
@@ -84,6 +137,101 @@ module RailsErrorDashboard
|
|
|
84
137
|
Rails.logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
|
|
85
138
|
end
|
|
86
139
|
|
|
140
|
+
# Yields [application_id_or_nil, row_html] once per row stream. The global
|
|
141
|
+
# table has an Application column when there is more than one application;
|
|
142
|
+
# an application's own table never does, so the two need different cells.
|
|
143
|
+
# Rendered once when they come out the same.
|
|
144
|
+
def self.each_row_html(error_log)
|
|
145
|
+
render = lambda do |visibility, show_application|
|
|
146
|
+
render_partial("rails_error_dashboard/errors/error_row",
|
|
147
|
+
error: error_log, show_platform: visibility[:platform],
|
|
148
|
+
show_environment: visibility[:environment], show_application: show_application)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
global = column_visibility(nil)
|
|
152
|
+
yield nil, render.call(global, global[:application])
|
|
153
|
+
|
|
154
|
+
application_id = error_log.application_id
|
|
155
|
+
return if application_id.blank?
|
|
156
|
+
|
|
157
|
+
yield application_id, render.call(column_visibility(application_id), false)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Which optional columns the index table shows, for the global view or for
|
|
161
|
+
# one application -- the same rule the index itself applies. Cached for
|
|
162
|
+
# COLUMN_VISIBILITY_TTL and keyed on the cache generation, so a user
|
|
163
|
+
# action that could change it (a batch delete) refreshes it at once.
|
|
164
|
+
def self.column_visibility(application_id)
|
|
165
|
+
key = [ "red/columns", AnalyticsCacheManager.generation, application_id || "all" ].join("/")
|
|
166
|
+
|
|
167
|
+
Rails.cache.fetch(key, expires_in: COLUMN_VISIBILITY_TTL) do
|
|
168
|
+
scope = application_id ? ErrorLog.where(application_id: application_id) : ErrorLog.all
|
|
169
|
+
{
|
|
170
|
+
platform: scope.distinct.pluck(:platform).compact.size > 1,
|
|
171
|
+
environment: ErrorLog.column_names.include?("environment") &&
|
|
172
|
+
scope.distinct.pluck(:environment).compact.size > 1,
|
|
173
|
+
application: application_id.nil? && Application.count > 1
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Live updates are a convenience; during a storm they are load. While the
|
|
179
|
+
# breaker is anything but :closed nothing is broadcast -- the page catches
|
|
180
|
+
# up on its next load. Fails towards broadcasting: Gate.state itself
|
|
181
|
+
# answers :closed when storm protection is off or unreadable.
|
|
182
|
+
def self.calm?
|
|
183
|
+
StormProtection::Gate.state == :closed
|
|
184
|
+
rescue StandardError
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Picks which stats scope this event may broadcast, and claims its window.
|
|
189
|
+
#
|
|
190
|
+
# @param scopes [Array<Integer, nil>] candidate scopes (nil = all applications)
|
|
191
|
+
# @return [Integer, nil, :none] the claimed scope, or :none when every
|
|
192
|
+
# candidate is still inside its window
|
|
193
|
+
#
|
|
194
|
+
# The claim is taken BEFORE the stats are computed, so concurrent captures
|
|
195
|
+
# cannot all decide to compute at once, and a computation that fails does
|
|
196
|
+
# not get retried by every event that follows it.
|
|
197
|
+
def self.claim_stats_window!(scopes = [ nil ])
|
|
198
|
+
THROTTLE_MUTEX.synchronize do
|
|
199
|
+
@stats_claimed_at ||= {}
|
|
200
|
+
now = monotonic_now
|
|
201
|
+
|
|
202
|
+
due = scopes.select do |scope|
|
|
203
|
+
last = @stats_claimed_at[scope]
|
|
204
|
+
last.nil? || (now - last) >= STATS_BROADCAST_INTERVAL
|
|
205
|
+
end
|
|
206
|
+
return :none if due.empty?
|
|
207
|
+
|
|
208
|
+
# Longest-waiting first; never-claimed counts as waiting forever.
|
|
209
|
+
# min_by is stable, so the global stream wins a tie.
|
|
210
|
+
chosen = due.min_by { |scope| @stats_claimed_at[scope] || -Float::INFINITY }
|
|
211
|
+
@stats_claimed_at[chosen] = now
|
|
212
|
+
|
|
213
|
+
if @stats_claimed_at.size > MAX_THROTTLED_STREAMS
|
|
214
|
+
stalest = @stats_claimed_at.min_by { |_scope, at| at }.first
|
|
215
|
+
@stats_claimed_at.delete(stalest)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
chosen
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def self.throttled_stream_count
|
|
223
|
+
THROTTLE_MUTEX.synchronize { (@stats_claimed_at || {}).size }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.monotonic_now
|
|
227
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# For specs and for a forked worker that wants a clean slate.
|
|
231
|
+
def self.reset_throttle!
|
|
232
|
+
THROTTLE_MUTEX.synchronize { @stats_claimed_at = {} }
|
|
233
|
+
end
|
|
234
|
+
|
|
87
235
|
# Render a partial using the engine's controller renderer.
|
|
88
236
|
# This ensures engine route helpers (error_path, etc.) are available,
|
|
89
237
|
# unlike Turbo's default ApplicationController.render which uses the host app's context.
|
|
@@ -121,8 +121,13 @@ module RailsErrorDashboard
|
|
|
121
121
|
# @param message [String, nil] The error message
|
|
122
122
|
# @return [String, nil] Normalized message
|
|
123
123
|
def self.normalize_message(message)
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
# Slice, THEN scrub, then match: every path (sync, async, storm gate)
|
|
125
|
+
# goes through here, so they all hash the same text. gsub raises on a
|
|
126
|
+
# string with invalid bytes; a valid message is returned by scrub as-is,
|
|
127
|
+
# so no existing fingerprint changes.
|
|
128
|
+
prefix = message&.[](0, HASH_MESSAGE_LIMIT)
|
|
129
|
+
|
|
130
|
+
EncodingSanitizer.scrub(prefix)
|
|
126
131
|
&.gsub(/0x[0-9a-f]+/i, "HEX") # Replace hex addresses (before numbers)
|
|
127
132
|
&.gsub(/#<[^>]+>/, "#<OBJ>") # Replace object inspections
|
|
128
133
|
&.gsub(/\d+/, "N") # Replace numbers
|
|
@@ -161,7 +166,8 @@ module RailsErrorDashboard
|
|
|
161
166
|
!frame.include?("/gems/")
|
|
162
167
|
}
|
|
163
168
|
|
|
164
|
-
|
|
169
|
+
# split raises on a frame with invalid bytes (a method name can hold any).
|
|
170
|
+
EncodingSanitizer.scrub(first_app_frame)&.split(":")&.first
|
|
165
171
|
end
|
|
166
172
|
|
|
167
173
|
# Try custom fingerprint lambda if configured
|
|
@@ -10,6 +10,19 @@ module RailsErrorDashboard
|
|
|
10
10
|
# @example
|
|
11
11
|
# ErrorNotificationDispatcher.call(error_log)
|
|
12
12
|
class ErrorNotificationDispatcher
|
|
13
|
+
# Is any channel configured to the point where .call would enqueue
|
|
14
|
+
# something? The same conditions as below, without the side effects.
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def self.any_channel?
|
|
17
|
+
config = RailsErrorDashboard.configuration
|
|
18
|
+
|
|
19
|
+
(config.enable_slack_notifications && config.slack_webhook_url.present?) ||
|
|
20
|
+
(config.enable_email_notifications && config.notification_email_recipients.present?) ||
|
|
21
|
+
(config.enable_discord_notifications && config.discord_webhook_url.present?) ||
|
|
22
|
+
(config.enable_pagerduty_notifications && config.pagerduty_integration_key.present?) ||
|
|
23
|
+
(config.enable_webhook_notifications && config.webhook_urls.present?) || false
|
|
24
|
+
end
|
|
25
|
+
|
|
13
26
|
# @param error_log [ErrorLog] The error to notify about
|
|
14
27
|
def self.call(error_log)
|
|
15
28
|
# OTel: emit a child span around the dispatch so operators can see
|
|
@@ -10,6 +10,14 @@ module RailsErrorDashboard
|
|
|
10
10
|
# @example
|
|
11
11
|
# ExceptionFilter.should_log?(exception) # => true/false
|
|
12
12
|
class ExceptionFilter
|
|
13
|
+
# Distinct errors remembered for first-seen admission (see sampled_out?).
|
|
14
|
+
# Eviction only costs one extra admitted sample, so there is no overflow
|
|
15
|
+
# accounting here.
|
|
16
|
+
MAX_SEEN = 1_000
|
|
17
|
+
|
|
18
|
+
@seen = {}
|
|
19
|
+
@seen_mutex = Mutex.new
|
|
20
|
+
|
|
13
21
|
# Check if an exception should be logged (not ignored, not sampled out)
|
|
14
22
|
# @param exception [Exception] The exception to check
|
|
15
23
|
# @return [Boolean] true if the exception should be logged
|
|
@@ -48,16 +56,63 @@ module RailsErrorDashboard
|
|
|
48
56
|
# Critical errors are ALWAYS logged regardless of sampling
|
|
49
57
|
# @param exception [Exception] The exception to check
|
|
50
58
|
# @return [Boolean] true if the exception should be skipped
|
|
59
|
+
#
|
|
60
|
+
# The FIRST event of each error in this process is always admitted.
|
|
61
|
+
# Sampling is a dice roll taken before anything is known about the error,
|
|
62
|
+
# so at a rate of 0.01 an error that happens three times is usually never
|
|
63
|
+
# recorded at all. Sampling is there to cut volume; it must not hide that
|
|
64
|
+
# an error exists. A rate of 0.0 stays a hard off switch.
|
|
51
65
|
def self.sampled_out?(exception)
|
|
52
66
|
sampling_rate = RailsErrorDashboard.configuration.sampling_rate
|
|
53
67
|
|
|
54
68
|
return false if sampling_rate >= 1.0
|
|
55
69
|
return false if critical?(exception)
|
|
56
70
|
return true if sampling_rate <= 0.0
|
|
71
|
+
return false if first_sighting?(exception)
|
|
57
72
|
|
|
58
73
|
rand > sampling_rate
|
|
59
74
|
end
|
|
60
75
|
|
|
76
|
+
# True exactly once per process for each (exception class, first
|
|
77
|
+
# application frame). Coarser than the real fingerprint on purpose: the
|
|
78
|
+
# fingerprint needs the application id (a query) and this runs before the
|
|
79
|
+
# storm gate. Two errors raised from one line share a "first".
|
|
80
|
+
#
|
|
81
|
+
# Per process, so N workers admit N firsts. Bounded at MAX_SEEN, least
|
|
82
|
+
# recently seen evicted first. Any failure means "not seen": admit.
|
|
83
|
+
def self.first_sighting?(exception)
|
|
84
|
+
key = seen_key(exception)
|
|
85
|
+
|
|
86
|
+
@seen_mutex.synchronize do
|
|
87
|
+
seen_before = !@seen.delete(key).nil?
|
|
88
|
+
@seen.shift while @seen.size >= MAX_SEEN
|
|
89
|
+
@seen[key] = true
|
|
90
|
+
!seen_before
|
|
91
|
+
end
|
|
92
|
+
rescue => e
|
|
93
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] ExceptionFilter.first_sighting? failed: #{e.class}: #{e.message}")
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# "ClassName|first application frame". The first backtrace line that is
|
|
98
|
+
# not inside a gem or the Ruby core; the first line when there is no such
|
|
99
|
+
# frame; nothing when the exception was never raised.
|
|
100
|
+
def self.seen_key(exception)
|
|
101
|
+
backtrace = exception.backtrace || []
|
|
102
|
+
frame = backtrace.find { |line| !line.include?("/gems/") && !line.start_with?("<internal:") } || backtrace.first
|
|
103
|
+
|
|
104
|
+
"#{exception.class.name}|#{frame.to_s.sub(/:in .*\z/, "")}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.seen_size
|
|
108
|
+
@seen_mutex.synchronize { @seen.size }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Forget every sighting (specs, and after a fork).
|
|
112
|
+
def self.reset_seen!
|
|
113
|
+
@seen_mutex.synchronize { @seen.clear }
|
|
114
|
+
end
|
|
115
|
+
|
|
61
116
|
# Check if exception is a critical error type
|
|
62
117
|
# @param exception [Exception] The exception to check
|
|
63
118
|
# @return [Boolean] true if the exception is critical
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module RailsErrorDashboard
|
|
6
|
+
module Services
|
|
7
|
+
# Pure algorithm: the commit a checkout is on, read from .git directly
|
|
8
|
+
#
|
|
9
|
+
# Replaces `git rev-parse --short HEAD`. That ran in a subprocess, from the
|
|
10
|
+
# capture path, once per captured error: a fork + exec in the request
|
|
11
|
+
# thread, which SystemHealthSnapshot's safety rules forbid outright
|
|
12
|
+
# ("no fork/subprocess ever"). Three small file reads need no git binary,
|
|
13
|
+
# work in a slim container that has the .git directory but no git, and
|
|
14
|
+
# cannot hang.
|
|
15
|
+
#
|
|
16
|
+
# Resolution order, the same one git uses:
|
|
17
|
+
# 1. .git/HEAD holds a SHA (detached HEAD)
|
|
18
|
+
# 2. .git/HEAD holds "ref: refs/heads/x" -> the loose ref file
|
|
19
|
+
# 3. ...or the same ref in packed-refs
|
|
20
|
+
# `.git` may be a FILE ("gitdir: <path>") in a worktree or submodule; a
|
|
21
|
+
# worktree's refs live in the directory named by its `commondir` file.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# GitHeadReader.call(Rails.root) # => "39f281a" or nil
|
|
25
|
+
class GitHeadReader
|
|
26
|
+
SHA = /\A\h{40}(\h{24})?\z/ # SHA-1, or SHA-256 repositories
|
|
27
|
+
SHORT = 7
|
|
28
|
+
MAX_BYTES = 1_000_000 # packed-refs in a very large repository: skip it
|
|
29
|
+
|
|
30
|
+
# @param root [Pathname, String, nil] the application root
|
|
31
|
+
# @return [String, nil] short SHA, or nil when it cannot be determined
|
|
32
|
+
def self.call(root)
|
|
33
|
+
return nil if root.nil?
|
|
34
|
+
|
|
35
|
+
git_dir = resolve_git_dir(Pathname(root.to_s).join(".git"))
|
|
36
|
+
return nil unless git_dir
|
|
37
|
+
|
|
38
|
+
head = read(git_dir.join("HEAD"))
|
|
39
|
+
return nil unless head
|
|
40
|
+
return head[0, SHORT] if head.match?(SHA)
|
|
41
|
+
|
|
42
|
+
ref = head[/\Aref:\s*(\S+)\z/, 1]
|
|
43
|
+
# A ref is a path under the git dir; never follow one out of it.
|
|
44
|
+
return nil if ref.nil? || !ref.start_with?("refs/") || ref.include?("..")
|
|
45
|
+
|
|
46
|
+
sha = [ git_dir, common_dir(git_dir) ].uniq.lazy.filter_map { |dir|
|
|
47
|
+
loose_ref(dir, ref) || packed_ref(dir, ref)
|
|
48
|
+
}.first
|
|
49
|
+
sha && sha[0, SHORT]
|
|
50
|
+
rescue => e
|
|
51
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] GitHeadReader failed: #{e.class}: #{e.message}")
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.resolve_git_dir(dot_git)
|
|
56
|
+
return dot_git if dot_git.directory?
|
|
57
|
+
return nil unless dot_git.file?
|
|
58
|
+
|
|
59
|
+
target = read(dot_git)&.[](/\Agitdir:\s*(.+)\z/, 1)
|
|
60
|
+
return nil unless target
|
|
61
|
+
|
|
62
|
+
dir = Pathname(target)
|
|
63
|
+
dir = dot_git.dirname.join(dir) unless dir.absolute?
|
|
64
|
+
dir.directory? ? dir : nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.common_dir(git_dir)
|
|
68
|
+
relative = read(git_dir.join("commondir"))
|
|
69
|
+
return git_dir unless relative
|
|
70
|
+
|
|
71
|
+
dir = Pathname(relative)
|
|
72
|
+
dir = git_dir.join(dir) unless dir.absolute?
|
|
73
|
+
dir.directory? ? dir.cleanpath : git_dir
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.loose_ref(dir, ref)
|
|
77
|
+
value = read(dir.join(ref))
|
|
78
|
+
value if value&.match?(SHA)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.packed_ref(dir, ref)
|
|
82
|
+
file = dir.join("packed-refs")
|
|
83
|
+
return nil unless file.file? && file.size <= MAX_BYTES
|
|
84
|
+
|
|
85
|
+
File.foreach(file) do |line|
|
|
86
|
+
sha, name = line.split(" ", 2)
|
|
87
|
+
return sha if name&.strip == ref && sha.match?(SHA)
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# First line of a small file, stripped; nil when absent or oversized.
|
|
93
|
+
def self.read(path)
|
|
94
|
+
return nil unless path.file? && path.size <= MAX_BYTES
|
|
95
|
+
|
|
96
|
+
File.open(path, &:gets)&.strip.presence
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method :resolve_git_dir, :common_dir, :loose_ref, :packed_ref, :read
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|