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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: fill in `environment` on error logs captured before the column
6
+ # existed, using the rails_env recorded in environment_info at capture time.
7
+ #
8
+ # Opt-in (rails_error_dashboard:backfill_environments). Live errors do not
9
+ # need it — FindOrIncrementError adopts a NULL row on its next occurrence —
10
+ # but history that never recurs would otherwise stay unbadged and
11
+ # unfilterable forever.
12
+ #
13
+ # Runs in batches, one UPDATE per row: the value is inside a JSON text
14
+ # column, so it has to be parsed in Ruby and no portable single UPDATE can
15
+ # do it across PostgreSQL, MySQL and SQLite.
16
+ class BackfillEnvironments
17
+ BATCH_SIZE = 500
18
+ COLUMN_LIMIT = 64
19
+
20
+ def self.call(batch_size: BATCH_SIZE)
21
+ new(batch_size: batch_size).call
22
+ end
23
+
24
+ def initialize(batch_size: BATCH_SIZE)
25
+ @batch_size = batch_size
26
+ end
27
+
28
+ # @return [Integer] rows updated
29
+ def call
30
+ return 0 unless ErrorLog.column_names.include?("environment")
31
+
32
+ updated = 0
33
+ ErrorLog.where(environment: nil).where.not(environment_info: nil)
34
+ .in_batches(of: @batch_size) do |batch|
35
+ batch.pluck(:id, :environment_info).each do |id, raw|
36
+ env = rails_env_from(raw)
37
+ next if env.nil?
38
+
39
+ updated += ErrorLog.where(id: id, environment: nil).update_all(environment: env)
40
+ end
41
+ end
42
+ updated
43
+ end
44
+
45
+ private
46
+
47
+ def rails_env_from(raw)
48
+ parsed = JSON.parse(raw.to_s)
49
+ return nil unless parsed.is_a?(Hash)
50
+
51
+ value = parsed["rails_env"].to_s.strip
52
+ value.empty? ? nil : value[0, COLUMN_LIMIT]
53
+ rescue JSON::ParserError
54
+ nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -9,6 +9,12 @@ module RailsErrorDashboard
9
9
  # 1. Unresolved errors with same hash within 24 hours → increment occurrence count
10
10
  # 2. Resolved/wont_fix errors with same hash (any age) → reopen and increment
11
11
  # 3. No match → create new error record
12
+ #
13
+ # Environment is a MATCH dimension, not part of the hash: the same error in
14
+ # staging and production is two rows with independent status. A row with a
15
+ # NULL environment predates the column; it matches as a wildcard and is
16
+ # stamped by the first occurrence that claims it, so history migrates
17
+ # itself without a backfill. An exact match always wins over a NULL one.
12
18
  class FindOrIncrementError
13
19
  def self.call(error_hash, attributes = {})
14
20
  new(error_hash, attributes).call
@@ -35,23 +41,40 @@ module RailsErrorDashboard
35
41
  private
36
42
 
37
43
  def find_unresolved
38
- ErrorLog.unresolved
39
- .where(error_hash: @error_hash)
40
- .where(application_id: @attributes[:application_id])
41
- .where("occurred_at >= ?", 24.hours.ago)
42
- .lock
43
- .order(last_seen_at: :desc)
44
- .first
44
+ with_environment(
45
+ ErrorLog.unresolved
46
+ .where(error_hash: @error_hash)
47
+ .where(application_id: @attributes[:application_id])
48
+ .where("occurred_at >= ?", 24.hours.ago)
49
+ ).lock.order(last_seen_at: :desc).first
45
50
  end
46
51
 
47
52
  def find_resolved
48
- ErrorLog
49
- .where(error_hash: @error_hash)
50
- .where(application_id: @attributes[:application_id])
51
- .where(status: %w[resolved wont_fix])
52
- .lock
53
- .order(last_seen_at: :desc)
54
- .first
53
+ with_environment(
54
+ ErrorLog
55
+ .where(error_hash: @error_hash)
56
+ .where(application_id: @attributes[:application_id])
57
+ .where(status: %w[resolved wont_fix])
58
+ ).lock.order(last_seen_at: :desc).first
59
+ end
60
+
61
+ # Restrict to this occurrence's environment or a legacy NULL row, exact
62
+ # first. Literal SQL, no interpolation. A blank environment (column not
63
+ # migrated yet, or an attribute-less caller) leaves the scope unchanged.
64
+ def with_environment(scope)
65
+ env = @attributes[:environment]
66
+ return scope if env.blank? || !ErrorLog.column_names.include?("environment")
67
+
68
+ scope.where(environment: [ env, nil ])
69
+ .order(Arel.sql("CASE WHEN environment IS NULL THEN 1 ELSE 0 END"))
70
+ end
71
+
72
+ # {} unless this is a legacy NULL-environment row being claimed.
73
+ def environment_adoption(error)
74
+ return {} unless ErrorLog.column_names.include?("environment")
75
+ return {} if error.environment.present? || @attributes[:environment].blank?
76
+
77
+ { environment: @attributes[:environment] }
55
78
  end
56
79
 
57
80
  def increment_existing(error)
@@ -62,7 +85,8 @@ module RailsErrorDashboard
62
85
  request_url: @attributes[:request_url] || error.request_url,
63
86
  request_params: @attributes[:request_params] || error.request_params,
64
87
  user_agent: @attributes[:user_agent] || error.user_agent,
65
- ip_address: @attributes[:ip_address] || error.ip_address
88
+ ip_address: @attributes[:ip_address] || error.ip_address,
89
+ **environment_adoption(error)
66
90
  )
67
91
  error
68
92
  end
@@ -78,7 +102,8 @@ module RailsErrorDashboard
78
102
  request_url: @attributes[:request_url] || error.request_url,
79
103
  request_params: @attributes[:request_params] || error.request_params,
80
104
  user_agent: @attributes[:user_agent] || error.user_agent,
81
- ip_address: @attributes[:ip_address] || error.ip_address
105
+ ip_address: @attributes[:ip_address] || error.ip_address,
106
+ **environment_adoption(error)
82
107
  }
83
108
  attrs[:reopened_at] = Time.current if ErrorLog.column_names.include?("reopened_at")
84
109
  error.update!(attrs)
@@ -90,27 +115,28 @@ module RailsErrorDashboard
90
115
  ErrorLog.create!(@attributes.reverse_merge(resolved: false))
91
116
  rescue ActiveRecord::RecordNotUnique
92
117
  # Race condition: another process created the same error
93
- retry_existing = ErrorLog.unresolved
94
- .where(error_hash: @error_hash)
95
- .where(application_id: @attributes[:application_id])
96
- .where("occurred_at >= ?", 24.hours.ago)
97
- .lock
98
- .first
118
+ retry_existing = with_environment(
119
+ ErrorLog.unresolved
120
+ .where(error_hash: @error_hash)
121
+ .where(application_id: @attributes[:application_id])
122
+ .where("occurred_at >= ?", 24.hours.ago)
123
+ ).lock.first
99
124
 
100
125
  if retry_existing
101
126
  retry_existing.update!(
102
127
  occurrence_count: retry_existing.occurrence_count + 1,
103
- last_seen_at: Time.current
128
+ last_seen_at: Time.current,
129
+ **environment_adoption(retry_existing)
104
130
  )
105
131
  retry_existing
106
132
  else
107
133
  # Also check resolved in race condition path
108
- retry_resolved = ErrorLog
109
- .where(error_hash: @error_hash)
110
- .where(application_id: @attributes[:application_id])
111
- .where(status: %w[resolved wont_fix])
112
- .lock
113
- .first
134
+ retry_resolved = with_environment(
135
+ ErrorLog
136
+ .where(error_hash: @error_hash)
137
+ .where(application_id: @attributes[:application_id])
138
+ .where(status: %w[resolved wont_fix])
139
+ ).lock.first
114
140
 
115
141
  if retry_resolved
116
142
  reopen_existing(retry_resolved)
@@ -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!
@@ -59,20 +59,38 @@ module RailsErrorDashboard
59
59
 
60
60
  error_hash = canonical_hash(entry, application)
61
61
  last_seen = parse_time(entry["last_seen_at"]) || Time.current
62
-
63
- # Priority 1: unresolved match — one UPDATE, no row instantiation
64
- updated = ErrorLog.unresolved
65
- .where(error_hash: error_hash, application_id: application.id)
66
- .update_all([ "occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen ])
67
- return count if updated.positive?
62
+ env = current_environment
63
+
64
+ # Priority 1: unresolved match — one UPDATE, no row instantiation.
65
+ # Environment mirrors FindOrIncrementError: exact row first, then a
66
+ # legacy NULL row which is stamped as it is claimed. Two statements
67
+ # rather than one IN (env, NULL), because update_all would hit BOTH
68
+ # rows when they coexist and double-count.
69
+ unresolved = ErrorLog.unresolved.where(error_hash: error_hash, application_id: application.id)
70
+ if env
71
+ updated = unresolved.where(environment: env)
72
+ .update_all([ "occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen ])
73
+ return count if updated.positive?
74
+
75
+ updated = unresolved.where(environment: nil)
76
+ .update_all([ "occurrence_count = occurrence_count + ?, last_seen_at = ?, environment = ?",
77
+ count, last_seen, env ])
78
+ return count if updated.positive?
79
+ else
80
+ updated = unresolved.update_all([ "occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen ])
81
+ return count if updated.positive?
82
+ end
68
83
 
69
84
  # Priority 2: resolved/wont_fix match — reopen, mirroring
70
85
  # FindOrIncrementError so storm recurrences don't stay buried
71
- resolved = ErrorLog
86
+ resolved_scope = ErrorLog
72
87
  .where(error_hash: error_hash, application_id: application.id)
73
88
  .where(status: %w[resolved wont_fix])
74
- .order(last_seen_at: :desc)
75
- .first
89
+ if env
90
+ resolved_scope = resolved_scope.where(environment: [ env, nil ])
91
+ .order(Arel.sql("CASE WHEN environment IS NULL THEN 1 ELSE 0 END"))
92
+ end
93
+ resolved = resolved_scope.order(last_seen_at: :desc).first
76
94
  if resolved
77
95
  attrs = {
78
96
  resolved: false,
@@ -82,6 +100,7 @@ module RailsErrorDashboard
82
100
  last_seen_at: last_seen
83
101
  }
84
102
  attrs[:reopened_at] = Time.current if ErrorLog.column_names.include?("reopened_at")
103
+ attrs[:environment] = env if env && resolved.environment.blank?
85
104
  resolved.update!(attrs)
86
105
  return count
87
106
  end
@@ -89,7 +108,11 @@ module RailsErrorDashboard
89
108
  # Priority 3: first seen during count-only mode — minimal ErrorLog
90
109
  # from the exemplar (no backtrace/context was captured; the next
91
110
  # occurrence after the storm fills in detail via the normal path)
111
+ create_attrs = {
112
+ environment: env
113
+ }.compact
92
114
  ErrorLog.create!(
115
+ **create_attrs,
93
116
  application_id: application.id,
94
117
  error_type: entry["error_class"],
95
118
  message: entry["message"],
@@ -123,6 +146,14 @@ module RailsErrorDashboard
123
146
  Digest::SHA256.hexdigest(digest_input)[0..15]
124
147
  end
125
148
 
149
+ # nil when the column is not migrated yet, so every environment clause
150
+ # above disappears and the command behaves exactly as before.
151
+ def current_environment
152
+ return nil unless ErrorLog.column_names.include?("environment")
153
+
154
+ RailsErrorDashboard.configuration.current_environment
155
+ end
156
+
126
157
  def resolve_application
127
158
  # Same chain LogError uses — app name is process-global
128
159
  app_name = RailsErrorDashboard.configuration.application_name ||
@@ -290,6 +290,12 @@ module RailsErrorDashboard
290
290
  attributes[:environment_info] = Services::EnvironmentSnapshot.snapshot.to_json
291
291
  end
292
292
 
293
+ # Environment awareness (if column exists). context wins so a sender
294
+ # elsewhere can attribute an event to its own environment.
295
+ if ErrorLog.column_names.include?("environment")
296
+ attributes[:environment] = resolve_environment
297
+ end
298
+
293
299
  # Apply sensitive data filtering (on by default)
294
300
  attributes = Services::SensitiveDataFilter.filter_attributes(attributes)
295
301
 
@@ -424,12 +430,22 @@ module RailsErrorDashboard
424
430
  def maybe_notify(error_log)
425
431
  return if error_log.muted?
426
432
  return if Services::StormProtection::Gate.notifications_suppressed?
433
+ return unless Services::NotificationThrottler.environment_allowed?(error_log)
427
434
  return unless yield
428
435
 
429
436
  Services::ErrorNotificationDispatcher.call(error_log)
430
437
  Services::NotificationThrottler.record_notification(error_log)
431
438
  end
432
439
 
440
+ # The environment this error is attributed to: an explicit context value
441
+ # (truncated to the column) or the process-wide resolution. Never nil.
442
+ def resolve_environment
443
+ name = @context[:environment].to_s.strip
444
+ return name[0, 64] unless name.empty?
445
+
446
+ RailsErrorDashboard.configuration.current_environment
447
+ end
448
+
433
449
  # Find or create application for multi-app support
434
450
  def find_or_create_application
435
451
  app_name = RailsErrorDashboard.configuration.application_name ||
@@ -492,6 +508,7 @@ module RailsErrorDashboard
492
508
  # Return early if baseline alerts are disabled or error is muted
493
509
  return unless config.enable_baseline_alerts
494
510
  return if error_log.muted?
511
+ return unless Services::NotificationThrottler.environment_allowed?(error_log)
495
512
  return unless defined?(Queries::BaselineStats)
496
513
  return unless defined?(BaselineAlertJob)
497
514
 
@@ -14,6 +14,12 @@ module RailsErrorDashboard
14
14
  attr_accessor :application_name
15
15
  attr_accessor :database # Database connection name for shared error dashboard DB
16
16
 
17
+ # Environment awareness. Errors record which environment they came from
18
+ # (production, staging, uat, ...). Names are free-form strings, never an
19
+ # enum -- teams invent environment names, and a fixed list is wrong tomorrow.
20
+ attr_accessor :environment # Overrides Rails.env for captured errors (ENV: ERROR_DASHBOARD_ENVIRONMENT)
21
+ attr_accessor :notification_environments # Only notify for these environments; nil = all (ENV: ERROR_DASHBOARD_NOTIFICATION_ENVIRONMENTS)
22
+
17
23
  # Notifications
18
24
  attr_accessor :slack_webhook_url
19
25
  attr_accessor :notification_email_recipients
@@ -262,6 +268,10 @@ module RailsErrorDashboard
262
268
  @application_name = ENV["APPLICATION_NAME"] # Auto-detected if not set
263
269
  @database = nil # Use primary database by default
264
270
 
271
+ # Environment awareness defaults: attribute to Rails.env unless overridden
272
+ @environment = ENV["ERROR_DASHBOARD_ENVIRONMENT"].to_s.strip.then { |v| v.empty? ? nil : v }
273
+ @notification_environments = parse_name_list(ENV["ERROR_DASHBOARD_NOTIFICATION_ENVIRONMENTS"])
274
+
265
275
  # Notification settings (disabled by default - enable during installation or in initializer)
266
276
  @slack_webhook_url = ENV["SLACK_WEBHOOK_URL"]
267
277
  @notification_email_recipients = ENV.fetch("ERROR_NOTIFICATION_EMAILS", "").split(",").map(&:strip)
@@ -792,6 +802,26 @@ module RailsErrorDashboard
792
802
  errors << "total_users_for_impact must be at least 1 (got: #{total_users_for_impact})"
793
803
  end
794
804
 
805
+ # Validate environment (free-form, but it has to fit the 64-char column)
806
+ unless environment.nil?
807
+ if !environment.is_a?(String) || environment.strip.empty?
808
+ errors << "environment must not be blank (got: #{environment.inspect}); leave it nil to use Rails.env"
809
+ elsif environment.length > 64
810
+ errors << "environment must be 64 characters or fewer (got #{environment.length})"
811
+ end
812
+ end
813
+
814
+ # Validate notification_environments (nil = notify everywhere; otherwise names only)
815
+ unless notification_environments.nil?
816
+ valid_list = notification_environments.is_a?(Array) &&
817
+ notification_environments.any? &&
818
+ notification_environments.all? { |name| name.is_a?(String) && !name.strip.empty? }
819
+ unless valid_list
820
+ errors << "notification_environments must be nil or a non-empty Array of environment names " \
821
+ "(got: #{notification_environments.inspect})"
822
+ end
823
+ end
824
+
795
825
  # Validate notification_minimum_severity (must be valid symbol)
796
826
  if notification_minimum_severity
797
827
  valid_notification_severities = %i[critical high medium low]
@@ -822,6 +852,28 @@ module RailsErrorDashboard
822
852
  true
823
853
  end
824
854
 
855
+ # The environment this process attributes captured errors to.
856
+ #
857
+ # Explicit option first, then Rails.env. Never nil and never raises: an
858
+ # error must still be captured when the environment cannot be named.
859
+ #
860
+ # @return [String]
861
+ def current_environment
862
+ name = environment.to_s.strip
863
+ return name unless name.empty?
864
+
865
+ rails_env = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s.strip : ""
866
+ rails_env.empty? ? "unknown" : rails_env
867
+ rescue StandardError
868
+ "unknown"
869
+ end
870
+
871
+ # "production, uat" -> ["production", "uat"]; blank or all-blank -> nil.
872
+ def parse_name_list(raw)
873
+ list = raw.to_s.split(",").map(&:strip).reject(&:empty?)
874
+ list.empty? ? nil : list
875
+ end
876
+
825
877
  # Check if using default or blank demo credentials with basic auth
826
878
  #
827
879
  # Returns false if the user explicitly set ENV vars (even to the same default values),
@@ -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)
@@ -25,6 +25,7 @@ module RailsErrorDashboard
25
25
  errors_over_time: errors_over_time,
26
26
  errors_by_type: errors_by_type,
27
27
  errors_by_platform: errors_by_platform,
28
+ errors_by_environment: errors_by_environment,
28
29
  errors_by_hour: errors_by_hour,
29
30
  top_users: top_affected_users,
30
31
  resolution_rate: resolution_rate,
@@ -88,6 +89,14 @@ module RailsErrorDashboard
88
89
  base_query.group(:platform).count
89
90
  end
90
91
 
92
+ # NULL (captured before the column existed) is reported under :unknown
93
+ # rather than dropped, so the chart's total still matches the period.
94
+ def errors_by_environment
95
+ return {} unless ErrorLog.column_names.include?("environment")
96
+
97
+ base_query.group(:environment).count.transform_keys { |env| env.nil? ? :unknown : env }
98
+ end
99
+
91
100
  def errors_by_hour
92
101
  # group_by_hour_of_day buckets into 0..23 to show diurnal patterns
93
102
  # (when in the day errors peak). The chart title says "Errors by Hour
@@ -28,6 +28,7 @@ module RailsErrorDashboard
28
28
  query = filter_by_error_type(query)
29
29
  query = filter_by_resolved(query)
30
30
  query = filter_by_platform(query)
31
+ query = filter_by_environment(query)
31
32
  query = filter_by_application(query)
32
33
  query = filter_by_user_id(query)
33
34
  query = filter_by_app_version(query)
@@ -92,6 +93,13 @@ module RailsErrorDashboard
92
93
  query.where(platform: @filters[:platform])
93
94
  end
94
95
 
96
+ def filter_by_environment(query)
97
+ return query unless @filters[:environment].present?
98
+ return query unless ErrorLog.column_names.include?("environment")
99
+
100
+ query.where(environment: @filters[:environment])
101
+ end
102
+
95
103
  def filter_by_application(query)
96
104
  return query unless @filters[:application_id].present?
97
105
 
@@ -17,11 +17,20 @@ module RailsErrorDashboard
17
17
  {
18
18
  error_types: base_scope.distinct.pluck(:error_type).compact.sort,
19
19
  platforms: base_scope.distinct.pluck(:platform).compact,
20
+ environments: environments,
20
21
  applications: Application.ordered_by_name.pluck(:name, :id),
21
22
  assignees: assignees
22
23
  }
23
24
  end
24
25
 
26
+ # Sorted so the select is stable between requests. Empty until the
27
+ # environment migration has run, so the index simply shows no filter.
28
+ def environments
29
+ return [] unless ErrorLog.column_names.include?("environment")
30
+
31
+ base_scope.distinct.pluck(:environment).compact.sort
32
+ end
33
+
25
34
  def assignees
26
35
  base_scope.where.not(assigned_to: nil)
27
36
  .select(:assigned_to)
@@ -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] }