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.
- checksums.yaml +4 -4
- data/README.md +16 -3
- data/app/controllers/rails_error_dashboard/errors_controller.rb +9 -0
- data/app/helpers/rails_error_dashboard/i18n_helper.rb +26 -0
- data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +7 -3
- 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/models/rails_error_dashboard/rack_attack_event.rb +6 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +85 -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 +58 -13
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
- 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/platform_comparison.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +36 -3
- data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
- data/config/locales/de.yml +22 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/es.yml +21 -0
- data/config/locales/fr.yml +22 -0
- data/config/locales/it.yml +30 -1
- data/config/locales/ja.yml +19 -0
- data/config/locales/pl.yml +22 -0
- data/config/locales/pt-BR.yml +21 -0
- data/config/locales/ru.yml +21 -0
- data/config/locales/uk.yml +21 -0
- data/config/locales/zh-CN.yml +17 -0
- data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
- data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -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_rack_attack_events.rb +12 -3
- 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/engine.rb +7 -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/queries/rack_attack_summary.rb +31 -5
- data/lib/rails_error_dashboard/services/ai_agent_classifier.rb +154 -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/rack_attack_tracker.rb +91 -9
- 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/subscribers/rack_attack_subscriber.rb +40 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +2 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
- 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
|
-
|
|
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)
|
|
@@ -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:,
|
|
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
|
-
|
|
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),
|
|
@@ -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
|
|
41
|
-
|
|
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] }
|