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
|
@@ -23,6 +23,113 @@ module RailsErrorDashboard
|
|
|
23
23
|
new(error_type, platform).weekly_baseline
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Pairs considered by .current_anomalies, busiest first. A bound, so the
|
|
27
|
+
# check costs the same whether 5 or 50,000 error types are stored.
|
|
28
|
+
MAX_ANOMALY_PAIRS = 500
|
|
29
|
+
BASELINE_PRECEDENCE = %w[hourly daily weekly].freeze
|
|
30
|
+
|
|
31
|
+
# Every (error_type, platform) pair that is anomalous RIGHT NOW, for all
|
|
32
|
+
# pairs at once. Same rule as #check_current_anomaly -- the first baseline
|
|
33
|
+
# available among hourly / daily / weekly, compared against this hour /
|
|
34
|
+
# today / this week -- but in a fixed number of queries instead of about six
|
|
35
|
+
# per pair. DashboardStats calls this from the live stats broadcast, which
|
|
36
|
+
# runs inside the host app's capture path.
|
|
37
|
+
#
|
|
38
|
+
# Only pairs with an event this week are looked at: a pair with none has a
|
|
39
|
+
# count of zero, which no baseline can call anomalous.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array<Hash>] error_type, platform, count, level, std_devs_above,
|
|
42
|
+
# baseline_type. Empty on any failure -- never raises.
|
|
43
|
+
def self.current_anomalies(sensitivity: 2, application_id: nil)
|
|
44
|
+
return [] unless defined?(ErrorBaseline) && ErrorBaseline.table_exists?
|
|
45
|
+
|
|
46
|
+
counts = current_counts_by_pair(application_id: application_id)
|
|
47
|
+
return [] if counts.empty?
|
|
48
|
+
|
|
49
|
+
baselines = latest_baselines_for(counts.keys)
|
|
50
|
+
|
|
51
|
+
counts.filter_map do |(error_type, platform), windows|
|
|
52
|
+
kind = BASELINE_PRECEDENCE.find { |type| baselines[[ error_type, platform, type ]] }
|
|
53
|
+
next unless kind
|
|
54
|
+
|
|
55
|
+
baseline = baselines[[ error_type, platform, kind ]]
|
|
56
|
+
# A flat history has no spread to measure against; dividing by it
|
|
57
|
+
# makes any count above the mean infinitely anomalous.
|
|
58
|
+
next if baseline.std_dev.nil? || baseline.std_dev.zero?
|
|
59
|
+
|
|
60
|
+
count = windows.fetch(kind.to_sym)
|
|
61
|
+
level = baseline.anomaly_level(count, sensitivity: sensitivity)
|
|
62
|
+
next unless level
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
error_type: error_type,
|
|
66
|
+
platform: platform,
|
|
67
|
+
count: count,
|
|
68
|
+
level: level,
|
|
69
|
+
std_devs_above: baseline.std_devs_above_mean(count),
|
|
70
|
+
baseline_type: kind
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
rescue => e
|
|
74
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] current_anomalies failed: #{e.class}: #{e.message}")
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# { [error_type, platform] => { hourly:, daily:, weekly: } } in ONE grouped
|
|
79
|
+
# query, counted in the units the baselines were built from (occurrence
|
|
80
|
+
# rows when that table exists). The week is the widest window, so it is
|
|
81
|
+
# the WHERE; the day and the hour are conditional sums inside it.
|
|
82
|
+
def self.current_counts_by_pair(application_id: nil)
|
|
83
|
+
logs = ErrorLog.table_name
|
|
84
|
+
column = Services::BaselineCalculator.time_column
|
|
85
|
+
now = Time.current
|
|
86
|
+
|
|
87
|
+
relation = if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
88
|
+
ErrorOccurrence.joins(:error_log)
|
|
89
|
+
else
|
|
90
|
+
ErrorLog.all
|
|
91
|
+
end
|
|
92
|
+
relation = relation.where(logs => { application_id: application_id }) if application_id.present?
|
|
93
|
+
|
|
94
|
+
since = ->(time) { ErrorLog.sanitize_sql_array([ "SUM(CASE WHEN #{column} >= ? THEN 1 ELSE 0 END)", time ]) }
|
|
95
|
+
|
|
96
|
+
rows = relation
|
|
97
|
+
.where("#{column} >= ?", now.beginning_of_week)
|
|
98
|
+
.group("#{logs}.error_type", "#{logs}.platform")
|
|
99
|
+
.order(Arel.sql("COUNT(*) DESC"))
|
|
100
|
+
.limit(MAX_ANOMALY_PAIRS)
|
|
101
|
+
.pluck(Arel.sql("#{logs}.error_type"), Arel.sql("#{logs}.platform"), Arel.sql("COUNT(*)"),
|
|
102
|
+
Arel.sql(since.call(now.beginning_of_day)), Arel.sql(since.call(now.beginning_of_hour)))
|
|
103
|
+
|
|
104
|
+
rows.to_h do |error_type, platform, weekly, daily, hourly|
|
|
105
|
+
[ [ error_type, platform ], { weekly: weekly.to_i, daily: daily.to_i, hourly: hourly.to_i } ]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# { [error_type, platform, baseline_type] => ErrorBaseline }, the most recent
|
|
110
|
+
# row of each, in ONE query. Baseline rows accumulate (one per calculation
|
|
111
|
+
# period), so "latest" is resolved in SQL with a join on MAX(period_start)
|
|
112
|
+
# rather than by loading the history. The join form is portable to every
|
|
113
|
+
# adapter; a row-value IN is not.
|
|
114
|
+
def self.latest_baselines_for(pairs)
|
|
115
|
+
table = ErrorBaseline.table_name
|
|
116
|
+
types = pairs.map(&:first).compact.uniq
|
|
117
|
+
return {} if types.empty?
|
|
118
|
+
|
|
119
|
+
latest = ErrorBaseline.where(error_type: types, baseline_type: BASELINE_PRECEDENCE)
|
|
120
|
+
.group(:error_type, :platform, :baseline_type)
|
|
121
|
+
.select(:error_type, :platform, :baseline_type, "MAX(period_start) AS latest_period_start")
|
|
122
|
+
|
|
123
|
+
ErrorBaseline
|
|
124
|
+
.joins("INNER JOIN (#{latest.to_sql}) latest_baselines ON " \
|
|
125
|
+
"latest_baselines.error_type = #{table}.error_type AND " \
|
|
126
|
+
"latest_baselines.platform = #{table}.platform AND " \
|
|
127
|
+
"latest_baselines.baseline_type = #{table}.baseline_type AND " \
|
|
128
|
+
"latest_baselines.latest_period_start = #{table}.period_start")
|
|
129
|
+
.index_by { |baseline| [ baseline.error_type, baseline.platform, baseline.baseline_type ] }
|
|
130
|
+
end
|
|
131
|
+
private_class_method :current_counts_by_pair, :latest_baselines_for
|
|
132
|
+
|
|
26
133
|
def initialize(error_type, platform)
|
|
27
134
|
@error_type = error_type
|
|
28
135
|
@platform = platform
|
|
@@ -5,6 +5,9 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Query: Fetch dashboard statistics
|
|
6
6
|
# This is a read operation that aggregates error data for the dashboard
|
|
7
7
|
class DashboardStats
|
|
8
|
+
# One instance answers ONE call: several aggregates (today's event count,
|
|
9
|
+
# the 7-day trend, spike detection) are memoised on it so that they are
|
|
10
|
+
# computed once per call rather than once per card that shows them.
|
|
8
11
|
def initialize(application_id: nil)
|
|
9
12
|
@application_id = application_id
|
|
10
13
|
end
|
|
@@ -24,7 +27,7 @@ module RailsErrorDashboard
|
|
|
24
27
|
# rows reported five users hitting one error as "1 error today".
|
|
25
28
|
# Summing is also exact during a storm: counted-only events never
|
|
26
29
|
# create occurrence rows, but they DO raise occurrence_count.
|
|
27
|
-
total_today:
|
|
30
|
+
total_today: today_event_count,
|
|
28
31
|
total_week: event_count_since(7.days.ago),
|
|
29
32
|
total_month: event_count_since(30.days.ago),
|
|
30
33
|
unresolved: base_scope.unresolved.count,
|
|
@@ -93,13 +96,14 @@ module RailsErrorDashboard
|
|
|
93
96
|
end
|
|
94
97
|
|
|
95
98
|
def cache_key
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
+
# The cache GENERATION, not maximum(:updated_at): the timestamp cost a
|
|
100
|
+
# query per key build and changed on every capture, so the cache never
|
|
101
|
+
# hit while errors were arriving. Freshness after a capture is the
|
|
102
|
+
# 1-minute TTL; user actions bump the generation (AnalyticsCacheManager).
|
|
99
103
|
[
|
|
100
104
|
"dashboard_stats",
|
|
101
105
|
@application_id || "all",
|
|
102
|
-
|
|
106
|
+
Services::AnalyticsCacheManager.generation,
|
|
103
107
|
Time.current.hour
|
|
104
108
|
].join("/")
|
|
105
109
|
end
|
|
@@ -147,7 +151,7 @@ module RailsErrorDashboard
|
|
|
147
151
|
recorded = occurrence_scope
|
|
148
152
|
.where("#{ErrorOccurrence.table_name}.occurred_at >= ?", Time.current.beginning_of_day)
|
|
149
153
|
.count
|
|
150
|
-
recorded <
|
|
154
|
+
recorded < today_event_count
|
|
151
155
|
rescue StandardError
|
|
152
156
|
false
|
|
153
157
|
end
|
|
@@ -169,9 +173,10 @@ module RailsErrorDashboard
|
|
|
169
173
|
|
|
170
174
|
# Get 7-day error trend (daily counts)
|
|
171
175
|
def errors_trend_7d
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
@errors_trend_7d ||=
|
|
177
|
+
base_scope.where("occurred_at >= ?", 7.days.ago)
|
|
178
|
+
.group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
|
|
179
|
+
.sum(:occurrence_count)
|
|
175
180
|
end
|
|
176
181
|
|
|
177
182
|
# Get error counts by severity for last 7 days
|
|
@@ -193,21 +198,25 @@ module RailsErrorDashboard
|
|
|
193
198
|
|
|
194
199
|
# Detect if there's an error spike
|
|
195
200
|
# Uses baselines if available, falls back to simple 2x average
|
|
201
|
+
#
|
|
202
|
+
# Memoised: the stats hash asks twice (spike_detected and spike_info), and
|
|
203
|
+
# this runs from the live stats broadcast inside the capture path.
|
|
196
204
|
def spike_detected?
|
|
197
|
-
return
|
|
205
|
+
return @spike_detected if defined?(@spike_detected)
|
|
198
206
|
|
|
199
|
-
|
|
207
|
+
@spike_detected = compute_spike_detected
|
|
208
|
+
end
|
|
200
209
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
def compute_spike_detected
|
|
211
|
+
trend = errors_trend_7d
|
|
212
|
+
return false if trend.empty?
|
|
213
|
+
return true if baseline_anomalies.any?
|
|
205
214
|
|
|
206
215
|
# Fall back to simple 2x average detection
|
|
207
|
-
avg_count =
|
|
216
|
+
avg_count = trend.values.sum / 7.0
|
|
208
217
|
return false if avg_count.zero?
|
|
209
218
|
|
|
210
|
-
|
|
219
|
+
today_event_count >= (avg_count * 2)
|
|
211
220
|
end
|
|
212
221
|
|
|
213
222
|
# Get spike information
|
|
@@ -215,7 +224,7 @@ module RailsErrorDashboard
|
|
|
215
224
|
def spike_info
|
|
216
225
|
return nil unless spike_detected?
|
|
217
226
|
|
|
218
|
-
today_count =
|
|
227
|
+
today_count = today_event_count
|
|
219
228
|
avg_count = (errors_trend_7d.values.sum / 7.0).round(1)
|
|
220
229
|
|
|
221
230
|
info = {
|
|
@@ -226,46 +235,35 @@ module RailsErrorDashboard
|
|
|
226
235
|
}
|
|
227
236
|
|
|
228
237
|
# Add baseline info if available
|
|
229
|
-
baseline_info = baseline_anomaly_info
|
|
238
|
+
baseline_info = baseline_anomaly_info
|
|
230
239
|
info.merge!(baseline_info) if baseline_info.present?
|
|
231
240
|
|
|
232
241
|
info
|
|
233
242
|
end
|
|
234
243
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
def today_event_count
|
|
245
|
+
@today_event_count ||= event_count_since(Time.current.beginning_of_day)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Every anomalous (error_type, platform) pair, from a fixed number of
|
|
249
|
+
# queries (BaselineStats.current_anomalies), loaded once per call. This
|
|
250
|
+
# used to be about six queries per distinct pair, run twice.
|
|
251
|
+
def baseline_anomalies
|
|
252
|
+
return @baseline_anomalies if defined?(@baseline_anomalies)
|
|
238
253
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
254
|
+
@baseline_anomalies = if defined?(Queries::BaselineStats)
|
|
255
|
+
Queries::BaselineStats.current_anomalies(sensitivity: 2, application_id: @application_id)
|
|
256
|
+
else
|
|
257
|
+
[]
|
|
243
258
|
end
|
|
244
259
|
end
|
|
245
260
|
|
|
246
261
|
# Get baseline anomaly information
|
|
247
|
-
def baseline_anomaly_info
|
|
248
|
-
return nil
|
|
249
|
-
|
|
250
|
-
# Find the most anomalous error type
|
|
251
|
-
anomalies = base_scope.distinct.pluck(:error_type, :platform).compact.map do |(error_type, platform)|
|
|
252
|
-
result = Queries::BaselineStats.new(error_type, platform)
|
|
253
|
-
.check_current_anomaly(sensitivity: 2, application_id: @application_id)
|
|
254
|
-
next unless result[:anomaly]
|
|
255
|
-
|
|
256
|
-
{
|
|
257
|
-
error_type: error_type,
|
|
258
|
-
platform: platform,
|
|
259
|
-
count: result[:current_count],
|
|
260
|
-
level: result[:level],
|
|
261
|
-
std_devs_above: result[:std_devs_above]
|
|
262
|
-
}
|
|
263
|
-
end.compact
|
|
264
|
-
|
|
265
|
-
return nil if anomalies.empty?
|
|
262
|
+
def baseline_anomaly_info
|
|
263
|
+
return nil if baseline_anomalies.empty?
|
|
266
264
|
|
|
267
265
|
# Return info about worst anomaly
|
|
268
|
-
worst =
|
|
266
|
+
worst = baseline_anomalies.max_by { |a| a[:std_devs_above] || 0 }
|
|
269
267
|
{
|
|
270
268
|
baseline_detected: true,
|
|
271
269
|
anomaly_error_type: worst[:error_type],
|
|
@@ -283,7 +281,7 @@ module RailsErrorDashboard
|
|
|
283
281
|
# make a real failure percentage from, so the honest figure is the rate
|
|
284
282
|
# itself, uncapped, labelled with its unit.
|
|
285
283
|
def error_rate
|
|
286
|
-
today_events =
|
|
284
|
+
today_events = today_event_count
|
|
287
285
|
return 0.0 if today_events.zero?
|
|
288
286
|
|
|
289
287
|
hours_today = ((Time.current - Time.current.beginning_of_day) / 1.hour).round(1)
|
|
@@ -299,11 +297,12 @@ module RailsErrorDashboard
|
|
|
299
297
|
# affected user. Storm count-only events create no occurrence row, so
|
|
300
298
|
# this is a floor during a storm -- affected_users_incomplete? says when.
|
|
301
299
|
def affected_users_today
|
|
302
|
-
distinct_affected_users(Time.current.beginning_of_day, nil)
|
|
300
|
+
@affected_users_today ||= distinct_affected_users(Time.current.beginning_of_day, nil)
|
|
303
301
|
end
|
|
304
302
|
|
|
305
303
|
def affected_users_yesterday
|
|
306
|
-
|
|
304
|
+
@affected_users_yesterday ||=
|
|
305
|
+
distinct_affected_users(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
|
|
307
306
|
end
|
|
308
307
|
|
|
309
308
|
def distinct_affected_users(from, to)
|
|
@@ -334,7 +333,13 @@ module RailsErrorDashboard
|
|
|
334
333
|
|
|
335
334
|
# Calculate percentage change in errors (today vs yesterday)
|
|
336
335
|
def trend_percentage
|
|
337
|
-
|
|
336
|
+
return @trend_percentage if defined?(@trend_percentage)
|
|
337
|
+
|
|
338
|
+
@trend_percentage = compute_trend_percentage
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def compute_trend_percentage
|
|
342
|
+
today = today_event_count
|
|
338
343
|
yesterday = event_count_between(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
|
|
339
344
|
|
|
340
345
|
return 0.0 if today.zero? && yesterday.zero?
|
|
@@ -224,12 +224,15 @@ module RailsErrorDashboard
|
|
|
224
224
|
previous_start = @start_date
|
|
225
225
|
previous_end = current_start
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
# Both periods come from base_query, which carries the application
|
|
228
|
+
# filter (and the window start). Querying ErrorLog directly made this
|
|
229
|
+
# the one panel on an application-filtered page that counted every app.
|
|
230
|
+
current_errors = base_query
|
|
228
231
|
.where("occurred_at >= ?", current_start)
|
|
229
232
|
.count
|
|
230
233
|
|
|
231
|
-
previous_errors =
|
|
232
|
-
.where("occurred_at
|
|
234
|
+
previous_errors = base_query
|
|
235
|
+
.where("occurred_at < ?", previous_end)
|
|
233
236
|
.count
|
|
234
237
|
|
|
235
238
|
change_percentage = if previous_errors > 0
|
|
@@ -5,6 +5,9 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Query: Fetch errors with filtering and pagination
|
|
6
6
|
# This is a read operation that returns a filtered collection of errors
|
|
7
7
|
class ErrorsList
|
|
8
|
+
# Escape character for LIKE patterns; see filter_by_search.
|
|
9
|
+
LIKE_ESCAPE = "!"
|
|
10
|
+
|
|
8
11
|
def self.call(filters = {})
|
|
9
12
|
new(filters).call
|
|
10
13
|
end
|
|
@@ -129,9 +132,19 @@ module RailsErrorDashboard
|
|
|
129
132
|
else
|
|
130
133
|
# Fall back to LIKE for SQLite/MySQL - search across all relevant fields
|
|
131
134
|
# Use LOWER() for case-insensitive search
|
|
132
|
-
|
|
135
|
+
#
|
|
136
|
+
# % and _ in the term are escaped so they match themselves: a search
|
|
137
|
+
# for "snake_case" must not match "snakeXcase", and "%" must not
|
|
138
|
+
# match every row. The escape character is named explicitly because
|
|
139
|
+
# SQLite has no default one, and it is "!" rather than a backslash
|
|
140
|
+
# because a backslash inside a quoted SQL literal means different
|
|
141
|
+
# things to MySQL and to everyone else.
|
|
142
|
+
escaped = ActiveRecord::Base.sanitize_sql_like(@filters[:search].to_s, LIKE_ESCAPE)
|
|
143
|
+
search_pattern = "%#{escaped}%"
|
|
133
144
|
query.where(
|
|
134
|
-
"LOWER(message) LIKE LOWER(?)
|
|
145
|
+
"LOWER(message) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}' " \
|
|
146
|
+
"OR LOWER(COALESCE(backtrace, '')) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}' " \
|
|
147
|
+
"OR LOWER(error_type) LIKE LOWER(?) ESCAPE '#{LIKE_ESCAPE}'",
|
|
135
148
|
search_pattern, search_pattern, search_pattern
|
|
136
149
|
)
|
|
137
150
|
end
|
|
@@ -78,7 +78,7 @@ module RailsErrorDashboard
|
|
|
78
78
|
if error_prefix.present?
|
|
79
79
|
candidates += ErrorLog
|
|
80
80
|
.where(platform: target_error.platform)
|
|
81
|
-
.where("error_type LIKE ?", "%#{error_prefix}%")
|
|
81
|
+
.where("error_type LIKE ? ESCAPE '!'", "%#{ActiveRecord::Base.sanitize_sql_like(error_prefix, "!")}%")
|
|
82
82
|
.where.not(id: target_error.id)
|
|
83
83
|
.where.not(id: candidates.map(&:id))
|
|
84
84
|
.limit(20)
|
|
@@ -2,29 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Services
|
|
5
|
-
# Infrastructure service:
|
|
5
|
+
# Infrastructure service: invalidate the dashboard's cached statistics
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Every cached stats key (DashboardStats, AnalyticsStats) embeds a
|
|
8
|
+
# GENERATION number read from Rails.cache. Invalidation is one increment of
|
|
9
|
+
# that number: entries written under the old generation are simply never
|
|
10
|
+
# read again and age out by their own TTL.
|
|
11
|
+
#
|
|
12
|
+
# This replaced delete_matched("dashboard_stats/*") and friends, which is a
|
|
13
|
+
# SCAN of the HOST app's whole Redis keyspace, NotImplementedError on
|
|
14
|
+
# memcached, and used to run from ErrorLog's after_save -- i.e. inside the
|
|
15
|
+
# host's request thread on every captured error.
|
|
16
|
+
#
|
|
17
|
+
# Who calls .clear: user actions and maintenance that change what the cards
|
|
18
|
+
# show (resolve, status change, batch actions, mute, retention). Captures
|
|
19
|
+
# deliberately do NOT: they rely on the TTL (1 minute for the stat cards,
|
|
20
|
+
# 5 for analytics), which decouples capture cost from dashboard freshness.
|
|
21
|
+
#
|
|
22
|
+
# With :memory_store the generation is per process, so an action handled by
|
|
23
|
+
# one worker reaches the others when their entries expire. With :null_store
|
|
24
|
+
# nothing is cached and there is nothing to invalidate.
|
|
10
25
|
class AnalyticsCacheManager
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
26
|
+
GENERATION_KEY = "red/cache_gen"
|
|
27
|
+
|
|
28
|
+
# @return [Integer] the current cache generation; 0 when unset or unreadable
|
|
29
|
+
def self.generation
|
|
30
|
+
Rails.cache.read(GENERATION_KEY).to_i
|
|
31
|
+
rescue => e
|
|
32
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] cache generation unreadable: #{e.class}: #{e.message}")
|
|
33
|
+
0
|
|
34
|
+
end
|
|
16
35
|
|
|
17
|
-
#
|
|
36
|
+
# Invalidate every cached dashboard statistic. Never raises.
|
|
37
|
+
#
|
|
38
|
+
# A plain read + write, deliberately not Rails.cache.increment: increment
|
|
39
|
+
# is unimplemented on some stores, returns nil for a missing key on
|
|
40
|
+
# others, and on Redis is an INCRBY that fails against an entry written
|
|
41
|
+
# by #write. It does not need to be atomic either -- racing clears only
|
|
42
|
+
# need the number to differ from the one stale entries were written under.
|
|
43
|
+
#
|
|
44
|
+
# Never lower than the clock in milliseconds, so that a store which evicts
|
|
45
|
+
# the generation key cannot restart at a number that entries still inside
|
|
46
|
+
# their TTL were written under a few minutes earlier.
|
|
18
47
|
def self.clear
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
else
|
|
22
|
-
Rails.logger.info("Cache store doesn't support delete_matched, skipping cache clear") if Rails.logger
|
|
23
|
-
end
|
|
24
|
-
rescue NotImplementedError => e
|
|
25
|
-
Rails.logger.info("Cache store doesn't support delete_matched: #{e.message}") if Rails.logger
|
|
48
|
+
Rails.cache.write(GENERATION_KEY, [ generation + 1, (Time.now.to_f * 1000).to_i ].max)
|
|
49
|
+
nil
|
|
26
50
|
rescue => e
|
|
27
|
-
|
|
51
|
+
RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to clear analytics cache: #{e.class}: #{e.message}")
|
|
52
|
+
nil
|
|
28
53
|
end
|
|
29
54
|
end
|
|
30
55
|
end
|
|
@@ -22,7 +22,9 @@ module RailsErrorDashboard
|
|
|
22
22
|
|
|
23
23
|
max_lines ||= RailsErrorDashboard.configuration.max_backtrace_lines
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# Scrubbed per line: a frame label can hold any bytes, and both the sub
|
|
26
|
+
# below and the join (mixed encodings) raise on an invalid one.
|
|
27
|
+
limited_backtrace = backtrace.first(max_lines).map { |line| shorten_gem_path(EncodingSanitizer.scrub(line)) }
|
|
26
28
|
result = limited_backtrace.join("\n")
|
|
27
29
|
|
|
28
30
|
if backtrace.length > max_lines
|
|
@@ -43,7 +43,9 @@ module RailsErrorDashboard
|
|
|
43
43
|
depth += 1
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
# to_json raises on a cause message or frame with invalid bytes, which
|
|
47
|
+
# used to drop the whole chain.
|
|
48
|
+
chain.empty? ? nil : EncodingSanitizer.scrub_deep(chain).to_json
|
|
47
49
|
rescue => e
|
|
48
50
|
# SAFETY: Never let cause chain extraction break error logging
|
|
49
51
|
RailsErrorDashboard::Logger.debug(
|
|
@@ -40,7 +40,7 @@ module RailsErrorDashboard
|
|
|
40
40
|
auth_headers
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
patch_result(response)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def reopen_issue(number:)
|
|
@@ -50,7 +50,7 @@ module RailsErrorDashboard
|
|
|
50
50
|
auth_headers
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
patch_result(response)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def add_comment(number:, body:)
|
|
@@ -114,6 +114,17 @@ module RailsErrorDashboard
|
|
|
114
114
|
|
|
115
115
|
private
|
|
116
116
|
|
|
117
|
+
# Gitea/Forgejo answer a successful PATCH with 200; 201 is what they
|
|
118
|
+
# return for creation. Accepting only 201 reported every close and reopen
|
|
119
|
+
# as failed although the forge had applied it.
|
|
120
|
+
def patch_result(response)
|
|
121
|
+
if [ 200, 201 ].include?(response[:status])
|
|
122
|
+
success_response({})
|
|
123
|
+
else
|
|
124
|
+
error_response("Codeberg API error (#{response[:status]})")
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
117
128
|
def auth_headers
|
|
118
129
|
{ "Authorization" => "token #{@token}" }
|
|
119
130
|
end
|
|
@@ -27,7 +27,9 @@ module RailsErrorDashboard
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def call
|
|
30
|
-
|
|
30
|
+
# Thread names and breadcrumb text are arbitrary bytes; both callers
|
|
31
|
+
# immediately to_json this, which raises on an invalid one.
|
|
32
|
+
EncodingSanitizer.scrub_deep(
|
|
31
33
|
captured_at: Time.current.iso8601,
|
|
32
34
|
pid: Process.pid,
|
|
33
35
|
uptime_seconds: process_uptime,
|
|
@@ -37,9 +39,9 @@ module RailsErrorDashboard
|
|
|
37
39
|
threads: thread_info,
|
|
38
40
|
gc: gc_info,
|
|
39
41
|
object_counts: object_counts
|
|
40
|
-
|
|
42
|
+
)
|
|
41
43
|
rescue => e
|
|
42
|
-
{ captured_at: Time.current.iso8601, error: e.message }
|
|
44
|
+
{ captured_at: Time.current.iso8601, error: EncodingSanitizer.scrub(e.message) }
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
private
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: make every String in a value safe to store, serialize and render
|
|
6
|
+
#
|
|
7
|
+
# Exception messages, backtrace lines, request URLs, user agents and params can
|
|
8
|
+
# carry bytes that are not valid UTF-8 (a binary upload echoed in a message, a
|
|
9
|
+
# Latin-1 query string, a NUL from a fuzzer). Left alone they raise at the
|
|
10
|
+
# worst possible moments: ActiveJob JSON-encoding the async payload in the
|
|
11
|
+
# host's request thread, the PostgreSQL INSERT, or `blank?` while rendering the
|
|
12
|
+
# error's own page.
|
|
13
|
+
#
|
|
14
|
+
# Invalid sequences become "?" and NUL bytes are removed (PostgreSQL text
|
|
15
|
+
# columns reject them). Strings tagged ASCII-8BIT (or any other encoding) are
|
|
16
|
+
# re-tagged UTF-8 and then scrubbed — not transcoded, because a binary tag
|
|
17
|
+
# says nothing about what the bytes mean and transcoding would raise.
|
|
18
|
+
#
|
|
19
|
+
# The common case costs one `valid_encoding?` scan and returns the SAME
|
|
20
|
+
# object. Nothing here raises, and the caller's objects are never mutated.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# EncodingSanitizer.scrub("caf\xC3 \xFF".b) # => "caf? ?"
|
|
24
|
+
# EncodingSanitizer.scrub_deep({ url: "/?q=\xFF" }) # => { url: "/?q=?" }
|
|
25
|
+
class EncodingSanitizer
|
|
26
|
+
REPLACEMENT = "?"
|
|
27
|
+
UNREADABLE = "[unreadable]"
|
|
28
|
+
TOO_DEEP = "[truncated: nested too deep]"
|
|
29
|
+
MAX_DEPTH = 10
|
|
30
|
+
NUL = "\0"
|
|
31
|
+
|
|
32
|
+
# @param str [Object] anything; only Strings are touched
|
|
33
|
+
# @return [Object] the same object when already clean, otherwise a clean copy
|
|
34
|
+
def self.scrub(str)
|
|
35
|
+
return str unless String === str
|
|
36
|
+
|
|
37
|
+
s = str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8)
|
|
38
|
+
return s if s.valid_encoding? && !s.include?(NUL)
|
|
39
|
+
|
|
40
|
+
s = s.scrub(REPLACEMENT) unless s.valid_encoding?
|
|
41
|
+
s.delete(NUL)
|
|
42
|
+
rescue => e
|
|
43
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EncodingSanitizer.scrub failed: #{e.class}")
|
|
44
|
+
UNREADABLE
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Recursively scrub Strings inside Hashes (keys and values) and Arrays.
|
|
48
|
+
# Anything else is returned untouched. A container nested deeper than
|
|
49
|
+
# MAX_DEPTH is replaced by a placeholder rather than passed through: an
|
|
50
|
+
# unscrubbed subtree would defeat the point, and a self-referencing one
|
|
51
|
+
# would never end.
|
|
52
|
+
#
|
|
53
|
+
# @param obj [Object]
|
|
54
|
+
# @return [Object]
|
|
55
|
+
def self.scrub_deep(obj, depth = 0)
|
|
56
|
+
case obj
|
|
57
|
+
when String
|
|
58
|
+
scrub(obj)
|
|
59
|
+
when Hash
|
|
60
|
+
return TOO_DEEP if depth >= MAX_DEPTH
|
|
61
|
+
|
|
62
|
+
# Build into an empty copy of the same class so a
|
|
63
|
+
# HashWithIndifferentAccess stays one.
|
|
64
|
+
obj.each_with_object(obj.class.new) do |(key, value), result|
|
|
65
|
+
result[scrub_deep(key, depth + 1)] = scrub_deep(value, depth + 1)
|
|
66
|
+
end
|
|
67
|
+
when Array
|
|
68
|
+
return TOO_DEEP if depth >= MAX_DEPTH
|
|
69
|
+
|
|
70
|
+
obj.map { |value| scrub_deep(value, depth + 1) }
|
|
71
|
+
else
|
|
72
|
+
obj
|
|
73
|
+
end
|
|
74
|
+
rescue => e
|
|
75
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EncodingSanitizer.scrub_deep failed: #{e.class}")
|
|
76
|
+
String === obj ? UNREADABLE : obj
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|