rails_error_dashboard 0.10.0 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +3 -0
  4. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
  5. data/app/models/rails_error_dashboard/error_log.rb +1 -0
  6. data/app/views/layouts/rails_error_dashboard.html.erb +30 -0
  7. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
  8. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
  10. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
  11. data/app/views/rails_error_dashboard/errors/analytics.html.erb +51 -1
  12. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
  13. data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
  14. data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
  15. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  16. data/config/locales/de.yml +19 -2
  17. data/config/locales/en.yml +17 -0
  18. data/config/locales/es.yml +18 -2
  19. data/config/locales/fr.yml +19 -2
  20. data/config/locales/it.yml +27 -3
  21. data/config/locales/ja.yml +17 -3
  22. data/config/locales/pl.yml +19 -2
  23. data/config/locales/pt-BR.yml +18 -2
  24. data/config/locales/ru.yml +18 -2
  25. data/config/locales/uk.yml +18 -2
  26. data/config/locales/zh-CN.yml +15 -3
  27. data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
  28. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
  29. data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
  30. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +55 -29
  31. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
  32. data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
  33. data/lib/rails_error_dashboard/configuration.rb +52 -0
  34. data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
  35. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  36. data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
  37. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
  38. data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
  39. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
  40. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
  41. data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
  42. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
  43. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
  44. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
  45. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
  46. data/lib/rails_error_dashboard/version.rb +1 -1
  47. data/lib/rails_error_dashboard.rb +1 -0
  48. data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
  49. metadata +4 -2
@@ -137,6 +137,7 @@ zh-CN:
137
137
  any: 任意严重程度
138
138
  all_apps: 全部应用
139
139
  all_platforms: 全部平台
140
+ all_environments: 全部环境
140
141
  all_types: 全部类型
141
142
  all_timeframes: 全部时间
142
143
  all_frequencies: 全部频率
@@ -165,6 +166,7 @@ zh-CN:
165
166
  search: 搜索:%{value}
166
167
  application: 应用:%{value}
167
168
  platform: 平台:%{value}
169
+ environment: 环境:%{value}
168
170
  error_type: 类型:%{value}
169
171
  severity: 严重程度:%{value}
170
172
  timeframe: 时间范围:%{value}
@@ -180,6 +182,7 @@ zh-CN:
180
182
  last_seen: 最近出现
181
183
  application: 应用
182
184
  platform: 平台
185
+ environment: 环境
183
186
  select_all: 选择全部错误
184
187
  batch:
185
188
  selected:
@@ -566,6 +569,7 @@ zh-CN:
566
569
  low: 低
567
570
  platform: 平台
568
571
  view_all_platform: 查看 %{platform} 的所有错误
572
+ view_all_environment: 查看 %{environment} 的所有错误
569
573
  user: 用户
570
574
  view_user_errors: 查看该用户的所有错误
571
575
  user_id: ID:%{id}
@@ -759,8 +763,7 @@ zh-CN:
759
763
  column_ips: IP 地址
760
764
  column_top_path: 主要路径
761
765
  column_top_agent: 主要代理
762
- overflow_notice_html: >-
763
- 由于跟踪器缓冲区已满,%{count} 个事件无法归属到任何规则。请调高 %{option} 以捕获更多事件。
766
+ overflow_notice_html: 由于跟踪器缓冲区已满,%{count} 个事件无法归属到任何规则。请调高 %{option} 以捕获更多事件。
764
767
  match_type:
765
768
  blocklist: blocklist
766
769
  throttle: throttle
@@ -950,6 +953,8 @@ zh-CN:
950
953
  max_backtrace_lines: 堆栈跟踪深度上限
951
954
  sampling_rate: 错误捕获的采样比例
952
955
  application_name: 本应用的名称(未设置时自动检测)
956
+ environment: 错误所属的环境(未设置时为 Rails.env)
957
+ notification_environments: 仅对这些环境发送通知(未设置时为全部)
953
958
  database: 错误日志使用的数据库
954
959
  use_separate_database: 为错误日志使用独立数据库
955
960
  user_model: 用户关联所用的模型名(未设置时自动检测)
@@ -1087,11 +1092,13 @@ zh-CN:
1087
1092
  unknown_application: 未知
1088
1093
  error_alert:
1089
1094
  subject: "\U0001F6A8 [%{application}] %{error_type}:%{message}"
1095
+ subject_with_environment: "\U0001F6A8 [%{application} · %{environment}] %{error_type}:%{message}"
1090
1096
  heading: "\U0001F6A8 错误告警"
1091
1097
  labels:
1092
1098
  application: 应用
1093
1099
  error_type: 错误类型
1094
1100
  platform: 平台
1101
+ environment: 环境
1095
1102
  occurred_at: 发生时间
1096
1103
  user_id: 用户 ID
1097
1104
  ip_address: IP 地址
@@ -1154,6 +1161,7 @@ zh-CN:
1154
1161
  application: 应用
1155
1162
  error_type: 错误类型
1156
1163
  platform: 平台
1164
+ environment: 环境
1157
1165
  occurred: 发生时间
1158
1166
  message: 消息
1159
1167
  user: 用户
@@ -1452,6 +1460,10 @@ zh-CN:
1452
1460
  platform_chart:
1453
1461
  title: 按平台统计错误
1454
1462
  quick_links: 快捷链接:
1463
+ environment_chart:
1464
+ title: 按环境统计的错误
1465
+ quick_links: 快捷链接:
1466
+ unknown: 未知
1455
1467
  type_chart:
1456
1468
  title: 出现最多的 10 种错误类型
1457
1469
  hour_chart:
@@ -1791,6 +1803,6 @@ zh-CN:
1791
1803
  axis_count: 数量
1792
1804
  axis_number_of_errors: 错误数量
1793
1805
  axis_hours: 小时
1794
- suffix_hours: "小时"
1806
+ suffix_hours: 小时
1795
1807
  series_total_errors: 错误总数
1796
1808
  series_hours_to_resolve: 解决耗时(小时)
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Records which environment an error came from (production, staging, uat, ...).
4
+ #
5
+ # RED had this column in v0.1.x and dropped it in the v1.0-launch refactor. It
6
+ # returns as a nullable string: NULL means "captured before this column
7
+ # existed", which is information the dashboard uses (no badge, matched as a
8
+ # wildcard by FindOrIncrementError and adopted on the next occurrence), not a
9
+ # gap to paper over with a default. `rails_error_dashboard:backfill_environments`
10
+ # fills NULLs from environment_info.rails_env for anyone who wants history too.
11
+ class AddEnvironmentToErrorLogs < ActiveRecord::Migration[7.0]
12
+ def change
13
+ # Guard against the squashed schema migration having already added this
14
+ # column -- without it, every later migration is silently cancelled.
15
+ return if column_exists?(:rails_error_dashboard_error_logs, :environment)
16
+
17
+ # 64 chars: long enough for "preprod-eu-west-2-canary", short enough that
18
+ # the composite index below stays cheap on MySQL's utf8mb4 key limit.
19
+ add_column :rails_error_dashboard_error_logs, :environment, :string, limit: 64
20
+
21
+ # Mirrors index_error_logs_on_platform_and_occurred_at: the index filter is
22
+ # "this environment, newest first".
23
+ add_index :rails_error_dashboard_error_logs, [ :environment, :occurred_at ],
24
+ name: "index_error_logs_on_environment_and_occurred_at"
25
+ end
26
+ end
@@ -11,6 +11,17 @@ RailsErrorDashboard.configure do |config|
11
11
  config.dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", "gandalf")
12
12
  config.dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", "youshallnotpass")
13
13
 
14
+ # Environment awareness (v0.11.0). Every error records the environment it
15
+ # came from, the index can filter by it, and staging can be kept out of
16
+ # your pager. Defaults to Rails.env; override when a deploy runs under
17
+ # RAILS_ENV=production but is really "staging", "uat", "preprod"... names
18
+ # are free-form, never an enum.
19
+ # config.environment = ENV.fetch("ERROR_DASHBOARD_ENVIRONMENT", "staging")
20
+ #
21
+ # Only notify (Slack, email, Discord, PagerDuty, webhooks, storm and
22
+ # baseline alerts) for these environments. nil = every environment.
23
+ # config.notification_environments = %w[production]
24
+
14
25
  # === Custom Authentication (optional) ===
15
26
  # Use your app's existing auth instead of HTTP Basic Auth.
16
27
  # The lambda runs in controller context (via instance_exec), giving access to
@@ -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)
@@ -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),
@@ -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)
@@ -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