rails_error_dashboard 0.9.0 → 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 (33) 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/configuration.rb +13 -3
  26. data/lib/rails_error_dashboard/engine.rb +7 -0
  27. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
  28. data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
  29. data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
  30. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
  31. data/lib/rails_error_dashboard/version.rb +1 -1
  32. data/lib/rails_error_dashboard.rb +1 -0
  33. metadata +15 -6
@@ -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!
@@ -478,12 +478,22 @@ module RailsErrorDashboard
478
478
  errors = []
479
479
  warnings = []
480
480
 
481
- # Block boot with default or blank credentials in production
481
+ # Block boot with default or blank credentials anywhere that is not local
482
+ # development or test.
483
+ #
484
+ # Deliberately an ALLOWLIST of safe environments rather than a check for
485
+ # `production`. Gating on Rails.env.production? tests one literal string,
486
+ # so an internet-facing `staging`, `uat`, `demo` or `preprod` box booted
487
+ # happily on credentials this project publishes in its own README
488
+ # (GHSA-qhgm-3pxf-mvc6). Every future environment name a team invents is
489
+ # now refused by default and has to be added here on purpose.
490
+ #
482
491
  # Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time
483
492
  if default_credentials? &&
484
- defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production? &&
493
+ defined?(Rails) && Rails.respond_to?(:env) &&
494
+ !Rails.env.development? && !Rails.env.test? &&
485
495
  ENV["SECRET_KEY_BASE_DUMMY"].blank?
486
- errors << "Default or blank credentials cannot be used in production. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables, or use authenticate_with for custom auth."
496
+ errors << "Default or blank credentials cannot be used in #{Rails.env}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables, or use authenticate_with for custom auth."
487
497
  end
488
498
 
489
499
  # Validate sampling_rate (must be between 0.0 and 1.0)
@@ -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.0"
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.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -232,9 +232,16 @@ description: 'Own your errors. Own your stack. A fully open-source, self-hosted
232
232
  dashboard UI (dark/light), multi-channel notifications (Slack, Email, Discord, PagerDuty,
233
233
  webhooks), workflow management, advanced analytics, platform detection (iOS/Android/Web/API),
234
234
  and two-way issue sync with GitHub, GitLab, Codeberg, and Linear. Also: LLM observability,
235
- AI-powered debugging help, and OpenTelemetry span export. 5-minute setup, works
236
- out-of-the-box. Rails 7.0-8.1, Ruby 3.2-4.0. BETA: API may change before v1.0.0.
237
- Live demo: https://rails-error-dashboard.anjan.dev (gandalf/youshallnotpass)'
235
+ AI-powered debugging help, and OpenTelemetry span export. The dashboard, mailers
236
+ and notification payloads are translated into 11 languages: English, German, Spanish,
237
+ French, Brazilian Portuguese, Italian, Polish, Russian, Ukrainian, Japanese, and
238
+ Simplified Chinese — 1,515 source strings, set via config.dashboard_locale with
239
+ a per-user picker. RED translates through its own private I18n backend and never
240
+ mutates your app''s I18n; a missing translation falls back to English. Backtraces,
241
+ exception names and webhook payload keys stay English by design. The ten non-English
242
+ locales are machine-translated and not yet reviewed by native speakers. 5-minute
243
+ setup, works out-of-the-box. Rails 7.0-8.1, Ruby 3.2-4.0. BETA: API may change before
244
+ v1.0.0. Live demo: https://rails-error-dashboard.anjan.dev (gandalf/youshallnotpass)'
238
245
  email:
239
246
  - anjan.jagirdar@gmail.com
240
247
  executables: []
@@ -384,6 +391,7 @@ files:
384
391
  - db/migrate/20260503000001_backfill_resolved_status.rb
385
392
  - db/migrate/20260613000001_create_storm_events.rb
386
393
  - db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
394
+ - db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb
387
395
  - lib/generators/rails_error_dashboard/install/install_generator.rb
388
396
  - lib/generators/rails_error_dashboard/install/templates/README
389
397
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -464,6 +472,7 @@ files:
464
472
  - lib/rails_error_dashboard/queries/storm_history.rb
465
473
  - lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
466
474
  - lib/rails_error_dashboard/queries/user_impact_summary.rb
475
+ - lib/rails_error_dashboard/services/ai_agent_classifier.rb
467
476
  - lib/rails_error_dashboard/services/analytics_cache_manager.rb
468
477
  - lib/rails_error_dashboard/services/backtrace_parser.rb
469
478
  - lib/rails_error_dashboard/services/backtrace_processor.rb
@@ -550,7 +559,7 @@ metadata:
550
559
  funding_uri: https://github.com/sponsors/AnjanJ
551
560
  post_install_message: |
552
561
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
553
- RED (Rails Error Dashboard) v0.9.0
562
+ RED (Rails Error Dashboard) v0.10.0
554
563
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
555
564
 
556
565
  First install:
@@ -589,5 +598,5 @@ requirements: []
589
598
  rubygems_version: 3.6.9
590
599
  specification_version: 4
591
600
  summary: Self-hosted error tracking for Rails — local variables, system health, separate
592
- or shared database. A free, open-source Sentry alternative.
601
+ or shared database, dashboard in 11 languages. A free, open-source Sentry alternative.
593
602
  test_files: []