rails_error_dashboard 0.9.1 → 0.11.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +9 -0
  4. data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
  5. data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
  6. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
  7. data/app/models/rails_error_dashboard/error_log.rb +1 -0
  8. data/app/models/rails_error_dashboard/rack_attack_event.rb +6 -2
  9. data/app/views/layouts/rails_error_dashboard.html.erb +85 -0
  10. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
  11. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
  12. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
  13. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
  14. data/app/views/rails_error_dashboard/errors/analytics.html.erb +58 -13
  15. data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
  16. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
  17. data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
  18. data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
  19. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +1 -1
  20. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
  21. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  22. data/config/locales/de.yml +22 -0
  23. data/config/locales/en.yml +24 -0
  24. data/config/locales/es.yml +21 -0
  25. data/config/locales/fr.yml +22 -0
  26. data/config/locales/it.yml +30 -1
  27. data/config/locales/ja.yml +19 -0
  28. data/config/locales/pl.yml +22 -0
  29. data/config/locales/pt-BR.yml +21 -0
  30. data/config/locales/ru.yml +21 -0
  31. data/config/locales/uk.yml +21 -0
  32. data/config/locales/zh-CN.yml +17 -0
  33. data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
  34. data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
  35. data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
  36. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
  37. data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +55 -29
  39. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
  40. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
  41. data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
  42. data/lib/rails_error_dashboard/configuration.rb +52 -0
  43. data/lib/rails_error_dashboard/engine.rb +7 -0
  44. data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
  45. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  46. data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
  47. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +31 -5
  48. data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -0
  49. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
  50. data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
  51. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
  52. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
  53. data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
  54. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
  55. data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +91 -9
  56. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
  57. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
  58. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
  59. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +40 -2
  60. data/lib/rails_error_dashboard/version.rb +1 -1
  61. data/lib/rails_error_dashboard.rb +2 -0
  62. data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
  63. metadata +6 -2
@@ -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
@@ -47,6 +47,7 @@ module RailsErrorDashboard
47
47
  value: error_log.platform || unknown,
48
48
  inline: true
49
49
  },
50
+ *environment_fields(error_log, locale),
50
51
  {
51
52
  name: NotificationHelpers.label(:occurrences, locale),
52
53
  value: error_log.occurrence_count.to_s,
@@ -84,6 +85,13 @@ module RailsErrorDashboard
84
85
 
85
86
  # @param error_log [ErrorLog] The error
86
87
  # @return [Integer] Discord color integer
88
+ # One field when the error carries an environment, none for a legacy row.
89
+ def self.environment_fields(error_log, locale)
90
+ return [] unless error_log.respond_to?(:environment) && error_log.environment.present?
91
+
92
+ [ { name: NotificationHelpers.label(:environment, locale), value: error_log.environment, inline: true } ]
93
+ end
94
+
87
95
  def self.severity_color(error_log)
88
96
  SEVERITY_COLORS[error_log.severity] || DEFAULT_COLOR
89
97
  end
@@ -23,9 +23,11 @@ module RailsErrorDashboard
23
23
 
24
24
  platforms = ErrorLog.distinct.pluck(:platform).compact
25
25
  show_platform = platforms.size > 1
26
+ show_environment = ErrorLog.column_names.include?("environment") &&
27
+ ErrorLog.distinct.pluck(:environment).compact.size > 1
26
28
 
27
29
  html = render_partial("rails_error_dashboard/errors/error_row",
28
- error: error_log, show_platform: show_platform)
30
+ error: error_log, show_platform: show_platform, show_environment: show_environment)
29
31
 
30
32
  Turbo::StreamsChannel.broadcast_prepend_to(
31
33
  "error_list",
@@ -46,9 +48,11 @@ module RailsErrorDashboard
46
48
 
47
49
  platforms = ErrorLog.distinct.pluck(:platform).compact
48
50
  show_platform = platforms.size > 1
51
+ show_environment = ErrorLog.column_names.include?("environment") &&
52
+ ErrorLog.distinct.pluck(:environment).compact.size > 1
49
53
 
50
54
  html = render_partial("rails_error_dashboard/errors/error_row",
51
- error: error_log, show_platform: show_platform)
55
+ error: error_log, show_platform: show_platform, show_environment: show_environment)
52
56
 
53
57
  Turbo::StreamsChannel.broadcast_replace_to(
54
58
  "error_list",
@@ -104,6 +104,7 @@ module RailsErrorDashboard
104
104
 
105
105
  def metadata_section
106
106
  items = []
107
+ items << "- **Environment:** #{@error.environment}" if @error.respond_to?(:environment) && @error.environment.present?
107
108
  items << "- **Platform:** #{@error.platform}" if @error.platform.present?
108
109
  items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
109
110
  items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
@@ -435,6 +435,7 @@ module RailsErrorDashboard
435
435
 
436
436
  def metadata_section
437
437
  items = []
438
+ items << "- **Environment:** #{@error.environment}" if @error.respond_to?(:environment) && @error.environment.present?
438
439
  items << "- **Platform:** #{@error.platform}" if @error.platform.present?
439
440
  items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
440
441
  items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
@@ -29,6 +29,29 @@ module RailsErrorDashboard
29
29
  true
30
30
  end
31
31
 
32
+ # Does this error's environment pass config.notification_environments?
33
+ #
34
+ # nil list → everything notifies (the pre-0.11 behaviour). Accepts an
35
+ # ErrorLog, a bare environment name, or nothing (the process
36
+ # environment) — the storm and baseline paths have no row to hand over.
37
+ # A legacy row with a NULL environment is judged as the process
38
+ # environment, so upgrading never silences an error that was notifying
39
+ # before. Fails open: an allowlist bug must not lose a page.
40
+ #
41
+ # @param subject [ErrorLog, String, nil]
42
+ # @return [Boolean]
43
+ def environment_allowed?(subject = nil)
44
+ allowed = RailsErrorDashboard.configuration.notification_environments
45
+ return true if allowed.nil?
46
+
47
+ name = subject.respond_to?(:environment) ? subject.environment : subject
48
+ name = RailsErrorDashboard.configuration.current_environment if name.blank?
49
+ allowed.include?(name.to_s)
50
+ rescue => e
51
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.environment_allowed? failed: #{e.message}")
52
+ true
53
+ end
54
+
32
55
  # Does the error's severity meet the configured minimum?
33
56
  # @param error_log [ErrorLog] The error to check
34
57
  # @return [Boolean] true if severity is at or above minimum
@@ -46,6 +46,7 @@ module RailsErrorDashboard
46
46
  controller: error_log.controller_name,
47
47
  action: error_log.action_name,
48
48
  platform: error_log.platform,
49
+ environment: error_log.environment,
49
50
  occurrences: error_log.occurrence_count,
50
51
  first_seen_at: error_log.first_seen_at&.iso8601,
51
52
  last_seen_at: error_log.last_seen_at&.iso8601,
@@ -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.
@@ -64,6 +64,7 @@ module RailsErrorDashboard
64
64
  locale
65
65
  )
66
66
  },
67
+ *environment_fields(error_log, locale),
67
68
  {
68
69
  type: "mrkdwn",
69
70
  text: NotificationHelpers.field(
@@ -74,6 +75,14 @@ module RailsErrorDashboard
74
75
  }
75
76
  end
76
77
 
78
+ # One field when the error carries an environment, none for a legacy
79
+ # row — so pre-0.11 notifications render exactly as they did.
80
+ def self.environment_fields(error_log, locale)
81
+ return [] unless error_log.respond_to?(:environment) && error_log.environment.present?
82
+
83
+ [ { type: "mrkdwn", text: NotificationHelpers.field(:environment, error_log.environment, locale) } ]
84
+ end
85
+
77
86
  def self.message_block(error_log, locale)
78
87
  {
79
88
  type: "section",
@@ -232,6 +232,8 @@ module RailsErrorDashboard
232
232
  def maybe_storm_notification(state)
233
233
  return if state == :closed
234
234
  return unless RailsErrorDashboard.configuration.storm_notification
235
+ # A storm on staging should not page whoever is on call for production.
236
+ return unless RailsErrorDashboard::Services::NotificationThrottler.environment_allowed?
235
237
 
236
238
  episode = breaker.episode_snapshot
237
239
  return unless episode
@@ -37,6 +37,7 @@ module RailsErrorDashboard
37
37
  message: error_log.message,
38
38
  severity: error_log.severity.to_s,
39
39
  platform: error_log.platform,
40
+ environment: error_log.environment,
40
41
  controller: error_log.controller_name,
41
42
  action: error_log.action_name,
42
43
  occurrence_count: error_log.occurrence_count,
@@ -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.11.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"
@@ -108,6 +109,7 @@ require "rails_error_dashboard/commands/resolve_error"
108
109
  require "rails_error_dashboard/commands/create_issue"
109
110
  require "rails_error_dashboard/commands/link_existing_issue"
110
111
  require "rails_error_dashboard/commands/flush_storm_counts"
112
+ require "rails_error_dashboard/commands/backfill_environments"
111
113
  require "rails_error_dashboard/services/issue_body_formatter"
112
114
  require "rails_error_dashboard/commands/batch_resolve_errors"
113
115
  require "rails_error_dashboard/commands/batch_delete_errors"
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :rails_error_dashboard do
4
+ desc "Fill in environment on error logs captured before the column existed (from environment_info.rails_env)"
5
+ task backfill_environments: :environment do
6
+ updated = RailsErrorDashboard::Commands::BackfillEnvironments.call
7
+ puts "rails_error_dashboard: backfilled environment on #{updated} error log(s)."
8
+ end
9
+
4
10
  namespace :db do
5
11
  desc "Drop all Rails Error Dashboard database tables (⚠️ DESTRUCTIVE - deletes all error data)"
6
12
  task drop: :environment do
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.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -391,6 +391,8 @@ 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
395
+ - db/migrate/20260826000001_add_environment_to_error_logs.rb
394
396
  - lib/generators/rails_error_dashboard/install/install_generator.rb
395
397
  - lib/generators/rails_error_dashboard/install/templates/README
396
398
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -400,6 +402,7 @@ files:
400
402
  - lib/rails_error_dashboard.rb
401
403
  - lib/rails_error_dashboard/commands/add_error_comment.rb
402
404
  - lib/rails_error_dashboard/commands/assign_error.rb
405
+ - lib/rails_error_dashboard/commands/backfill_environments.rb
403
406
  - lib/rails_error_dashboard/commands/batch_delete_errors.rb
404
407
  - lib/rails_error_dashboard/commands/batch_mute_errors.rb
405
408
  - lib/rails_error_dashboard/commands/batch_resolve_errors.rb
@@ -471,6 +474,7 @@ files:
471
474
  - lib/rails_error_dashboard/queries/storm_history.rb
472
475
  - lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
473
476
  - lib/rails_error_dashboard/queries/user_impact_summary.rb
477
+ - lib/rails_error_dashboard/services/ai_agent_classifier.rb
474
478
  - lib/rails_error_dashboard/services/analytics_cache_manager.rb
475
479
  - lib/rails_error_dashboard/services/backtrace_parser.rb
476
480
  - lib/rails_error_dashboard/services/backtrace_processor.rb
@@ -557,7 +561,7 @@ metadata:
557
561
  funding_uri: https://github.com/sponsors/AnjanJ
558
562
  post_install_message: |
559
563
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
560
- RED (Rails Error Dashboard) v0.9.1
564
+ RED (Rails Error Dashboard) v0.11.0
561
565
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
562
566
 
563
567
  First install: