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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Classifies a User-Agent string into a coarse traffic kind, and names the
|
|
6
|
+
# agent when it is a recognised one.
|
|
7
|
+
#
|
|
8
|
+
# WHY THIS EXISTS (issue #170): tracking which AI agents read an app is the
|
|
9
|
+
# reason people reach for Rack::Attack's `track` rules now. Counting IPs
|
|
10
|
+
# cannot answer it — one agent is a rotating fleet of hundreds of addresses,
|
|
11
|
+
# so unique-IP totals overstate the population badly. The user agent is the
|
|
12
|
+
# signal that actually identifies the reader.
|
|
13
|
+
#
|
|
14
|
+
# Deliberately plain string matching, NOT the `browser` gem: `browser` is an
|
|
15
|
+
# optional dependency that degrades gracefully everywhere else in this gem,
|
|
16
|
+
# and it does not know these agents anyway. This runs on the flush path, not
|
|
17
|
+
# the request path, but it stays allocation-cheap regardless.
|
|
18
|
+
#
|
|
19
|
+
# The bot lists are necessarily a snapshot. An unrecognised agent falls back
|
|
20
|
+
# to :other rather than being guessed at — a wrong attribution is worse than
|
|
21
|
+
# an honest "unknown" when the whole point is measurement.
|
|
22
|
+
class AiAgentClassifier
|
|
23
|
+
# Order matters: the first match wins, so more specific patterns lead.
|
|
24
|
+
#
|
|
25
|
+
# AI agents split into two behaviours worth telling apart, because they
|
|
26
|
+
# answer different questions:
|
|
27
|
+
# - :ai_assistant — fetches on demand, because a human asked something now
|
|
28
|
+
# - :ai_crawler — bulk-fetches to build a training corpus or index
|
|
29
|
+
AI_ASSISTANTS = {
|
|
30
|
+
"ChatGPT-User" => /ChatGPT-User/i,
|
|
31
|
+
"Claude-User" => /Claude-User/i,
|
|
32
|
+
"Claude Code" => /Claude-?Code/i,
|
|
33
|
+
"Perplexity-User" => /Perplexity-User/i,
|
|
34
|
+
"Gemini-User" => /Gemini-User/i
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
AI_CRAWLERS = {
|
|
38
|
+
"GPTBot" => /GPTBot/i,
|
|
39
|
+
"OAI-SearchBot" => /OAI-SearchBot/i,
|
|
40
|
+
"ClaudeBot" => /ClaudeBot/i,
|
|
41
|
+
"anthropic-ai" => /anthropic-ai/i,
|
|
42
|
+
"PerplexityBot" => /PerplexityBot/i,
|
|
43
|
+
"Google-Extended" => /Google-Extended/i,
|
|
44
|
+
"Applebot-Extended" => /Applebot-Extended/i,
|
|
45
|
+
"Bytespider" => /Bytespider/i,
|
|
46
|
+
"CCBot" => /CCBot/i,
|
|
47
|
+
"Meta-ExternalAgent" => /Meta-ExternalAgent/i,
|
|
48
|
+
"Amazonbot" => /Amazonbot/i,
|
|
49
|
+
"cohere-ai" => /cohere-ai/i,
|
|
50
|
+
"DuckAssistBot" => /DuckAssistBot/i,
|
|
51
|
+
"YouBot" => /YouBot/i,
|
|
52
|
+
"Diffbot" => /Diffbot/i,
|
|
53
|
+
"Timpibot" => /Timpibot/i
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# Conventional search/SEO crawlers. Not AI traffic, but worth naming so
|
|
57
|
+
# they can be excluded rather than silently inflating an "unknown" bucket.
|
|
58
|
+
CRAWLERS = {
|
|
59
|
+
"Googlebot" => /Googlebot/i,
|
|
60
|
+
"Bingbot" => /bingbot/i,
|
|
61
|
+
"DuckDuckBot" => /DuckDuckBot/i,
|
|
62
|
+
"Baiduspider" => /Baiduspider/i,
|
|
63
|
+
"YandexBot" => /YandexBot/i,
|
|
64
|
+
"AhrefsBot" => /AhrefsBot/i,
|
|
65
|
+
"SemrushBot" => /SemrushBot/i,
|
|
66
|
+
"Applebot" => /Applebot/i,
|
|
67
|
+
"facebookexternalhit" => /facebookexternalhit/i,
|
|
68
|
+
"LLMS-Txt-Scanner" => /LLMS-Txt-Scanner/i
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
# Checked only after every bot pattern has missed, because plenty of bots
|
|
72
|
+
# embed a full browser UA string and would match these first.
|
|
73
|
+
BROWSER_HINTS = /Mozilla|Chrome|Safari|Firefox|Edge|Opera|Gecko|WebKit/i
|
|
74
|
+
|
|
75
|
+
# Non-browser HTTP clients — usually scripts, monitors or scrapers.
|
|
76
|
+
LIBRARIES = {
|
|
77
|
+
"curl" => /\bcurl\//i,
|
|
78
|
+
"wget" => /\bWget\//i,
|
|
79
|
+
"python-requests" => /python-requests/i,
|
|
80
|
+
"httpx" => /\bhttpx\//i,
|
|
81
|
+
"Go-http-client" => /Go-http-client/i,
|
|
82
|
+
"Java" => /\bJava\//i,
|
|
83
|
+
"okhttp" => /\bokhttp\//i,
|
|
84
|
+
"axios" => /\baxios\//i,
|
|
85
|
+
"Faraday" => /Faraday/i,
|
|
86
|
+
"RubyGems" => /Ruby\b/i
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
KINDS = %i[ai_assistant ai_crawler crawler browser library other].freeze
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
# @param user_agent [String, nil]
|
|
93
|
+
# @return [Symbol] one of KINDS
|
|
94
|
+
def kind(user_agent)
|
|
95
|
+
ua = user_agent.to_s
|
|
96
|
+
return :other if ua.strip.empty?
|
|
97
|
+
|
|
98
|
+
return :ai_assistant if match_name(AI_ASSISTANTS, ua)
|
|
99
|
+
return :ai_crawler if match_name(AI_CRAWLERS, ua)
|
|
100
|
+
return :crawler if match_name(CRAWLERS, ua)
|
|
101
|
+
return :library if match_name(LIBRARIES, ua)
|
|
102
|
+
return :browser if ua.match?(BROWSER_HINTS)
|
|
103
|
+
|
|
104
|
+
:other
|
|
105
|
+
rescue => e
|
|
106
|
+
:other
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Canonical name for a recognised agent, or nil when unrecognised.
|
|
110
|
+
# Never invents a name — callers show the raw UA in that case.
|
|
111
|
+
#
|
|
112
|
+
# @param user_agent [String, nil]
|
|
113
|
+
# @return [String, nil]
|
|
114
|
+
def name(user_agent)
|
|
115
|
+
ua = user_agent.to_s
|
|
116
|
+
return nil if ua.strip.empty?
|
|
117
|
+
|
|
118
|
+
match_name(AI_ASSISTANTS, ua) ||
|
|
119
|
+
match_name(AI_CRAWLERS, ua) ||
|
|
120
|
+
match_name(CRAWLERS, ua) ||
|
|
121
|
+
match_name(LIBRARIES, ua)
|
|
122
|
+
rescue => e
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Whether this agent is an LLM reader of either flavour. This is the
|
|
127
|
+
# predicate the dashboard's "AI agents" figure counts.
|
|
128
|
+
#
|
|
129
|
+
# @param user_agent [String, nil]
|
|
130
|
+
# @return [Boolean]
|
|
131
|
+
def ai?(user_agent)
|
|
132
|
+
%i[ai_assistant ai_crawler].include?(kind(user_agent))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Hash] { kind:, name:, ai: } — one pass for callers that want all three
|
|
136
|
+
def classify(user_agent)
|
|
137
|
+
k = kind(user_agent)
|
|
138
|
+
{
|
|
139
|
+
kind: k,
|
|
140
|
+
name: name(user_agent),
|
|
141
|
+
ai: %i[ai_assistant ai_crawler].include?(k)
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def match_name(table, ua)
|
|
148
|
+
table.each { |agent_name, pattern| return agent_name if ua.match?(pattern) }
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -47,6 +47,7 @@ module RailsErrorDashboard
|
|
|
47
47
|
value: error_log.platform || unknown,
|
|
48
48
|
inline: true
|
|
49
49
|
},
|
|
50
|
+
*environment_fields(error_log, locale),
|
|
50
51
|
{
|
|
51
52
|
name: NotificationHelpers.label(:occurrences, locale),
|
|
52
53
|
value: error_log.occurrence_count.to_s,
|
|
@@ -84,6 +85,13 @@ module RailsErrorDashboard
|
|
|
84
85
|
|
|
85
86
|
# @param error_log [ErrorLog] The error
|
|
86
87
|
# @return [Integer] Discord color integer
|
|
88
|
+
# One field when the error carries an environment, none for a legacy row.
|
|
89
|
+
def self.environment_fields(error_log, locale)
|
|
90
|
+
return [] unless error_log.respond_to?(:environment) && error_log.environment.present?
|
|
91
|
+
|
|
92
|
+
[ { name: NotificationHelpers.label(:environment, locale), value: error_log.environment, inline: true } ]
|
|
93
|
+
end
|
|
94
|
+
|
|
87
95
|
def self.severity_color(error_log)
|
|
88
96
|
SEVERITY_COLORS[error_log.severity] || DEFAULT_COLOR
|
|
89
97
|
end
|
|
@@ -23,9 +23,11 @@ module RailsErrorDashboard
|
|
|
23
23
|
|
|
24
24
|
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
25
25
|
show_platform = platforms.size > 1
|
|
26
|
+
show_environment = ErrorLog.column_names.include?("environment") &&
|
|
27
|
+
ErrorLog.distinct.pluck(:environment).compact.size > 1
|
|
26
28
|
|
|
27
29
|
html = render_partial("rails_error_dashboard/errors/error_row",
|
|
28
|
-
error: error_log, show_platform: show_platform)
|
|
30
|
+
error: error_log, show_platform: show_platform, show_environment: show_environment)
|
|
29
31
|
|
|
30
32
|
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
31
33
|
"error_list",
|
|
@@ -46,9 +48,11 @@ module RailsErrorDashboard
|
|
|
46
48
|
|
|
47
49
|
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
48
50
|
show_platform = platforms.size > 1
|
|
51
|
+
show_environment = ErrorLog.column_names.include?("environment") &&
|
|
52
|
+
ErrorLog.distinct.pluck(:environment).compact.size > 1
|
|
49
53
|
|
|
50
54
|
html = render_partial("rails_error_dashboard/errors/error_row",
|
|
51
|
-
error: error_log, show_platform: show_platform)
|
|
55
|
+
error: error_log, show_platform: show_platform, show_environment: show_environment)
|
|
52
56
|
|
|
53
57
|
Turbo::StreamsChannel.broadcast_replace_to(
|
|
54
58
|
"error_list",
|
|
@@ -104,6 +104,7 @@ module RailsErrorDashboard
|
|
|
104
104
|
|
|
105
105
|
def metadata_section
|
|
106
106
|
items = []
|
|
107
|
+
items << "- **Environment:** #{@error.environment}" if @error.respond_to?(:environment) && @error.environment.present?
|
|
107
108
|
items << "- **Platform:** #{@error.platform}" if @error.platform.present?
|
|
108
109
|
items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
|
|
109
110
|
items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
|
|
@@ -435,6 +435,7 @@ module RailsErrorDashboard
|
|
|
435
435
|
|
|
436
436
|
def metadata_section
|
|
437
437
|
items = []
|
|
438
|
+
items << "- **Environment:** #{@error.environment}" if @error.respond_to?(:environment) && @error.environment.present?
|
|
438
439
|
items << "- **Platform:** #{@error.platform}" if @error.platform.present?
|
|
439
440
|
items << "- **First seen:** #{@error.first_seen_at&.utc&.strftime("%Y-%m-%d %H:%M:%S UTC")}" if @error.first_seen_at
|
|
440
441
|
items << "- **Occurrences:** #{@error.occurrence_count}" if @error.occurrence_count
|
|
@@ -29,6 +29,29 @@ module RailsErrorDashboard
|
|
|
29
29
|
true
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Does this error's environment pass config.notification_environments?
|
|
33
|
+
#
|
|
34
|
+
# nil list → everything notifies (the pre-0.11 behaviour). Accepts an
|
|
35
|
+
# ErrorLog, a bare environment name, or nothing (the process
|
|
36
|
+
# environment) — the storm and baseline paths have no row to hand over.
|
|
37
|
+
# A legacy row with a NULL environment is judged as the process
|
|
38
|
+
# environment, so upgrading never silences an error that was notifying
|
|
39
|
+
# before. Fails open: an allowlist bug must not lose a page.
|
|
40
|
+
#
|
|
41
|
+
# @param subject [ErrorLog, String, nil]
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def environment_allowed?(subject = nil)
|
|
44
|
+
allowed = RailsErrorDashboard.configuration.notification_environments
|
|
45
|
+
return true if allowed.nil?
|
|
46
|
+
|
|
47
|
+
name = subject.respond_to?(:environment) ? subject.environment : subject
|
|
48
|
+
name = RailsErrorDashboard.configuration.current_environment if name.blank?
|
|
49
|
+
allowed.include?(name.to_s)
|
|
50
|
+
rescue => e
|
|
51
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.environment_allowed? failed: #{e.message}")
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
32
55
|
# Does the error's severity meet the configured minimum?
|
|
33
56
|
# @param error_log [ErrorLog] The error to check
|
|
34
57
|
# @return [Boolean] true if severity is at or above minimum
|
|
@@ -46,6 +46,7 @@ module RailsErrorDashboard
|
|
|
46
46
|
controller: error_log.controller_name,
|
|
47
47
|
action: error_log.action_name,
|
|
48
48
|
platform: error_log.platform,
|
|
49
|
+
environment: error_log.environment,
|
|
49
50
|
occurrences: error_log.occurrence_count,
|
|
50
51
|
first_seen_at: error_log.first_seen_at&.iso8601,
|
|
51
52
|
last_seen_at: error_log.last_seen_at&.iso8601,
|
|
@@ -32,6 +32,14 @@ module RailsErrorDashboard
|
|
|
32
32
|
MAX_DISCRIMINATOR_LENGTH = 191
|
|
33
33
|
MAX_PATH_LENGTH = 191
|
|
34
34
|
MAX_METHOD_LENGTH = 10
|
|
35
|
+
MAX_USER_AGENT_LENGTH = 191
|
|
36
|
+
|
|
37
|
+
# Reserved rule/match_type used to account for counts dropped by LRU
|
|
38
|
+
# eviction. Without this the evicted count vanishes silently and the
|
|
39
|
+
# dashboard under-reports with no indication anything was lost — the same
|
|
40
|
+
# problem StormProtection::CountBuffer solves with an overflow counter.
|
|
41
|
+
OVERFLOW_RULE = "__overflow__"
|
|
42
|
+
OVERFLOW_MATCH_TYPE = "overflow"
|
|
35
43
|
|
|
36
44
|
# Separator for the composite buffer key. Chosen because it cannot appear in
|
|
37
45
|
# an HTTP method and is vanishingly unlikely in a rule name or path.
|
|
@@ -46,7 +54,9 @@ module RailsErrorDashboard
|
|
|
46
54
|
# @param discriminator [String] rate-limit key (usually IP or user id)
|
|
47
55
|
# @param path [String] request path
|
|
48
56
|
# @param http_method [String] request method
|
|
49
|
-
|
|
57
|
+
# @param user_agent [String] client user agent, for AI/crawler attribution
|
|
58
|
+
def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil,
|
|
59
|
+
user_agent: nil)
|
|
50
60
|
return unless enabled?
|
|
51
61
|
|
|
52
62
|
key = build_key(
|
|
@@ -54,15 +64,21 @@ module RailsErrorDashboard
|
|
|
54
64
|
match_type.to_s,
|
|
55
65
|
truncate(discriminator, MAX_DISCRIMINATOR_LENGTH),
|
|
56
66
|
truncate(path, MAX_PATH_LENGTH),
|
|
57
|
-
truncate(http_method, MAX_METHOD_LENGTH)
|
|
67
|
+
truncate(http_method, MAX_METHOD_LENGTH),
|
|
68
|
+
truncate(user_agent, MAX_USER_AGENT_LENGTH)
|
|
58
69
|
)
|
|
59
70
|
|
|
60
71
|
counts = (Thread.current[COUNTS_THREAD_KEY] ||= {})
|
|
61
72
|
counts[key] = (counts[key] || 0) + 1
|
|
62
73
|
|
|
63
|
-
# LRU eviction —
|
|
64
|
-
#
|
|
65
|
-
|
|
74
|
+
# LRU eviction — bounds memory under rotating-discriminator attacks.
|
|
75
|
+
# Loops because the overflow bucket occupies a slot of its own once
|
|
76
|
+
# created, so a single eviction may not bring the map back under cap.
|
|
77
|
+
# evict_oldest! returns false once only the overflow key is left, which
|
|
78
|
+
# guarantees termination even if max_cache_size is misconfigured to 0.
|
|
79
|
+
while counts.size > max_cache_size
|
|
80
|
+
break unless evict_oldest!(counts)
|
|
81
|
+
end
|
|
66
82
|
|
|
67
83
|
maybe_flush!
|
|
68
84
|
nil
|
|
@@ -93,6 +109,46 @@ module RailsErrorDashboard
|
|
|
93
109
|
nil
|
|
94
110
|
end
|
|
95
111
|
|
|
112
|
+
# Flush every live thread's buffer, not just the caller's.
|
|
113
|
+
#
|
|
114
|
+
# WHY: flush! only ever sees Thread.current. Buffers live on the Puma
|
|
115
|
+
# threads that served the requests, so at shutdown (and from a background
|
|
116
|
+
# job) the caller's own buffer is empty while the real counts sit on
|
|
117
|
+
# threads nobody is asking. Without this, everything buffered at SIGTERM
|
|
118
|
+
# is lost, and a rule that matches once and then sees no further traffic
|
|
119
|
+
# on that thread is never persisted at all.
|
|
120
|
+
#
|
|
121
|
+
# Thread#[] reads another thread's fiber-locals directly, so no thread
|
|
122
|
+
# registry is needed — the same approach SwallowedExceptionTracker uses.
|
|
123
|
+
# sync: true because callers are already off the request path.
|
|
124
|
+
def flush_all_threads!
|
|
125
|
+
Thread.list.each do |thread|
|
|
126
|
+
# Rescue per thread, not just around the whole loop: one thread
|
|
127
|
+
# whose write fails must not strand the buffers of every thread
|
|
128
|
+
# after it in the list.
|
|
129
|
+
begin
|
|
130
|
+
counts = thread[COUNTS_THREAD_KEY]
|
|
131
|
+
next if counts.nil? || counts.empty?
|
|
132
|
+
|
|
133
|
+
snapshot = counts.dup
|
|
134
|
+
counts.clear
|
|
135
|
+
thread[FLUSH_THREAD_KEY] = nil
|
|
136
|
+
|
|
137
|
+
dispatch_flush(snapshot, sync: true)
|
|
138
|
+
rescue => e
|
|
139
|
+
RailsErrorDashboard::Logger.debug(
|
|
140
|
+
"[RailsErrorDashboard] RackAttackTracker.flush_all_threads! skipped a thread: #{e.class} - #{e.message}"
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
nil
|
|
145
|
+
rescue => e
|
|
146
|
+
RailsErrorDashboard::Logger.debug(
|
|
147
|
+
"[RailsErrorDashboard] RackAttackTracker.flush_all_threads! failed: #{e.class} - #{e.message}"
|
|
148
|
+
)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
96
152
|
# Clear thread-local state without persisting. Used by specs and by
|
|
97
153
|
# thread teardown paths.
|
|
98
154
|
def reset!
|
|
@@ -111,9 +167,16 @@ module RailsErrorDashboard
|
|
|
111
167
|
end
|
|
112
168
|
|
|
113
169
|
# Decompose a buffer key back into its parts.
|
|
114
|
-
#
|
|
170
|
+
#
|
|
171
|
+
# The limit must match the field count exactly. With a limit of 5 the
|
|
172
|
+
# user agent would be glued onto http_method instead of standing alone.
|
|
173
|
+
# split also drops trailing empty fields without the limit, so a key
|
|
174
|
+
# whose user agent is blank must still yield six elements.
|
|
175
|
+
#
|
|
176
|
+
# @return [Array<String>] [rule, match_type, discriminator, path, http_method, user_agent]
|
|
115
177
|
def parse_key(key)
|
|
116
|
-
key.to_s.split(KEY_SEPARATOR,
|
|
178
|
+
parts = key.to_s.split(KEY_SEPARATOR, 6)
|
|
179
|
+
parts.fill("", parts.length, 6 - parts.length)
|
|
117
180
|
end
|
|
118
181
|
|
|
119
182
|
private
|
|
@@ -128,9 +191,28 @@ module RailsErrorDashboard
|
|
|
128
191
|
parts.map(&:to_s).join(KEY_SEPARATOR)
|
|
129
192
|
end
|
|
130
193
|
|
|
194
|
+
# Evict the oldest entry, rolling its count into the overflow bucket so
|
|
195
|
+
# the total stays truthful. Ruby hashes preserve insertion order, so the
|
|
196
|
+
# first key is the oldest.
|
|
197
|
+
#
|
|
198
|
+
# The overflow key is skipped when choosing a victim: it is written once
|
|
199
|
+
# and would otherwise be the oldest key forever, so evicting it would
|
|
200
|
+
# discard exactly the accounting this method exists to keep.
|
|
201
|
+
# @return [Boolean] true if an entry was evicted, false if the overflow
|
|
202
|
+
# bucket is all that remains (which is what terminates the caller's loop)
|
|
131
203
|
def evict_oldest!(hash)
|
|
132
|
-
oldest_key = hash.each_key.
|
|
133
|
-
|
|
204
|
+
oldest_key = hash.each_key.find { |k| k != overflow_key }
|
|
205
|
+
return false unless oldest_key
|
|
206
|
+
|
|
207
|
+
dropped = hash.delete(oldest_key).to_i
|
|
208
|
+
hash[overflow_key] = (hash[overflow_key] || 0) + dropped if dropped.positive?
|
|
209
|
+
true
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def overflow_key
|
|
213
|
+
@overflow_key ||= build_key(
|
|
214
|
+
OVERFLOW_RULE, OVERFLOW_MATCH_TYPE, "", "", "", ""
|
|
215
|
+
)
|
|
134
216
|
end
|
|
135
217
|
|
|
136
218
|
# Cheap periodic flush check — a float subtraction, no I/O.
|
|
@@ -64,6 +64,7 @@ module RailsErrorDashboard
|
|
|
64
64
|
locale
|
|
65
65
|
)
|
|
66
66
|
},
|
|
67
|
+
*environment_fields(error_log, locale),
|
|
67
68
|
{
|
|
68
69
|
type: "mrkdwn",
|
|
69
70
|
text: NotificationHelpers.field(
|
|
@@ -74,6 +75,14 @@ module RailsErrorDashboard
|
|
|
74
75
|
}
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# One field when the error carries an environment, none for a legacy
|
|
79
|
+
# row — so pre-0.11 notifications render exactly as they did.
|
|
80
|
+
def self.environment_fields(error_log, locale)
|
|
81
|
+
return [] unless error_log.respond_to?(:environment) && error_log.environment.present?
|
|
82
|
+
|
|
83
|
+
[ { type: "mrkdwn", text: NotificationHelpers.field(:environment, error_log.environment, locale) } ]
|
|
84
|
+
end
|
|
85
|
+
|
|
77
86
|
def self.message_block(error_log, locale)
|
|
78
87
|
{
|
|
79
88
|
type: "section",
|
|
@@ -232,6 +232,8 @@ module RailsErrorDashboard
|
|
|
232
232
|
def maybe_storm_notification(state)
|
|
233
233
|
return if state == :closed
|
|
234
234
|
return unless RailsErrorDashboard.configuration.storm_notification
|
|
235
|
+
# A storm on staging should not page whoever is on call for production.
|
|
236
|
+
return unless RailsErrorDashboard::Services::NotificationThrottler.environment_allowed?
|
|
235
237
|
|
|
236
238
|
episode = breaker.episode_snapshot
|
|
237
239
|
return unless episode
|
|
@@ -37,6 +37,7 @@ module RailsErrorDashboard
|
|
|
37
37
|
message: error_log.message,
|
|
38
38
|
severity: error_log.severity.to_s,
|
|
39
39
|
platform: error_log.platform,
|
|
40
|
+
environment: error_log.environment,
|
|
40
41
|
controller: error_log.controller_name,
|
|
41
42
|
action: error_log.action_name,
|
|
42
43
|
occurrence_count: error_log.occurrence_count,
|
|
@@ -70,9 +70,10 @@ module RailsErrorDashboard
|
|
|
70
70
|
|
|
71
71
|
match_type = event_name.split(".").first # "throttle", "blocklist", "track"
|
|
72
72
|
rule = env["rack.attack.matched"].to_s
|
|
73
|
-
discriminator = env
|
|
73
|
+
discriminator = resolve_discriminator(env, request)
|
|
74
74
|
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
75
75
|
method = request.respond_to?(:request_method) ? request.request_method.to_s : ""
|
|
76
|
+
user_agent = resolve_user_agent(request, env)
|
|
76
77
|
|
|
77
78
|
# Persist independently of error capture. A throttled request returns
|
|
78
79
|
# HTTP 429 and raises nothing, so it would otherwise never reach the
|
|
@@ -82,7 +83,8 @@ module RailsErrorDashboard
|
|
|
82
83
|
match_type: match_type,
|
|
83
84
|
discriminator: discriminator,
|
|
84
85
|
path: path,
|
|
85
|
-
http_method: method
|
|
86
|
+
http_method: method,
|
|
87
|
+
user_agent: user_agent
|
|
86
88
|
)
|
|
87
89
|
|
|
88
90
|
# Also record a breadcrumb so the event still shows up in the activity
|
|
@@ -102,6 +104,42 @@ module RailsErrorDashboard
|
|
|
102
104
|
|
|
103
105
|
Services::BreadcrumbCollector.add("rack_attack", message, metadata: metadata)
|
|
104
106
|
end
|
|
107
|
+
|
|
108
|
+
# Resolve the discriminator, falling back to the client IP.
|
|
109
|
+
#
|
|
110
|
+
# WHY (issue #170): a `track` rule declared without :limit/:period is a
|
|
111
|
+
# Rack::Attack::Check, and Check#matched_by? sets only "rack.attack.matched"
|
|
112
|
+
# and "rack.attack.match_type" — never "rack.attack.match_discriminator".
|
|
113
|
+
# Only Throttle#annotate_request_with_matched_data sets that key. The value
|
|
114
|
+
# the rule's block returns (typically `req.ip`) is used purely as a truthy
|
|
115
|
+
# match test and then discarded upstream.
|
|
116
|
+
#
|
|
117
|
+
# Without this fallback every track row stores a blank discriminator, so
|
|
118
|
+
# RackAttackSummary reports "Unique IPs: 0" for a rule that plainly matched
|
|
119
|
+
# real clients. We use request.ip rather than re-invoking the rule's block:
|
|
120
|
+
# the block is arbitrary host code that may have side effects or return a
|
|
121
|
+
# non-IP value, and re-running it from a notification subscriber would
|
|
122
|
+
# execute it a second time per request.
|
|
123
|
+
def resolve_discriminator(env, request)
|
|
124
|
+
explicit = env["rack.attack.match_discriminator"].to_s
|
|
125
|
+
return explicit unless explicit.empty?
|
|
126
|
+
|
|
127
|
+
# request.ip parses X-Forwarded-For and can raise on malformed input.
|
|
128
|
+
request.respond_to?(:ip) ? request.ip.to_s : ""
|
|
129
|
+
rescue => e
|
|
130
|
+
""
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The user agent identifies WHICH client matched a rule — the question
|
|
134
|
+
# IP counts cannot answer, since one AI agent is a rotating fleet of
|
|
135
|
+
# addresses (issue #170). Falls back to the raw env key so a request
|
|
136
|
+
# object that does not implement #user_agent still yields the value.
|
|
137
|
+
def resolve_user_agent(request, env)
|
|
138
|
+
ua = request.respond_to?(:user_agent) ? request.user_agent : nil
|
|
139
|
+
(ua || env["HTTP_USER_AGENT"]).to_s
|
|
140
|
+
rescue => e
|
|
141
|
+
""
|
|
142
|
+
end
|
|
105
143
|
end
|
|
106
144
|
end
|
|
107
145
|
end
|
|
@@ -84,6 +84,7 @@ require "rails_error_dashboard/services/variable_serializer"
|
|
|
84
84
|
require "rails_error_dashboard/services/local_variable_capturer"
|
|
85
85
|
require "rails_error_dashboard/services/swallowed_exception_tracker"
|
|
86
86
|
require "rails_error_dashboard/services/rack_attack_tracker"
|
|
87
|
+
require "rails_error_dashboard/services/ai_agent_classifier"
|
|
87
88
|
require "rails_error_dashboard/services/crash_capture"
|
|
88
89
|
require "rails_error_dashboard/services/diagnostic_dump_generator"
|
|
89
90
|
require "rails_error_dashboard/services/coverage_tracker"
|
|
@@ -108,6 +109,7 @@ require "rails_error_dashboard/commands/resolve_error"
|
|
|
108
109
|
require "rails_error_dashboard/commands/create_issue"
|
|
109
110
|
require "rails_error_dashboard/commands/link_existing_issue"
|
|
110
111
|
require "rails_error_dashboard/commands/flush_storm_counts"
|
|
112
|
+
require "rails_error_dashboard/commands/backfill_environments"
|
|
111
113
|
require "rails_error_dashboard/services/issue_body_formatter"
|
|
112
114
|
require "rails_error_dashboard/commands/batch_resolve_errors"
|
|
113
115
|
require "rails_error_dashboard/commands/batch_delete_errors"
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :rails_error_dashboard do
|
|
4
|
+
desc "Fill in environment on error logs captured before the column existed (from environment_info.rails_env)"
|
|
5
|
+
task backfill_environments: :environment do
|
|
6
|
+
updated = RailsErrorDashboard::Commands::BackfillEnvironments.call
|
|
7
|
+
puts "rails_error_dashboard: backfilled environment on #{updated} error log(s)."
|
|
8
|
+
end
|
|
9
|
+
|
|
4
10
|
namespace :db do
|
|
5
11
|
desc "Drop all Rails Error Dashboard database tables (⚠️ DESTRUCTIVE - deletes all error data)"
|
|
6
12
|
task drop: :environment do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -391,6 +391,8 @@ files:
|
|
|
391
391
|
- db/migrate/20260503000001_backfill_resolved_status.rb
|
|
392
392
|
- db/migrate/20260613000001_create_storm_events.rb
|
|
393
393
|
- db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
|
|
394
|
+
- db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb
|
|
395
|
+
- db/migrate/20260826000001_add_environment_to_error_logs.rb
|
|
394
396
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
395
397
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
396
398
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -400,6 +402,7 @@ files:
|
|
|
400
402
|
- lib/rails_error_dashboard.rb
|
|
401
403
|
- lib/rails_error_dashboard/commands/add_error_comment.rb
|
|
402
404
|
- lib/rails_error_dashboard/commands/assign_error.rb
|
|
405
|
+
- lib/rails_error_dashboard/commands/backfill_environments.rb
|
|
403
406
|
- lib/rails_error_dashboard/commands/batch_delete_errors.rb
|
|
404
407
|
- lib/rails_error_dashboard/commands/batch_mute_errors.rb
|
|
405
408
|
- lib/rails_error_dashboard/commands/batch_resolve_errors.rb
|
|
@@ -471,6 +474,7 @@ files:
|
|
|
471
474
|
- lib/rails_error_dashboard/queries/storm_history.rb
|
|
472
475
|
- lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
|
|
473
476
|
- lib/rails_error_dashboard/queries/user_impact_summary.rb
|
|
477
|
+
- lib/rails_error_dashboard/services/ai_agent_classifier.rb
|
|
474
478
|
- lib/rails_error_dashboard/services/analytics_cache_manager.rb
|
|
475
479
|
- lib/rails_error_dashboard/services/backtrace_parser.rb
|
|
476
480
|
- lib/rails_error_dashboard/services/backtrace_processor.rb
|
|
@@ -557,7 +561,7 @@ metadata:
|
|
|
557
561
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
558
562
|
post_install_message: |
|
|
559
563
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
560
|
-
RED (Rails Error Dashboard) v0.
|
|
564
|
+
RED (Rails Error Dashboard) v0.11.0
|
|
561
565
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
562
566
|
|
|
563
567
|
First install:
|