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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/rails_error_dashboard/errors_controller.rb +6 -0
  3. data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
  4. data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
  5. data/app/models/rails_error_dashboard/rack_attack_event.rb +6 -2
  6. data/app/views/layouts/rails_error_dashboard.html.erb +55 -0
  7. data/app/views/rails_error_dashboard/errors/analytics.html.erb +7 -12
  8. data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
  9. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
  10. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
  11. data/config/locales/de.yml +5 -0
  12. data/config/locales/en.yml +7 -0
  13. data/config/locales/es.yml +5 -0
  14. data/config/locales/fr.yml +5 -0
  15. data/config/locales/it.yml +5 -0
  16. data/config/locales/ja.yml +5 -0
  17. data/config/locales/pl.yml +5 -0
  18. data/config/locales/pt-BR.yml +5 -0
  19. data/config/locales/ru.yml +5 -0
  20. data/config/locales/uk.yml +5 -0
  21. data/config/locales/zh-CN.yml +5 -0
  22. data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
  23. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
  24. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
  25. data/lib/rails_error_dashboard/engine.rb +7 -0
  26. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
  27. data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
  28. data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
  29. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
  30. data/lib/rails_error_dashboard/version.rb +1 -1
  31. data/lib/rails_error_dashboard.rb +1 -0
  32. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57296ebd76d5c90e1605cf26e0946418332a109b6a8e06e47764d0679d580180
4
- data.tar.gz: c7c013e699b365aa6fe3ec520d57261eb9e81beabe32ae60e040e4dec60632f2
3
+ metadata.gz: 7065559d38e3152511024cd4a44698c3b215cc6cd60e4917d52cf4d893180b6c
4
+ data.tar.gz: 0d9237d4e89f4cb08392aecfe109dbb38bc1aaf673e834d827441bebf84a6e3f
5
5
  SHA512:
6
- metadata.gz: eacb14ac3148b50729be1a2028df13699feab0c80bdd8d52094d275eabe55f159ed6d68bec6b6b6b8b7642bcb46fa9e0b27c1c23138da1cca277044de5dc1b26
7
- data.tar.gz: 9ba438dd2e6efbe417a26846cc2c8e3784e568ba2f4c471ea92c25258a782ab7265d5cb27e488760d8871764a270e5299de76057c799c3a3220450b410c50f06
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 current thread's buffer (scheduled cron safety net).
26
- # sync: true because we are already off the request path.
27
- Services::RackAttackTracker.flush!(sync: true)
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
- MATCH_TYPES = %w[throttle blocklist track safelist].freeze
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.axis_error_type") %>' },
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.axis_count") %>' },
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: " hours",
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: " hours",
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].strftime("%b %d"),
40
- end: @period_comparison[:current_period][:end].strftime("%b %d")) %>
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].strftime("%b %d"),
49
- end: @period_comparison[:previous_period][:end].strftime("%b %d")) %>
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.strftime('%b %d') }&.to_json || [].to_json %>,
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-4">
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-4">
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-4">
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">&mdash;</span>
149
+ <% end %>
150
+ </td>
118
151
  <td><%= local_time_ago(event[:last_seen]) %></td>
119
152
  </tr>
120
153
  <% end %>
@@ -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
@@ -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"
@@ -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
@@ -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
@@ -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
@@ -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: 解決までの時間
@@ -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
@@ -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
@@ -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: Часов до решения
@@ -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: Годин до вирішення
@@ -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:, period:, app_id:, count:)
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.pluck(
41
- :rule, :match_type, :discriminator, :path, :event_count, :last_seen_at, :period_hour
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
- def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil)
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 — Ruby hashes preserve insertion order, so the first key
64
- # is the oldest. Bounds memory under rotating-discriminator attacks.
65
- evict_oldest!(counts) if counts.size > max_cache_size
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
- # @return [Array<String>] [rule, match_type, discriminator, path, http_method]
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, 5)
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.first
133
- hash.delete(oldest_key) if oldest_key
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["rack.attack.match_discriminator"].to_s
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
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.9.1"
2
+ VERSION = "0.10.0"
3
3
  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.9.1
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.9.1
562
+ RED (Rails Error Dashboard) v0.10.0
561
563
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
562
564
 
563
565
  First install: