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.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +3 -0
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
- data/app/models/rails_error_dashboard/error_log.rb +1 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +30 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +51 -1
- data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
- data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/config/locales/de.yml +19 -2
- data/config/locales/en.yml +17 -0
- data/config/locales/es.yml +18 -2
- data/config/locales/fr.yml +19 -2
- data/config/locales/it.yml +27 -3
- data/config/locales/ja.yml +17 -3
- data/config/locales/pl.yml +19 -2
- data/config/locales/pt-BR.yml +18 -2
- data/config/locales/ru.yml +18 -2
- data/config/locales/uk.yml +18 -2
- data/config/locales/zh-CN.yml +15 -3
- data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
- data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +55 -29
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
- data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
- data/lib/rails_error_dashboard/configuration.rb +52 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
- data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
- data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
- data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
- data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
- data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
- data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
- data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
- data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +1 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
- metadata +4 -2
data/config/locales/zh-CN.yml
CHANGED
|
@@ -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
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
86
|
+
resolved_scope = ErrorLog
|
|
72
87
|
.where(error_hash: error_hash, application_id: application.id)
|
|
73
88
|
.where(status: %w[resolved wont_fix])
|
|
74
|
-
|
|
75
|
-
.
|
|
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
|