rails_error_dashboard 0.9.1 → 0.10.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/errors_controller.rb +6 -0
- data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
- data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
- data/app/models/rails_error_dashboard/rack_attack_event.rb +6 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +55 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +7 -12
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
- data/config/locales/de.yml +5 -0
- data/config/locales/en.yml +7 -0
- data/config/locales/es.yml +5 -0
- data/config/locales/fr.yml +5 -0
- data/config/locales/it.yml +5 -0
- data/config/locales/ja.yml +5 -0
- data/config/locales/pl.yml +5 -0
- data/config/locales/pt-BR.yml +5 -0
- data/config/locales/ru.yml +5 -0
- data/config/locales/uk.yml +5 -0
- data/config/locales/zh-CN.yml +5 -0
- data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
- data/lib/rails_error_dashboard/engine.rb +7 -0
- data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
- data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
- data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
- data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7065559d38e3152511024cd4a44698c3b215cc6cd60e4917d52cf4d893180b6c
|
|
4
|
+
data.tar.gz: 0d9237d4e89f4cb08392aecfe109dbb38bc1aaf673e834d827441bebf84a6e3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 996b72e8406365ca4ee064464faa785b7a43dfa5f643ed9f2ca3be39d4d98b3dd13ed6426a1dbef3eb53fad3d6433d89b70b3f8a3d8e40434aad1e882cff0eb1
|
|
7
|
+
data.tar.gz: 4814ed8949397ce5ca0cd27cf4e9f52f5ba2e5bf243eff411eb0e7c80539fef978c0dc87505c02bb0e2a259725a8fec97a5897c62073eae2a9142c0f0b2c2ea8
|
|
@@ -535,6 +535,12 @@ module RailsErrorDashboard
|
|
|
535
535
|
@unique_rules = all_events.size
|
|
536
536
|
@total_events = all_events.sum { |e| e[:count] }
|
|
537
537
|
@unique_ips = all_events.flat_map { |e| e[:ips] }.uniq.size
|
|
538
|
+
# Events matched by a recognised AI agent. Counts requests, not addresses —
|
|
539
|
+
# one agent rotates through many IPs, so unique_ips overstates it (#170).
|
|
540
|
+
@ai_events = all_events.sum { |e| e[:ai_count].to_i }
|
|
541
|
+
# Counts the tracker's LRU eviction dropped; shown so the totals above are
|
|
542
|
+
# never silently understated.
|
|
543
|
+
@overflow_count = result[:overflow_count].to_i
|
|
538
544
|
|
|
539
545
|
@pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
|
|
540
546
|
end
|
|
@@ -150,5 +150,31 @@ module RailsErrorDashboard
|
|
|
150
150
|
rescue StandardError
|
|
151
151
|
"%B %d, %Y %I:%M:%S %p"
|
|
152
152
|
end
|
|
153
|
+
|
|
154
|
+
# A finished, localized date string for somewhere the browser will not
|
|
155
|
+
# re-render it.
|
|
156
|
+
#
|
|
157
|
+
# Almost every timestamp on the dashboard goes out as a <span data-format>
|
|
158
|
+
# that formatDateTime() localizes client-side. Chart labels cannot: they
|
|
159
|
+
# are serialized into a JS array and handed to Chart.js as plain strings,
|
|
160
|
+
# so whatever the server writes is what the axis shows. Calling strftime
|
|
161
|
+
# directly there is what put English month names on every locale's charts
|
|
162
|
+
# (#178) — Ruby's strftime is not locale-aware.
|
|
163
|
+
#
|
|
164
|
+
# Same formatter the mailers and the Slack/Discord payloads use, so a chart
|
|
165
|
+
# axis and an email agree about what a date looks like.
|
|
166
|
+
#
|
|
167
|
+
# @param time [Time, Date, nil]
|
|
168
|
+
# @param pattern [String] a strftime pattern
|
|
169
|
+
# @return [String] "" for nil — an axis label must never read "undefined"
|
|
170
|
+
def red_chart_date(time, pattern)
|
|
171
|
+
return "" if time.nil?
|
|
172
|
+
|
|
173
|
+
time = time.to_time if time.respond_to?(:to_time) && !time.is_a?(Time)
|
|
174
|
+
Services::LocalizedTimeFormatter.call(time, pattern: pattern, locale: red_locale)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
# A chart with an English axis beats a chart that fails to render.
|
|
177
|
+
time.respond_to?(:strftime) ? time.strftime(pattern) : time.to_s
|
|
178
|
+
end
|
|
153
179
|
end
|
|
154
180
|
end
|
|
@@ -22,9 +22,13 @@ module RailsErrorDashboard
|
|
|
22
22
|
# Mode 1: Persist provided snapshot (dispatched from tracker flush)
|
|
23
23
|
Commands::FlushRackAttackEvents.call(counts: counts)
|
|
24
24
|
else
|
|
25
|
-
# Mode 2: Flush
|
|
26
|
-
#
|
|
27
|
-
|
|
25
|
+
# Mode 2: Flush EVERY live thread's buffer (scheduled cron safety net).
|
|
26
|
+
#
|
|
27
|
+
# This used to call flush!, which only ever sees Thread.current — the
|
|
28
|
+
# job worker's own buffer, which is always empty. The counts live on the
|
|
29
|
+
# Puma threads that served the requests, so the documented safety net
|
|
30
|
+
# swept nothing. flush_all_threads! is what makes the promise true.
|
|
31
|
+
Services::RackAttackTracker.flush_all_threads!
|
|
28
32
|
end
|
|
29
33
|
end
|
|
30
34
|
end
|
|
@@ -12,8 +12,12 @@ module RailsErrorDashboard
|
|
|
12
12
|
class RackAttackEvent < ErrorLogsRecord
|
|
13
13
|
self.table_name = "rails_error_dashboard_rack_attack_events"
|
|
14
14
|
|
|
15
|
-
# Event types emitted by Rack::Attack (v5.0+)
|
|
16
|
-
|
|
15
|
+
# Event types emitted by Rack::Attack (v5.0+), plus "overflow" — not a
|
|
16
|
+
# Rack::Attack event at all, but a synthetic bucket holding counts dropped
|
|
17
|
+
# by the tracker's LRU eviction so totals stay truthful. Overflow rows are
|
|
18
|
+
# excluded from the per-rule listing and surfaced separately.
|
|
19
|
+
MATCH_TYPES = %w[throttle blocklist track safelist overflow].freeze
|
|
20
|
+
OVERFLOW_MATCH_TYPE = "overflow"
|
|
17
21
|
|
|
18
22
|
belongs_to :application, optional: true
|
|
19
23
|
|
|
@@ -1751,6 +1751,51 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1751
1751
|
.replace('%H',pad(h)).replace('%I',pad(h12)).replace('%M',pad(mi)).replace('%S',pad(s)).replace('%p',ampm).replace('%P',ampm.toLowerCase());
|
|
1752
1752
|
}
|
|
1753
1753
|
|
|
1754
|
+
// Chart.js renders date axes through chartjs-adapter-date-fns, whose bundle
|
|
1755
|
+
// ships English locale data only. Loading eleven date-fns locales from a CDN
|
|
1756
|
+
// to fix that would be absurd when RED already carries localized month and
|
|
1757
|
+
// day names in red.js.* for formatDateTime(). So the adapter's format() is
|
|
1758
|
+
// redirected through the same vocabulary the rest of the dashboard uses, and
|
|
1759
|
+
// a chart axis, a page timestamp and an email now agree.
|
|
1760
|
+
//
|
|
1761
|
+
// Only the tokens the adapter's own format table can emit are mapped:
|
|
1762
|
+
// datetime "MMM d, yyyy, h:mm:ss aaaa" millisecond "h:mm:ss.SSS aaaa"
|
|
1763
|
+
// second "h:mm:ss aaaa" minute "h:mm aaaa" hour "ha" day "MMM d"
|
|
1764
|
+
// week "PP" month "MMM yyyy" quarter "qqq - yyyy" year "yyyy"
|
|
1765
|
+
// Anything else — a caller-supplied pattern, a future adapter version — is
|
|
1766
|
+
// handed back to the original implementation rather than half-translated.
|
|
1767
|
+
function installRedChartDateAdapter() {
|
|
1768
|
+
if (typeof Chart === 'undefined' || !Chart._adapters || !Chart._adapters._date) return;
|
|
1769
|
+
var proto = Chart._adapters._date.prototype;
|
|
1770
|
+
if (!proto || typeof proto.format !== 'function' || proto._redPatched) return;
|
|
1771
|
+
|
|
1772
|
+
// date-fns token -> strftime, for the patterns the adapter actually uses.
|
|
1773
|
+
var TOKENS = {
|
|
1774
|
+
'MMM d, yyyy, h:mm:ss aaaa': '%b %d, %Y, %I:%M:%S %p',
|
|
1775
|
+
'h:mm:ss.SSS aaaa': '%I:%M:%S %p',
|
|
1776
|
+
'h:mm:ss aaaa': '%I:%M:%S %p',
|
|
1777
|
+
'h:mm aaaa': '%I:%M %p',
|
|
1778
|
+
'ha': '%I %p',
|
|
1779
|
+
'MMM d': '%b %d',
|
|
1780
|
+
'PP': '%b %d, %Y',
|
|
1781
|
+
'MMM yyyy': '%b %Y',
|
|
1782
|
+
'yyyy': '%Y'
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
var original = proto.format;
|
|
1786
|
+
proto.format = function(time, fmt) {
|
|
1787
|
+
try {
|
|
1788
|
+
var strf = TOKENS[fmt];
|
|
1789
|
+
if (strf) return formatDateTime(new Date(time), strf);
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
// Fall through — a chart with English axes beats a chart that throws.
|
|
1792
|
+
}
|
|
1793
|
+
return original.call(this, time, fmt);
|
|
1794
|
+
};
|
|
1795
|
+
proto._redPatched = true;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
|
|
1754
1799
|
function getTimezoneAbbreviation(date) {
|
|
1755
1800
|
// RED's locale set and the browser's Intl data need not agree, so an
|
|
1756
1801
|
// unsupported tag throws RangeError here rather than degrading. Catch it
|
|
@@ -1801,6 +1846,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|
|
1801
1846
|
}
|
|
1802
1847
|
|
|
1803
1848
|
convertToLocalTime();
|
|
1849
|
+
|
|
1850
|
+
// Localize Chart.js date axes. Kept out of the block above on purpose: that
|
|
1851
|
+
// region is extracted and run in bare node by js_date_parity_spec, where
|
|
1852
|
+
// `document` is a stub, so it must contain definitions only — no top-level
|
|
1853
|
+
// browser calls. The adapter is patched on the prototype, so charts drawn
|
|
1854
|
+
// later pick it up; chartkick:load re-asserts for page scripts whose own
|
|
1855
|
+
// DOMContentLoaded handler beat this one, and _redPatched makes that a no-op.
|
|
1856
|
+
installRedChartDateAdapter();
|
|
1857
|
+
document.addEventListener('chartkick:load', installRedChartDateAdapter);
|
|
1858
|
+
|
|
1804
1859
|
if (typeof Turbo !== 'undefined') {
|
|
1805
1860
|
document.addEventListener('turbo:load', convertToLocalTime);
|
|
1806
1861
|
document.addEventListener('turbo:frame-load', convertToLocalTime);
|
|
@@ -75,8 +75,6 @@
|
|
|
75
75
|
curve: false,
|
|
76
76
|
points: true,
|
|
77
77
|
height: "300px",
|
|
78
|
-
xtitle: "Date",
|
|
79
|
-
ytitle: "Number of Errors",
|
|
80
78
|
library: {
|
|
81
79
|
scales: {
|
|
82
80
|
x: {
|
|
@@ -172,18 +170,19 @@
|
|
|
172
170
|
new Chartkick.BarChart("errors-by-type-chart", <%= raw @errors_by_type.to_json %>, {
|
|
173
171
|
color: "#EF4444",
|
|
174
172
|
height: "400px",
|
|
175
|
-
xtitle: "Error Type",
|
|
176
|
-
ytitle: "Count",
|
|
177
173
|
library: {
|
|
178
174
|
scales: {
|
|
175
|
+
// Chartkick's BarChart is horizontal (it sets indexAxis:"y"),
|
|
176
|
+
// so the categories sit on y and the counts on x — the
|
|
177
|
+
// opposite of the ColumnCharts on this page. #178.
|
|
179
178
|
x: {
|
|
180
179
|
ticks: { color: colors.textColor },
|
|
181
|
-
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.
|
|
180
|
+
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_count") %>' },
|
|
182
181
|
grid: { color: colors.gridColor }
|
|
183
182
|
},
|
|
184
183
|
y: {
|
|
185
184
|
ticks: { color: colors.textColor },
|
|
186
|
-
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.
|
|
185
|
+
title: { color: colors.textColor, display: true, text: '<%= red_js_t("red.ui_js.charts.axis_error_type") %>' },
|
|
187
186
|
grid: { color: colors.gridColor }
|
|
188
187
|
}
|
|
189
188
|
},
|
|
@@ -214,8 +213,6 @@
|
|
|
214
213
|
new Chartkick.ColumnChart("errors-by-hour-chart", <%= raw @errors_by_hour.to_json %>, {
|
|
215
214
|
color: "#DC2626",
|
|
216
215
|
height: "300px",
|
|
217
|
-
xtitle: "Hour",
|
|
218
|
-
ytitle: "Number of Errors",
|
|
219
216
|
library: {
|
|
220
217
|
scales: {
|
|
221
218
|
x: {
|
|
@@ -422,8 +419,6 @@
|
|
|
422
419
|
new Chartkick.ColumnChart("errors-by-version-chart", versionData, {
|
|
423
420
|
colors: ["#8B5CF6", "#EF4444", "#F59E0B", "#10B981"],
|
|
424
421
|
height: "300px",
|
|
425
|
-
xtitle: "Version",
|
|
426
|
-
ytitle: "Error Count",
|
|
427
422
|
library: window.getChartLibraryOptions()
|
|
428
423
|
});
|
|
429
424
|
});
|
|
@@ -558,7 +553,7 @@
|
|
|
558
553
|
const colors = window.getChartColors();
|
|
559
554
|
new Chartkick.BarChart("mttr-by-platform-chart",
|
|
560
555
|
<%= raw @mttr_by_platform.to_json %>, {
|
|
561
|
-
suffix: "
|
|
556
|
+
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
562
557
|
height: "200px",
|
|
563
558
|
colors: ["#8B5CF6"],
|
|
564
559
|
library: window.getChartLibraryOptions()
|
|
@@ -611,7 +606,7 @@
|
|
|
611
606
|
const colors = window.getChartColors();
|
|
612
607
|
new Chartkick.LineChart("mttr-trend-chart",
|
|
613
608
|
<%= raw @mttr_stats[:mttr_trend].to_json %>, {
|
|
614
|
-
suffix: "
|
|
609
|
+
suffix: '<%= red_js_t("red.ui_js.charts.suffix_hours") %>',
|
|
615
610
|
height: "250px",
|
|
616
611
|
colors: ["#10B981"],
|
|
617
612
|
curve: false,
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
<div class="display-6"><%= @period_comparison[:current_period][:count] %></div>
|
|
37
37
|
<small class="text-muted">
|
|
38
38
|
<%= red_t("red.analytics.correlation.trend_analysis.period_range",
|
|
39
|
-
start: @period_comparison[:current_period][:start]
|
|
40
|
-
end: @period_comparison[:current_period][:end]
|
|
39
|
+
start: red_chart_date(@period_comparison[:current_period][:start], "%b %d"),
|
|
40
|
+
end: red_chart_date(@period_comparison[:current_period][:end], "%b %d")) %>
|
|
41
41
|
</small>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="col-md-4">
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
<div class="display-6"><%= @period_comparison[:previous_period][:count] %></div>
|
|
46
46
|
<small class="text-muted">
|
|
47
47
|
<%= red_t("red.analytics.correlation.trend_analysis.period_range",
|
|
48
|
-
start: @period_comparison[:previous_period][:start]
|
|
49
|
-
end: @period_comparison[:previous_period][:end]
|
|
48
|
+
start: red_chart_date(@period_comparison[:previous_period][:start], "%b %d"),
|
|
49
|
+
end: red_chart_date(@period_comparison[:previous_period][:end], "%b %d")) %>
|
|
50
50
|
</small>
|
|
51
51
|
</div>
|
|
52
52
|
<div class="col-md-4">
|
|
@@ -358,7 +358,7 @@
|
|
|
358
358
|
new Chart(dailyTrendCtx, {
|
|
359
359
|
type: 'line',
|
|
360
360
|
data: {
|
|
361
|
-
labels: <%= raw @daily_trends.values.first&.keys&.map { |d| d
|
|
361
|
+
labels: <%= raw @daily_trends.values.first&.keys&.map { |d| red_chart_date(d, '%b %d') }&.to_json || [].to_json %>,
|
|
362
362
|
datasets: datasets
|
|
363
363
|
},
|
|
364
364
|
options: {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
<% else %>
|
|
51
51
|
<div class="row mb-4">
|
|
52
|
-
<div class="col-md-
|
|
52
|
+
<div class="col-md-3">
|
|
53
53
|
<div class="card text-center">
|
|
54
54
|
<div class="card-body">
|
|
55
55
|
<div class="display-6 text-warning"><%= @unique_rules %></div>
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
</div>
|
|
58
58
|
</div>
|
|
59
59
|
</div>
|
|
60
|
-
<div class="col-md-
|
|
60
|
+
<div class="col-md-3">
|
|
61
61
|
<div class="card text-center">
|
|
62
62
|
<div class="card-body">
|
|
63
63
|
<div class="display-6 text-danger"><%= @total_events %></div>
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
</div>
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
|
-
<div class="col-md-
|
|
68
|
+
<div class="col-md-3">
|
|
69
69
|
<div class="card text-center">
|
|
70
70
|
<div class="card-body">
|
|
71
71
|
<div class="display-6 text-info"><%= @unique_ips %></div>
|
|
@@ -73,8 +73,27 @@
|
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
75
|
</div>
|
|
76
|
+
<div class="col-md-3">
|
|
77
|
+
<div class="card text-center">
|
|
78
|
+
<div class="card-body">
|
|
79
|
+
<div class="display-6 text-primary"><%= @ai_events %></div>
|
|
80
|
+
<small class="text-muted"><%= red_t("red.errors.rack_attack_page.ai_events") %></small>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
76
84
|
</div>
|
|
77
85
|
|
|
86
|
+
<% if @overflow_count.to_i.positive? %>
|
|
87
|
+
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
|
88
|
+
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
89
|
+
<div>
|
|
90
|
+
<%= red_t("red.errors.rack_attack_page.overflow_notice_html",
|
|
91
|
+
count: @overflow_count,
|
|
92
|
+
option: content_tag(:code, "rack_attack_max_cache_size")) %>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<% end %>
|
|
96
|
+
|
|
78
97
|
<div class="card mb-4">
|
|
79
98
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
80
99
|
<h5 class="mb-0">
|
|
@@ -94,6 +113,7 @@
|
|
|
94
113
|
<th width="80"><%= red_t("red.errors.rack_attack_page.column_count") %></th>
|
|
95
114
|
<th width="80"><%= red_t("red.errors.rack_attack_page.column_ips") %></th>
|
|
96
115
|
<th><%= red_t("red.errors.rack_attack_page.column_top_path") %></th>
|
|
116
|
+
<th width="160"><%= red_t("red.errors.rack_attack_page.column_top_agent") %></th>
|
|
97
117
|
<th width="140"><%= red_t("red.errors.pages.column_last_seen") %></th>
|
|
98
118
|
</tr>
|
|
99
119
|
</thead>
|
|
@@ -115,6 +135,19 @@
|
|
|
115
135
|
<td><strong><%= event[:count] %></strong></td>
|
|
116
136
|
<td><%= event[:unique_ips] %></td>
|
|
117
137
|
<td><code><%= event[:top_path] %></code></td>
|
|
138
|
+
<td>
|
|
139
|
+
<% if event[:top_agent].present? %>
|
|
140
|
+
<%# AI agents are highlighted because "which agent" is the
|
|
141
|
+
question IP counts cannot answer — one agent is a fleet. %>
|
|
142
|
+
<span class="badge <%= event[:ai_count].to_i.positive? ? "bg-primary" : "bg-light text-dark" %>"
|
|
143
|
+
title="<%= event[:top_agent] %>"><%= truncate(event[:top_agent], length: 22) %></span>
|
|
144
|
+
<% if event[:unique_agents].to_i > 1 %>
|
|
145
|
+
<small class="text-muted">+<%= event[:unique_agents].to_i - 1 %></small>
|
|
146
|
+
<% end %>
|
|
147
|
+
<% else %>
|
|
148
|
+
<span class="text-muted">—</span>
|
|
149
|
+
<% end %>
|
|
150
|
+
</td>
|
|
118
151
|
<td><%= local_time_ago(event[:last_seen]) %></td>
|
|
119
152
|
</tr>
|
|
120
153
|
<% end %>
|
data/config/locales/de.yml
CHANGED
|
@@ -833,12 +833,16 @@ de:
|
|
|
833
833
|
unique_rules: Eindeutige Regeln
|
|
834
834
|
total_events: Ereignisse gesamt
|
|
835
835
|
unique_ips: Eindeutige IPs
|
|
836
|
+
ai_events: KI-Agent-Anfragen
|
|
836
837
|
all_title: Rate-Limit-Ereignisse nach Regel
|
|
837
838
|
column_rule: Regel
|
|
838
839
|
column_type: Typ
|
|
839
840
|
column_count: Anzahl
|
|
840
841
|
column_ips: IPs
|
|
841
842
|
column_top_path: Häufigster Pfad
|
|
843
|
+
column_top_agent: Häufigster Agent
|
|
844
|
+
overflow_notice_html: >-
|
|
845
|
+
%{count} Ereignisse konnten keiner Regel zugeordnet werden, da der Puffer des Trackers voll war. Erhöhen Sie %{option}, um mehr zu erfassen.
|
|
842
846
|
match_type:
|
|
843
847
|
blocklist: blocklist
|
|
844
848
|
throttle: throttle
|
|
@@ -2003,5 +2007,6 @@ de:
|
|
|
2003
2007
|
axis_count: Anzahl
|
|
2004
2008
|
axis_number_of_errors: Anzahl der Fehler
|
|
2005
2009
|
axis_hours: Stunden
|
|
2010
|
+
suffix_hours: " Stunden"
|
|
2006
2011
|
series_total_errors: Fehler gesamt
|
|
2007
2012
|
series_hours_to_resolve: Stunden bis zur Behebung
|
data/config/locales/en.yml
CHANGED
|
@@ -1172,12 +1172,16 @@ en:
|
|
|
1172
1172
|
unique_rules: "Unique Rules"
|
|
1173
1173
|
total_events: "Total Events"
|
|
1174
1174
|
unique_ips: "Unique IPs"
|
|
1175
|
+
ai_events: "AI Agent Requests"
|
|
1175
1176
|
all_title: "Rate Limit Events by Rule"
|
|
1176
1177
|
column_rule: "Rule"
|
|
1177
1178
|
column_type: "Type"
|
|
1178
1179
|
column_count: "Count"
|
|
1179
1180
|
column_ips: "IPs"
|
|
1180
1181
|
column_top_path: "Top Path"
|
|
1182
|
+
column_top_agent: "Top Agent"
|
|
1183
|
+
overflow_notice_html: >-
|
|
1184
|
+
%{count} events could not be attributed to a rule because the tracker's buffer was full. Raise %{option} to capture more.
|
|
1181
1185
|
# Rack Attack match types. These are machine values the view also
|
|
1182
1186
|
# compares by string equality, so the display is a lookup and the
|
|
1183
1187
|
# comparison keeps the English literal.
|
|
@@ -2715,5 +2719,8 @@ en:
|
|
|
2715
2719
|
axis_count: "Count"
|
|
2716
2720
|
axis_number_of_errors: "Number of Errors"
|
|
2717
2721
|
axis_hours: "Hours"
|
|
2722
|
+
# Chartkick concatenates this after a number on the MTTR charts, so it
|
|
2723
|
+
# carries its own leading space and is a bare unit, not a sentence.
|
|
2724
|
+
suffix_hours: " hours"
|
|
2718
2725
|
series_total_errors: "Total Errors"
|
|
2719
2726
|
series_hours_to_resolve: "Hours to Resolve"
|
data/config/locales/es.yml
CHANGED
|
@@ -862,12 +862,16 @@ es:
|
|
|
862
862
|
unique_rules: Reglas únicas
|
|
863
863
|
total_events: Total de eventos
|
|
864
864
|
unique_ips: IP únicas
|
|
865
|
+
ai_events: Solicitudes de agentes de IA
|
|
865
866
|
all_title: Eventos de límite de tasa por regla
|
|
866
867
|
column_rule: Regla
|
|
867
868
|
column_type: Tipo
|
|
868
869
|
column_count: Cantidad
|
|
869
870
|
column_ips: IP
|
|
870
871
|
column_top_path: Ruta principal
|
|
872
|
+
column_top_agent: Agente principal
|
|
873
|
+
overflow_notice_html: >-
|
|
874
|
+
No se pudieron atribuir %{count} eventos a ninguna regla porque el búfer del rastreador estaba lleno. Aumente %{option} para capturar más.
|
|
871
875
|
match_type:
|
|
872
876
|
blocklist: blocklist
|
|
873
877
|
throttle: throttle
|
|
@@ -2075,5 +2079,6 @@ es:
|
|
|
2075
2079
|
axis_count: Cantidad
|
|
2076
2080
|
axis_number_of_errors: Número de errores
|
|
2077
2081
|
axis_hours: Horas
|
|
2082
|
+
suffix_hours: " horas"
|
|
2078
2083
|
series_total_errors: Total de errores
|
|
2079
2084
|
series_hours_to_resolve: Horas hasta la resolución
|
data/config/locales/fr.yml
CHANGED
|
@@ -861,12 +861,16 @@ fr:
|
|
|
861
861
|
unique_rules: Règles uniques
|
|
862
862
|
total_events: Total des événements
|
|
863
863
|
unique_ips: IP uniques
|
|
864
|
+
ai_events: Requêtes d'agents IA
|
|
864
865
|
all_title: Événements de limitation de débit par règle
|
|
865
866
|
column_rule: Règle
|
|
866
867
|
column_type: Type
|
|
867
868
|
column_count: Nombre
|
|
868
869
|
column_ips: IP
|
|
869
870
|
column_top_path: Chemin principal
|
|
871
|
+
column_top_agent: Agent principal
|
|
872
|
+
overflow_notice_html: >-
|
|
873
|
+
%{count} événements n'ont pas pu être attribués à une règle car la mémoire tampon du traceur était pleine. Augmentez %{option} pour en capturer davantage.
|
|
870
874
|
match_type:
|
|
871
875
|
blocklist: blocklist
|
|
872
876
|
throttle: throttle
|
|
@@ -2078,5 +2082,6 @@ fr:
|
|
|
2078
2082
|
axis_count: Nombre
|
|
2079
2083
|
axis_number_of_errors: Nombre d'erreurs
|
|
2080
2084
|
axis_hours: Heures
|
|
2085
|
+
suffix_hours: " heures"
|
|
2081
2086
|
series_total_errors: Total des erreurs
|
|
2082
2087
|
series_hours_to_resolve: Heures avant résolution
|
data/config/locales/it.yml
CHANGED
|
@@ -847,12 +847,16 @@ it:
|
|
|
847
847
|
unique_rules: Regole uniche
|
|
848
848
|
total_events: Eventi totali
|
|
849
849
|
unique_ips: IP unici
|
|
850
|
+
ai_events: Richieste di agenti IA
|
|
850
851
|
all_title: Eventi di limite di richieste per regola
|
|
851
852
|
column_rule: Regola
|
|
852
853
|
column_type: Tipo
|
|
853
854
|
column_count: Conteggio
|
|
854
855
|
column_ips: Indirizzi IP
|
|
855
856
|
column_top_path: Percorso principale
|
|
857
|
+
column_top_agent: Agente principale
|
|
858
|
+
overflow_notice_html: >-
|
|
859
|
+
%{count} eventi non sono stati attribuiti a nessuna regola perché il buffer del tracker era pieno. Aumenta %{option} per acquisirne di più.
|
|
856
860
|
match_type:
|
|
857
861
|
blocklist: blocklist
|
|
858
862
|
throttle: throttle
|
|
@@ -2056,5 +2060,6 @@ it:
|
|
|
2056
2060
|
axis_count: Conteggio
|
|
2057
2061
|
axis_number_of_errors: Numero di errori
|
|
2058
2062
|
axis_hours: Ore
|
|
2063
|
+
suffix_hours: " ore"
|
|
2059
2064
|
series_total_errors: Errori totali
|
|
2060
2065
|
series_hours_to_resolve: Ore per la risoluzione
|
data/config/locales/ja.yml
CHANGED
|
@@ -755,12 +755,16 @@ ja:
|
|
|
755
755
|
unique_rules: ルール数
|
|
756
756
|
total_events: 総イベント数
|
|
757
757
|
unique_ips: ユニーク IP 数
|
|
758
|
+
ai_events: AI エージェントのリクエスト数
|
|
758
759
|
all_title: ルール別レート制限イベント
|
|
759
760
|
column_rule: ルール
|
|
760
761
|
column_type: 種類
|
|
761
762
|
column_count: 件数
|
|
762
763
|
column_ips: IP 数
|
|
763
764
|
column_top_path: 上位パス
|
|
765
|
+
column_top_agent: 主要エージェント
|
|
766
|
+
overflow_notice_html: >-
|
|
767
|
+
トラッカーのバッファが満杯だったため、%{count} 件のイベントをルールに割り当てられませんでした。より多く記録するには %{option} を増やしてください。
|
|
764
768
|
match_type:
|
|
765
769
|
blocklist: blocklist
|
|
766
770
|
throttle: throttle
|
|
@@ -1798,5 +1802,6 @@ ja:
|
|
|
1798
1802
|
axis_count: 件数
|
|
1799
1803
|
axis_number_of_errors: エラー件数
|
|
1800
1804
|
axis_hours: 時間
|
|
1805
|
+
suffix_hours: "時間"
|
|
1801
1806
|
series_total_errors: 総エラー数
|
|
1802
1807
|
series_hours_to_resolve: 解決までの時間
|
data/config/locales/pl.yml
CHANGED
|
@@ -869,12 +869,16 @@ pl:
|
|
|
869
869
|
unique_rules: Unikalne reguły
|
|
870
870
|
total_events: Łącznie zdarzeń
|
|
871
871
|
unique_ips: Unikalne adresy IP
|
|
872
|
+
ai_events: Żądania agentów AI
|
|
872
873
|
all_title: Zdarzenia limitu żądań wg reguły
|
|
873
874
|
column_rule: Reguła
|
|
874
875
|
column_type: Typ
|
|
875
876
|
column_count: Liczba
|
|
876
877
|
column_ips: Adresy IP
|
|
877
878
|
column_top_path: Najczęstsza ścieżka
|
|
879
|
+
column_top_agent: Główny agent
|
|
880
|
+
overflow_notice_html: >-
|
|
881
|
+
Nie udało się przypisać %{count} zdarzeń do żadnej reguły, ponieważ bufor mechanizmu śledzenia był pełny. Zwiększ %{option}, aby rejestrować więcej.
|
|
878
882
|
match_type:
|
|
879
883
|
blocklist: blocklist
|
|
880
884
|
throttle: throttle
|
|
@@ -2104,5 +2108,6 @@ pl:
|
|
|
2104
2108
|
axis_count: Liczba
|
|
2105
2109
|
axis_number_of_errors: Liczba błędów
|
|
2106
2110
|
axis_hours: Godziny
|
|
2111
|
+
suffix_hours: " godz."
|
|
2107
2112
|
series_total_errors: Łącznie błędów
|
|
2108
2113
|
series_hours_to_resolve: Godzin do rozwiązania
|
data/config/locales/pt-BR.yml
CHANGED
|
@@ -859,12 +859,16 @@ pt-BR:
|
|
|
859
859
|
unique_rules: Regras únicas
|
|
860
860
|
total_events: Total de eventos
|
|
861
861
|
unique_ips: IPs únicos
|
|
862
|
+
ai_events: Solicitações de agentes de IA
|
|
862
863
|
all_title: Eventos de limite de taxa por regra
|
|
863
864
|
column_rule: Regra
|
|
864
865
|
column_type: Tipo
|
|
865
866
|
column_count: Quantidade
|
|
866
867
|
column_ips: IPs
|
|
867
868
|
column_top_path: Caminho principal
|
|
869
|
+
column_top_agent: Agente principal
|
|
870
|
+
overflow_notice_html: >-
|
|
871
|
+
Não foi possível atribuir %{count} eventos a nenhuma regra porque o buffer do rastreador estava cheio. Aumente %{option} para capturar mais.
|
|
868
872
|
match_type:
|
|
869
873
|
blocklist: blocklist
|
|
870
874
|
throttle: throttle
|
|
@@ -2060,5 +2064,6 @@ pt-BR:
|
|
|
2060
2064
|
axis_count: Quantidade
|
|
2061
2065
|
axis_number_of_errors: Número de erros
|
|
2062
2066
|
axis_hours: Horas
|
|
2067
|
+
suffix_hours: " horas"
|
|
2063
2068
|
series_total_errors: Total de erros
|
|
2064
2069
|
series_hours_to_resolve: Horas até a resolução
|
data/config/locales/ru.yml
CHANGED
|
@@ -871,12 +871,16 @@ ru:
|
|
|
871
871
|
unique_rules: Уникальные правила
|
|
872
872
|
total_events: Всего событий
|
|
873
873
|
unique_ips: Уникальные IP
|
|
874
|
+
ai_events: Запросы ИИ-агентов
|
|
874
875
|
all_title: События ограничения частоты по правилам
|
|
875
876
|
column_rule: Правило
|
|
876
877
|
column_type: Тип
|
|
877
878
|
column_count: Количество
|
|
878
879
|
column_ips: IP-адреса
|
|
879
880
|
column_top_path: Частый путь
|
|
881
|
+
column_top_agent: Основной агент
|
|
882
|
+
overflow_notice_html: >-
|
|
883
|
+
Не удалось отнести %{count} событий ни к одному правилу, так как буфер трекера был заполнен. Увеличьте %{option}, чтобы записывать больше.
|
|
880
884
|
match_type:
|
|
881
885
|
blocklist: blocklist
|
|
882
886
|
throttle: throttle
|
|
@@ -2105,5 +2109,6 @@ ru:
|
|
|
2105
2109
|
axis_count: Количество
|
|
2106
2110
|
axis_number_of_errors: Количество ошибок
|
|
2107
2111
|
axis_hours: Часы
|
|
2112
|
+
suffix_hours: " ч"
|
|
2108
2113
|
series_total_errors: Всего ошибок
|
|
2109
2114
|
series_hours_to_resolve: Часов до решения
|
data/config/locales/uk.yml
CHANGED
|
@@ -869,12 +869,16 @@ uk:
|
|
|
869
869
|
unique_rules: Унікальні правила
|
|
870
870
|
total_events: Усього подій
|
|
871
871
|
unique_ips: Унікальні IP
|
|
872
|
+
ai_events: Запити ШІ-агентів
|
|
872
873
|
all_title: Події обмеження запитів за правилами
|
|
873
874
|
column_rule: Правило
|
|
874
875
|
column_type: Тип
|
|
875
876
|
column_count: Кількість
|
|
876
877
|
column_ips: IP-адреси
|
|
877
878
|
column_top_path: Найчастіший шлях
|
|
879
|
+
column_top_agent: Основний агент
|
|
880
|
+
overflow_notice_html: >-
|
|
881
|
+
Не вдалося віднести %{count} подій до жодного правила, оскільки буфер трекера був заповнений. Збільште %{option}, щоб записувати більше.
|
|
878
882
|
match_type:
|
|
879
883
|
blocklist: blocklist
|
|
880
884
|
throttle: throttle
|
|
@@ -2102,5 +2106,6 @@ uk:
|
|
|
2102
2106
|
axis_count: Кількість
|
|
2103
2107
|
axis_number_of_errors: Кількість помилок
|
|
2104
2108
|
axis_hours: Години
|
|
2109
|
+
suffix_hours: " год"
|
|
2105
2110
|
series_total_errors: Усього помилок
|
|
2106
2111
|
series_hours_to_resolve: Годин до вирішення
|
data/config/locales/zh-CN.yml
CHANGED
|
@@ -751,12 +751,16 @@ zh-CN:
|
|
|
751
751
|
unique_rules: 规则数
|
|
752
752
|
total_events: 事件总数
|
|
753
753
|
unique_ips: 独立 IP 数
|
|
754
|
+
ai_events: AI 代理请求数
|
|
754
755
|
all_title: 按规则查看限流事件
|
|
755
756
|
column_rule: 规则
|
|
756
757
|
column_type: 类型
|
|
757
758
|
column_count: 数量
|
|
758
759
|
column_ips: IP 地址
|
|
759
760
|
column_top_path: 主要路径
|
|
761
|
+
column_top_agent: 主要代理
|
|
762
|
+
overflow_notice_html: >-
|
|
763
|
+
由于跟踪器缓冲区已满,%{count} 个事件无法归属到任何规则。请调高 %{option} 以捕获更多事件。
|
|
760
764
|
match_type:
|
|
761
765
|
blocklist: blocklist
|
|
762
766
|
throttle: throttle
|
|
@@ -1787,5 +1791,6 @@ zh-CN:
|
|
|
1787
1791
|
axis_count: 数量
|
|
1788
1792
|
axis_number_of_errors: 错误数量
|
|
1789
1793
|
axis_hours: 小时
|
|
1794
|
+
suffix_hours: "小时"
|
|
1790
1795
|
series_total_errors: 错误总数
|
|
1791
1796
|
series_hours_to_resolve: 解决耗时(小时)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddUserAgentToRackAttackEvents < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
# Guard against the squashed schema migration having already added this
|
|
6
|
+
# column — without it, every later migration is silently cancelled.
|
|
7
|
+
return if column_exists?(:rails_error_dashboard_rack_attack_events, :user_agent)
|
|
8
|
+
|
|
9
|
+
# Capped at 191 to match the other free-text columns on this table.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately NOT added to index_rack_attack_events_upsert_key. That index
|
|
12
|
+
# is already budgeted at 250+50+191+191 chars = 2736 bytes against MySQL's
|
|
13
|
+
# 3072-byte utf8mb4 limit (see the create migration); a fourth 191-char
|
|
14
|
+
# column would blow it. User agents are also extremely high cardinality, so
|
|
15
|
+
# indexing them would fragment the hourly buckets this table exists to
|
|
16
|
+
# aggregate. It is stored first-write-wins per bucket, like http_method.
|
|
17
|
+
add_column :rails_error_dashboard_rack_attack_events, :user_agent, :string, limit: 191
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -348,7 +348,19 @@ module RailsErrorDashboard
|
|
|
348
348
|
end
|
|
349
349
|
end
|
|
350
350
|
|
|
351
|
+
# Start after the highest timestamp already present, not at the current
|
|
352
|
+
# clock. The counter increments per copied file, so a re-run that happens
|
|
353
|
+
# within N seconds of the previous install (N = number of migrations)
|
|
354
|
+
# would otherwise reuse numbers the first install already consumed —
|
|
355
|
+
# and Rails aborts the whole `db:migrate` with "Multiple migrations have
|
|
356
|
+
# the version number ...", so the upgrade silently applies nothing.
|
|
351
357
|
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
|
358
|
+
highest_existing = [ "db/migrate", "db/error_dashboard_migrate" ].flat_map do |dir|
|
|
359
|
+
full_path = File.join(destination_root, dir)
|
|
360
|
+
next [] unless Dir.exist?(full_path)
|
|
361
|
+
Dir.glob(File.join(full_path, "*.rb")).map { |f| File.basename(f)[/^\d+/].to_i }
|
|
362
|
+
end.max
|
|
363
|
+
timestamp = highest_existing + 1 if highest_existing && highest_existing >= timestamp
|
|
352
364
|
|
|
353
365
|
Dir.glob(File.join(source_dir, "*.rb")).sort.each do |source_file|
|
|
354
366
|
basename = File.basename(source_file)
|
|
@@ -8,7 +8,10 @@ module RailsErrorDashboard
|
|
|
8
8
|
# hourly-bucketed rows. Uses find_or_initialize_by + increment for
|
|
9
9
|
# cross-database compatibility (no raw SQL upsert).
|
|
10
10
|
#
|
|
11
|
-
# counts keys: "rule\x1Fmatch_type\x1Fdiscriminator\x1Fpath\x1Fhttp_method"
|
|
11
|
+
# counts keys: "rule\x1Fmatch_type\x1Fdiscriminator\x1Fpath\x1Fhttp_method\x1Fuser_agent"
|
|
12
|
+
#
|
|
13
|
+
# http_method and user_agent are carried on the key but are NOT part of the
|
|
14
|
+
# row's identity — see upsert_event.
|
|
12
15
|
class FlushRackAttackEvents
|
|
13
16
|
def self.call(counts:)
|
|
14
17
|
new(counts: counts).call
|
|
@@ -25,7 +28,7 @@ module RailsErrorDashboard
|
|
|
25
28
|
app_id = current_application_id
|
|
26
29
|
|
|
27
30
|
@counts.each do |key, count|
|
|
28
|
-
rule, match_type, discriminator, path, http_method =
|
|
31
|
+
rule, match_type, discriminator, path, http_method, user_agent =
|
|
29
32
|
Services::RackAttackTracker.parse_key(key)
|
|
30
33
|
|
|
31
34
|
next if rule.blank? || match_type.blank?
|
|
@@ -36,6 +39,7 @@ module RailsErrorDashboard
|
|
|
36
39
|
discriminator: discriminator,
|
|
37
40
|
path: path,
|
|
38
41
|
http_method: http_method,
|
|
42
|
+
user_agent: user_agent,
|
|
39
43
|
period: period,
|
|
40
44
|
app_id: app_id,
|
|
41
45
|
count: count
|
|
@@ -49,7 +53,8 @@ module RailsErrorDashboard
|
|
|
49
53
|
|
|
50
54
|
private
|
|
51
55
|
|
|
52
|
-
def upsert_event(rule:, match_type:, discriminator:, path:, http_method:,
|
|
56
|
+
def upsert_event(rule:, match_type:, discriminator:, path:, http_method:, user_agent:,
|
|
57
|
+
period:, app_id:, count:)
|
|
53
58
|
# nil and "" must map to the same row — the unique index treats them as
|
|
54
59
|
# distinct in some adapters, so normalize blanks to nil consistently.
|
|
55
60
|
record = RackAttackEvent.find_or_initialize_by(
|
|
@@ -61,7 +66,11 @@ module RailsErrorDashboard
|
|
|
61
66
|
application_id: app_id
|
|
62
67
|
)
|
|
63
68
|
|
|
69
|
+
# http_method and user_agent are deliberately NOT part of the upsert key
|
|
70
|
+
# (the unique index is already at 2736 of MySQL's 3072 bytes), so they
|
|
71
|
+
# are first-write-wins attributes of the bucket rather than identity.
|
|
64
72
|
record.http_method = http_method.presence if record.http_method.blank?
|
|
73
|
+
record.user_agent = user_agent.presence if record.user_agent.blank?
|
|
65
74
|
record.event_count = (record.event_count || 0) + count
|
|
66
75
|
record.last_seen_at = Time.current
|
|
67
76
|
record.save!
|
|
@@ -85,6 +85,13 @@ module RailsErrorDashboard
|
|
|
85
85
|
if RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
|
|
86
86
|
defined?(Rack::Attack)
|
|
87
87
|
RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
|
|
88
|
+
|
|
89
|
+
# Buffered counts live on the Puma threads that served the requests and
|
|
90
|
+
# are only written out on the flush interval, which a low-traffic rule
|
|
91
|
+
# may never reach. Without this, everything still buffered at SIGTERM
|
|
92
|
+
# (every deploy) is lost. at_exit, not Signal.trap — trapping would
|
|
93
|
+
# clobber Puma's USR1/USR2 handlers (safety rule 9).
|
|
94
|
+
at_exit { RailsErrorDashboard::Services::RackAttackTracker.flush_all_threads! }
|
|
88
95
|
end
|
|
89
96
|
|
|
90
97
|
# Subscribe to ActionCable AS::Notifications events (requires breadcrumbs + ActionCable)
|
|
@@ -24,7 +24,8 @@ module RailsErrorDashboard
|
|
|
24
24
|
|
|
25
25
|
def call
|
|
26
26
|
{
|
|
27
|
-
events: aggregated_events
|
|
27
|
+
events: aggregated_events,
|
|
28
|
+
overflow_count: overflow_count
|
|
28
29
|
}
|
|
29
30
|
end
|
|
30
31
|
|
|
@@ -36,15 +37,27 @@ module RailsErrorDashboard
|
|
|
36
37
|
scope
|
|
37
38
|
end
|
|
38
39
|
|
|
40
|
+
# Counts dropped by the tracker's LRU eviction, kept out of the per-rule
|
|
41
|
+
# listing (they belong to no single rule) but reported so the dashboard
|
|
42
|
+
# never silently under-states volume.
|
|
43
|
+
def overflow_count
|
|
44
|
+
base_query.where(match_type: RackAttackEvent::OVERFLOW_MATCH_TYPE).sum(:event_count).to_i
|
|
45
|
+
rescue => e
|
|
46
|
+
0
|
|
47
|
+
end
|
|
48
|
+
|
|
39
49
|
def aggregated_events
|
|
40
|
-
rows = base_query
|
|
41
|
-
|
|
42
|
-
|
|
50
|
+
rows = base_query
|
|
51
|
+
.where.not(match_type: RackAttackEvent::OVERFLOW_MATCH_TYPE)
|
|
52
|
+
.pluck(
|
|
53
|
+
:rule, :match_type, :discriminator, :path, :event_count, :last_seen_at,
|
|
54
|
+
:period_hour, :user_agent
|
|
55
|
+
)
|
|
43
56
|
return [] if rows.empty?
|
|
44
57
|
|
|
45
58
|
grouped = {}
|
|
46
59
|
|
|
47
|
-
rows.each do |rule, match_type, discriminator, path, event_count, last_seen_at, period_hour|
|
|
60
|
+
rows.each do |rule, match_type, discriminator, path, event_count, last_seen_at, period_hour, user_agent|
|
|
48
61
|
key = rule.to_s.presence || "unknown"
|
|
49
62
|
count = event_count.to_i
|
|
50
63
|
seen_at = last_seen_at || period_hour
|
|
@@ -55,12 +68,19 @@ module RailsErrorDashboard
|
|
|
55
68
|
count: 0,
|
|
56
69
|
ips: Set.new,
|
|
57
70
|
path_counts: Hash.new(0),
|
|
71
|
+
agent_counts: Hash.new(0),
|
|
72
|
+
ai_count: 0,
|
|
58
73
|
last_seen: nil
|
|
59
74
|
}
|
|
60
75
|
|
|
61
76
|
entry[:count] += count
|
|
62
77
|
entry[:ips] << discriminator.to_s if discriminator.present?
|
|
63
78
|
entry[:path_counts][path.to_s] += count if path.present?
|
|
79
|
+
if user_agent.present?
|
|
80
|
+
agent = Services::AiAgentClassifier.name(user_agent) || user_agent.to_s
|
|
81
|
+
entry[:agent_counts][agent] += count
|
|
82
|
+
entry[:ai_count] += count if Services::AiAgentClassifier.ai?(user_agent)
|
|
83
|
+
end
|
|
64
84
|
entry[:last_seen] = [ entry[:last_seen], seen_at ].compact.max
|
|
65
85
|
|
|
66
86
|
# Prefer the most severe match type when a rule spans several. A rule
|
|
@@ -74,11 +94,17 @@ module RailsErrorDashboard
|
|
|
74
94
|
r[:paths] = r[:path_counts].sort_by { |_p, c| -c }.map(&:first)
|
|
75
95
|
r[:unique_ips] = r[:ips].size
|
|
76
96
|
r[:ips] = r[:ips].to_a
|
|
97
|
+
# Which client matched most often — the question unique_ips cannot
|
|
98
|
+
# answer, because one AI agent is a whole fleet of addresses (#170).
|
|
99
|
+
r[:top_agent] = r[:agent_counts].max_by { |_agent, count| count }&.first
|
|
100
|
+
r[:agents] = r[:agent_counts].sort_by { |_a, c| -c }.map(&:first)
|
|
101
|
+
r[:unique_agents] = r[:agent_counts].size
|
|
77
102
|
# Distinct rate-limited clients is the meaningful figure here; the old
|
|
78
103
|
# breadcrumb-derived :error_count no longer applies now that events are
|
|
79
104
|
# stored independently of errors.
|
|
80
105
|
r[:error_count] = 0
|
|
81
106
|
r.delete(:path_counts)
|
|
107
|
+
r.delete(:agent_counts)
|
|
82
108
|
end
|
|
83
109
|
|
|
84
110
|
grouped.values.sort_by { |r| -r[:count] }
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Classifies a User-Agent string into a coarse traffic kind, and names the
|
|
6
|
+
# agent when it is a recognised one.
|
|
7
|
+
#
|
|
8
|
+
# WHY THIS EXISTS (issue #170): tracking which AI agents read an app is the
|
|
9
|
+
# reason people reach for Rack::Attack's `track` rules now. Counting IPs
|
|
10
|
+
# cannot answer it — one agent is a rotating fleet of hundreds of addresses,
|
|
11
|
+
# so unique-IP totals overstate the population badly. The user agent is the
|
|
12
|
+
# signal that actually identifies the reader.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately plain string matching, NOT the `browser` gem: `browser` is an
|
|
15
|
+
# optional dependency that degrades gracefully everywhere else in this gem,
|
|
16
|
+
# and it does not know these agents anyway. This runs on the flush path, not
|
|
17
|
+
# the request path, but it stays allocation-cheap regardless.
|
|
18
|
+
#
|
|
19
|
+
# The bot lists are necessarily a snapshot. An unrecognised agent falls back
|
|
20
|
+
# to :other rather than being guessed at — a wrong attribution is worse than
|
|
21
|
+
# an honest "unknown" when the whole point is measurement.
|
|
22
|
+
class AiAgentClassifier
|
|
23
|
+
# Order matters: the first match wins, so more specific patterns lead.
|
|
24
|
+
#
|
|
25
|
+
# AI agents split into two behaviours worth telling apart, because they
|
|
26
|
+
# answer different questions:
|
|
27
|
+
# - :ai_assistant — fetches on demand, because a human asked something now
|
|
28
|
+
# - :ai_crawler — bulk-fetches to build a training corpus or index
|
|
29
|
+
AI_ASSISTANTS = {
|
|
30
|
+
"ChatGPT-User" => /ChatGPT-User/i,
|
|
31
|
+
"Claude-User" => /Claude-User/i,
|
|
32
|
+
"Claude Code" => /Claude-?Code/i,
|
|
33
|
+
"Perplexity-User" => /Perplexity-User/i,
|
|
34
|
+
"Gemini-User" => /Gemini-User/i
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
AI_CRAWLERS = {
|
|
38
|
+
"GPTBot" => /GPTBot/i,
|
|
39
|
+
"OAI-SearchBot" => /OAI-SearchBot/i,
|
|
40
|
+
"ClaudeBot" => /ClaudeBot/i,
|
|
41
|
+
"anthropic-ai" => /anthropic-ai/i,
|
|
42
|
+
"PerplexityBot" => /PerplexityBot/i,
|
|
43
|
+
"Google-Extended" => /Google-Extended/i,
|
|
44
|
+
"Applebot-Extended" => /Applebot-Extended/i,
|
|
45
|
+
"Bytespider" => /Bytespider/i,
|
|
46
|
+
"CCBot" => /CCBot/i,
|
|
47
|
+
"Meta-ExternalAgent" => /Meta-ExternalAgent/i,
|
|
48
|
+
"Amazonbot" => /Amazonbot/i,
|
|
49
|
+
"cohere-ai" => /cohere-ai/i,
|
|
50
|
+
"DuckAssistBot" => /DuckAssistBot/i,
|
|
51
|
+
"YouBot" => /YouBot/i,
|
|
52
|
+
"Diffbot" => /Diffbot/i,
|
|
53
|
+
"Timpibot" => /Timpibot/i
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# Conventional search/SEO crawlers. Not AI traffic, but worth naming so
|
|
57
|
+
# they can be excluded rather than silently inflating an "unknown" bucket.
|
|
58
|
+
CRAWLERS = {
|
|
59
|
+
"Googlebot" => /Googlebot/i,
|
|
60
|
+
"Bingbot" => /bingbot/i,
|
|
61
|
+
"DuckDuckBot" => /DuckDuckBot/i,
|
|
62
|
+
"Baiduspider" => /Baiduspider/i,
|
|
63
|
+
"YandexBot" => /YandexBot/i,
|
|
64
|
+
"AhrefsBot" => /AhrefsBot/i,
|
|
65
|
+
"SemrushBot" => /SemrushBot/i,
|
|
66
|
+
"Applebot" => /Applebot/i,
|
|
67
|
+
"facebookexternalhit" => /facebookexternalhit/i,
|
|
68
|
+
"LLMS-Txt-Scanner" => /LLMS-Txt-Scanner/i
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# Checked only after every bot pattern has missed, because plenty of bots
|
|
72
|
+
# embed a full browser UA string and would match these first.
|
|
73
|
+
BROWSER_HINTS = /Mozilla|Chrome|Safari|Firefox|Edge|Opera|Gecko|WebKit/i
|
|
74
|
+
|
|
75
|
+
# Non-browser HTTP clients — usually scripts, monitors or scrapers.
|
|
76
|
+
LIBRARIES = {
|
|
77
|
+
"curl" => /\bcurl\//i,
|
|
78
|
+
"wget" => /\bWget\//i,
|
|
79
|
+
"python-requests" => /python-requests/i,
|
|
80
|
+
"httpx" => /\bhttpx\//i,
|
|
81
|
+
"Go-http-client" => /Go-http-client/i,
|
|
82
|
+
"Java" => /\bJava\//i,
|
|
83
|
+
"okhttp" => /\bokhttp\//i,
|
|
84
|
+
"axios" => /\baxios\//i,
|
|
85
|
+
"Faraday" => /Faraday/i,
|
|
86
|
+
"RubyGems" => /Ruby\b/i
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
KINDS = %i[ai_assistant ai_crawler crawler browser library other].freeze
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# @param user_agent [String, nil]
|
|
93
|
+
# @return [Symbol] one of KINDS
|
|
94
|
+
def kind(user_agent)
|
|
95
|
+
ua = user_agent.to_s
|
|
96
|
+
return :other if ua.strip.empty?
|
|
97
|
+
|
|
98
|
+
return :ai_assistant if match_name(AI_ASSISTANTS, ua)
|
|
99
|
+
return :ai_crawler if match_name(AI_CRAWLERS, ua)
|
|
100
|
+
return :crawler if match_name(CRAWLERS, ua)
|
|
101
|
+
return :library if match_name(LIBRARIES, ua)
|
|
102
|
+
return :browser if ua.match?(BROWSER_HINTS)
|
|
103
|
+
|
|
104
|
+
:other
|
|
105
|
+
rescue => e
|
|
106
|
+
:other
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Canonical name for a recognised agent, or nil when unrecognised.
|
|
110
|
+
# Never invents a name — callers show the raw UA in that case.
|
|
111
|
+
#
|
|
112
|
+
# @param user_agent [String, nil]
|
|
113
|
+
# @return [String, nil]
|
|
114
|
+
def name(user_agent)
|
|
115
|
+
ua = user_agent.to_s
|
|
116
|
+
return nil if ua.strip.empty?
|
|
117
|
+
|
|
118
|
+
match_name(AI_ASSISTANTS, ua) ||
|
|
119
|
+
match_name(AI_CRAWLERS, ua) ||
|
|
120
|
+
match_name(CRAWLERS, ua) ||
|
|
121
|
+
match_name(LIBRARIES, ua)
|
|
122
|
+
rescue => e
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Whether this agent is an LLM reader of either flavour. This is the
|
|
127
|
+
# predicate the dashboard's "AI agents" figure counts.
|
|
128
|
+
#
|
|
129
|
+
# @param user_agent [String, nil]
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def ai?(user_agent)
|
|
132
|
+
%i[ai_assistant ai_crawler].include?(kind(user_agent))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Hash] { kind:, name:, ai: } — one pass for callers that want all three
|
|
136
|
+
def classify(user_agent)
|
|
137
|
+
k = kind(user_agent)
|
|
138
|
+
{
|
|
139
|
+
kind: k,
|
|
140
|
+
name: name(user_agent),
|
|
141
|
+
ai: %i[ai_assistant ai_crawler].include?(k)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def match_name(table, ua)
|
|
148
|
+
table.each { |agent_name, pattern| return agent_name if ua.match?(pattern) }
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -32,6 +32,14 @@ module RailsErrorDashboard
|
|
|
32
32
|
MAX_DISCRIMINATOR_LENGTH = 191
|
|
33
33
|
MAX_PATH_LENGTH = 191
|
|
34
34
|
MAX_METHOD_LENGTH = 10
|
|
35
|
+
MAX_USER_AGENT_LENGTH = 191
|
|
36
|
+
|
|
37
|
+
# Reserved rule/match_type used to account for counts dropped by LRU
|
|
38
|
+
# eviction. Without this the evicted count vanishes silently and the
|
|
39
|
+
# dashboard under-reports with no indication anything was lost — the same
|
|
40
|
+
# problem StormProtection::CountBuffer solves with an overflow counter.
|
|
41
|
+
OVERFLOW_RULE = "__overflow__"
|
|
42
|
+
OVERFLOW_MATCH_TYPE = "overflow"
|
|
35
43
|
|
|
36
44
|
# Separator for the composite buffer key. Chosen because it cannot appear in
|
|
37
45
|
# an HTTP method and is vanishingly unlikely in a rule name or path.
|
|
@@ -46,7 +54,9 @@ module RailsErrorDashboard
|
|
|
46
54
|
# @param discriminator [String] rate-limit key (usually IP or user id)
|
|
47
55
|
# @param path [String] request path
|
|
48
56
|
# @param http_method [String] request method
|
|
49
|
-
|
|
57
|
+
# @param user_agent [String] client user agent, for AI/crawler attribution
|
|
58
|
+
def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil,
|
|
59
|
+
user_agent: nil)
|
|
50
60
|
return unless enabled?
|
|
51
61
|
|
|
52
62
|
key = build_key(
|
|
@@ -54,15 +64,21 @@ module RailsErrorDashboard
|
|
|
54
64
|
match_type.to_s,
|
|
55
65
|
truncate(discriminator, MAX_DISCRIMINATOR_LENGTH),
|
|
56
66
|
truncate(path, MAX_PATH_LENGTH),
|
|
57
|
-
truncate(http_method, MAX_METHOD_LENGTH)
|
|
67
|
+
truncate(http_method, MAX_METHOD_LENGTH),
|
|
68
|
+
truncate(user_agent, MAX_USER_AGENT_LENGTH)
|
|
58
69
|
)
|
|
59
70
|
|
|
60
71
|
counts = (Thread.current[COUNTS_THREAD_KEY] ||= {})
|
|
61
72
|
counts[key] = (counts[key] || 0) + 1
|
|
62
73
|
|
|
63
|
-
# LRU eviction —
|
|
64
|
-
#
|
|
65
|
-
|
|
74
|
+
# LRU eviction — bounds memory under rotating-discriminator attacks.
|
|
75
|
+
# Loops because the overflow bucket occupies a slot of its own once
|
|
76
|
+
# created, so a single eviction may not bring the map back under cap.
|
|
77
|
+
# evict_oldest! returns false once only the overflow key is left, which
|
|
78
|
+
# guarantees termination even if max_cache_size is misconfigured to 0.
|
|
79
|
+
while counts.size > max_cache_size
|
|
80
|
+
break unless evict_oldest!(counts)
|
|
81
|
+
end
|
|
66
82
|
|
|
67
83
|
maybe_flush!
|
|
68
84
|
nil
|
|
@@ -93,6 +109,46 @@ module RailsErrorDashboard
|
|
|
93
109
|
nil
|
|
94
110
|
end
|
|
95
111
|
|
|
112
|
+
# Flush every live thread's buffer, not just the caller's.
|
|
113
|
+
#
|
|
114
|
+
# WHY: flush! only ever sees Thread.current. Buffers live on the Puma
|
|
115
|
+
# threads that served the requests, so at shutdown (and from a background
|
|
116
|
+
# job) the caller's own buffer is empty while the real counts sit on
|
|
117
|
+
# threads nobody is asking. Without this, everything buffered at SIGTERM
|
|
118
|
+
# is lost, and a rule that matches once and then sees no further traffic
|
|
119
|
+
# on that thread is never persisted at all.
|
|
120
|
+
#
|
|
121
|
+
# Thread#[] reads another thread's fiber-locals directly, so no thread
|
|
122
|
+
# registry is needed — the same approach SwallowedExceptionTracker uses.
|
|
123
|
+
# sync: true because callers are already off the request path.
|
|
124
|
+
def flush_all_threads!
|
|
125
|
+
Thread.list.each do |thread|
|
|
126
|
+
# Rescue per thread, not just around the whole loop: one thread
|
|
127
|
+
# whose write fails must not strand the buffers of every thread
|
|
128
|
+
# after it in the list.
|
|
129
|
+
begin
|
|
130
|
+
counts = thread[COUNTS_THREAD_KEY]
|
|
131
|
+
next if counts.nil? || counts.empty?
|
|
132
|
+
|
|
133
|
+
snapshot = counts.dup
|
|
134
|
+
counts.clear
|
|
135
|
+
thread[FLUSH_THREAD_KEY] = nil
|
|
136
|
+
|
|
137
|
+
dispatch_flush(snapshot, sync: true)
|
|
138
|
+
rescue => e
|
|
139
|
+
RailsErrorDashboard::Logger.debug(
|
|
140
|
+
"[RailsErrorDashboard] RackAttackTracker.flush_all_threads! skipped a thread: #{e.class} - #{e.message}"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
nil
|
|
145
|
+
rescue => e
|
|
146
|
+
RailsErrorDashboard::Logger.debug(
|
|
147
|
+
"[RailsErrorDashboard] RackAttackTracker.flush_all_threads! failed: #{e.class} - #{e.message}"
|
|
148
|
+
)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
96
152
|
# Clear thread-local state without persisting. Used by specs and by
|
|
97
153
|
# thread teardown paths.
|
|
98
154
|
def reset!
|
|
@@ -111,9 +167,16 @@ module RailsErrorDashboard
|
|
|
111
167
|
end
|
|
112
168
|
|
|
113
169
|
# Decompose a buffer key back into its parts.
|
|
114
|
-
#
|
|
170
|
+
#
|
|
171
|
+
# The limit must match the field count exactly. With a limit of 5 the
|
|
172
|
+
# user agent would be glued onto http_method instead of standing alone.
|
|
173
|
+
# split also drops trailing empty fields without the limit, so a key
|
|
174
|
+
# whose user agent is blank must still yield six elements.
|
|
175
|
+
#
|
|
176
|
+
# @return [Array<String>] [rule, match_type, discriminator, path, http_method, user_agent]
|
|
115
177
|
def parse_key(key)
|
|
116
|
-
key.to_s.split(KEY_SEPARATOR,
|
|
178
|
+
parts = key.to_s.split(KEY_SEPARATOR, 6)
|
|
179
|
+
parts.fill("", parts.length, 6 - parts.length)
|
|
117
180
|
end
|
|
118
181
|
|
|
119
182
|
private
|
|
@@ -128,9 +191,28 @@ module RailsErrorDashboard
|
|
|
128
191
|
parts.map(&:to_s).join(KEY_SEPARATOR)
|
|
129
192
|
end
|
|
130
193
|
|
|
194
|
+
# Evict the oldest entry, rolling its count into the overflow bucket so
|
|
195
|
+
# the total stays truthful. Ruby hashes preserve insertion order, so the
|
|
196
|
+
# first key is the oldest.
|
|
197
|
+
#
|
|
198
|
+
# The overflow key is skipped when choosing a victim: it is written once
|
|
199
|
+
# and would otherwise be the oldest key forever, so evicting it would
|
|
200
|
+
# discard exactly the accounting this method exists to keep.
|
|
201
|
+
# @return [Boolean] true if an entry was evicted, false if the overflow
|
|
202
|
+
# bucket is all that remains (which is what terminates the caller's loop)
|
|
131
203
|
def evict_oldest!(hash)
|
|
132
|
-
oldest_key = hash.each_key.
|
|
133
|
-
|
|
204
|
+
oldest_key = hash.each_key.find { |k| k != overflow_key }
|
|
205
|
+
return false unless oldest_key
|
|
206
|
+
|
|
207
|
+
dropped = hash.delete(oldest_key).to_i
|
|
208
|
+
hash[overflow_key] = (hash[overflow_key] || 0) + dropped if dropped.positive?
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def overflow_key
|
|
213
|
+
@overflow_key ||= build_key(
|
|
214
|
+
OVERFLOW_RULE, OVERFLOW_MATCH_TYPE, "", "", "", ""
|
|
215
|
+
)
|
|
134
216
|
end
|
|
135
217
|
|
|
136
218
|
# Cheap periodic flush check — a float subtraction, no I/O.
|
|
@@ -70,9 +70,10 @@ module RailsErrorDashboard
|
|
|
70
70
|
|
|
71
71
|
match_type = event_name.split(".").first # "throttle", "blocklist", "track"
|
|
72
72
|
rule = env["rack.attack.matched"].to_s
|
|
73
|
-
discriminator = env
|
|
73
|
+
discriminator = resolve_discriminator(env, request)
|
|
74
74
|
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
75
75
|
method = request.respond_to?(:request_method) ? request.request_method.to_s : ""
|
|
76
|
+
user_agent = resolve_user_agent(request, env)
|
|
76
77
|
|
|
77
78
|
# Persist independently of error capture. A throttled request returns
|
|
78
79
|
# HTTP 429 and raises nothing, so it would otherwise never reach the
|
|
@@ -82,7 +83,8 @@ module RailsErrorDashboard
|
|
|
82
83
|
match_type: match_type,
|
|
83
84
|
discriminator: discriminator,
|
|
84
85
|
path: path,
|
|
85
|
-
http_method: method
|
|
86
|
+
http_method: method,
|
|
87
|
+
user_agent: user_agent
|
|
86
88
|
)
|
|
87
89
|
|
|
88
90
|
# Also record a breadcrumb so the event still shows up in the activity
|
|
@@ -102,6 +104,42 @@ module RailsErrorDashboard
|
|
|
102
104
|
|
|
103
105
|
Services::BreadcrumbCollector.add("rack_attack", message, metadata: metadata)
|
|
104
106
|
end
|
|
107
|
+
|
|
108
|
+
# Resolve the discriminator, falling back to the client IP.
|
|
109
|
+
#
|
|
110
|
+
# WHY (issue #170): a `track` rule declared without :limit/:period is a
|
|
111
|
+
# Rack::Attack::Check, and Check#matched_by? sets only "rack.attack.matched"
|
|
112
|
+
# and "rack.attack.match_type" — never "rack.attack.match_discriminator".
|
|
113
|
+
# Only Throttle#annotate_request_with_matched_data sets that key. The value
|
|
114
|
+
# the rule's block returns (typically `req.ip`) is used purely as a truthy
|
|
115
|
+
# match test and then discarded upstream.
|
|
116
|
+
#
|
|
117
|
+
# Without this fallback every track row stores a blank discriminator, so
|
|
118
|
+
# RackAttackSummary reports "Unique IPs: 0" for a rule that plainly matched
|
|
119
|
+
# real clients. We use request.ip rather than re-invoking the rule's block:
|
|
120
|
+
# the block is arbitrary host code that may have side effects or return a
|
|
121
|
+
# non-IP value, and re-running it from a notification subscriber would
|
|
122
|
+
# execute it a second time per request.
|
|
123
|
+
def resolve_discriminator(env, request)
|
|
124
|
+
explicit = env["rack.attack.match_discriminator"].to_s
|
|
125
|
+
return explicit unless explicit.empty?
|
|
126
|
+
|
|
127
|
+
# request.ip parses X-Forwarded-For and can raise on malformed input.
|
|
128
|
+
request.respond_to?(:ip) ? request.ip.to_s : ""
|
|
129
|
+
rescue => e
|
|
130
|
+
""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The user agent identifies WHICH client matched a rule — the question
|
|
134
|
+
# IP counts cannot answer, since one AI agent is a rotating fleet of
|
|
135
|
+
# addresses (issue #170). Falls back to the raw env key so a request
|
|
136
|
+
# object that does not implement #user_agent still yields the value.
|
|
137
|
+
def resolve_user_agent(request, env)
|
|
138
|
+
ua = request.respond_to?(:user_agent) ? request.user_agent : nil
|
|
139
|
+
(ua || env["HTTP_USER_AGENT"]).to_s
|
|
140
|
+
rescue => e
|
|
141
|
+
""
|
|
142
|
+
end
|
|
105
143
|
end
|
|
106
144
|
end
|
|
107
145
|
end
|
|
@@ -84,6 +84,7 @@ require "rails_error_dashboard/services/variable_serializer"
|
|
|
84
84
|
require "rails_error_dashboard/services/local_variable_capturer"
|
|
85
85
|
require "rails_error_dashboard/services/swallowed_exception_tracker"
|
|
86
86
|
require "rails_error_dashboard/services/rack_attack_tracker"
|
|
87
|
+
require "rails_error_dashboard/services/ai_agent_classifier"
|
|
87
88
|
require "rails_error_dashboard/services/crash_capture"
|
|
88
89
|
require "rails_error_dashboard/services/diagnostic_dump_generator"
|
|
89
90
|
require "rails_error_dashboard/services/coverage_tracker"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -391,6 +391,7 @@ files:
|
|
|
391
391
|
- db/migrate/20260503000001_backfill_resolved_status.rb
|
|
392
392
|
- db/migrate/20260613000001_create_storm_events.rb
|
|
393
393
|
- db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
|
|
394
|
+
- db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb
|
|
394
395
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
395
396
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
396
397
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -471,6 +472,7 @@ files:
|
|
|
471
472
|
- lib/rails_error_dashboard/queries/storm_history.rb
|
|
472
473
|
- lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
|
|
473
474
|
- lib/rails_error_dashboard/queries/user_impact_summary.rb
|
|
475
|
+
- lib/rails_error_dashboard/services/ai_agent_classifier.rb
|
|
474
476
|
- lib/rails_error_dashboard/services/analytics_cache_manager.rb
|
|
475
477
|
- lib/rails_error_dashboard/services/backtrace_parser.rb
|
|
476
478
|
- lib/rails_error_dashboard/services/backtrace_processor.rb
|
|
@@ -557,7 +559,7 @@ metadata:
|
|
|
557
559
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
558
560
|
post_install_message: |
|
|
559
561
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
560
|
-
RED (Rails Error Dashboard) v0.
|
|
562
|
+
RED (Rails Error Dashboard) v0.10.0
|
|
561
563
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
562
564
|
|
|
563
565
|
First install:
|