rails_error_dashboard 0.9.0 → 0.10.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/app/controllers/rails_error_dashboard/errors_controller.rb +6 -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/models/rails_error_dashboard/rack_attack_event.rb +6 -2
- data/app/views/layouts/rails_error_dashboard.html.erb +55 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +7 -12
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +4 -4
- 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/config/locales/de.yml +5 -0
- data/config/locales/en.yml +7 -0
- data/config/locales/es.yml +5 -0
- data/config/locales/fr.yml +5 -0
- data/config/locales/it.yml +5 -0
- data/config/locales/ja.yml +5 -0
- data/config/locales/pl.yml +5 -0
- data/config/locales/pt-BR.yml +5 -0
- data/config/locales/ru.yml +5 -0
- data/config/locales/uk.yml +5 -0
- data/config/locales/zh-CN.yml +5 -0
- data/db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb +19 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +12 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +12 -3
- data/lib/rails_error_dashboard/configuration.rb +13 -3
- data/lib/rails_error_dashboard/engine.rb +7 -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/rack_attack_tracker.rb +91 -9
- 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 +1 -0
- metadata +15 -6
|
@@ -348,7 +348,19 @@ module RailsErrorDashboard
|
|
|
348
348
|
end
|
|
349
349
|
end
|
|
350
350
|
|
|
351
|
+
# Start after the highest timestamp already present, not at the current
|
|
352
|
+
# clock. The counter increments per copied file, so a re-run that happens
|
|
353
|
+
# within N seconds of the previous install (N = number of migrations)
|
|
354
|
+
# would otherwise reuse numbers the first install already consumed —
|
|
355
|
+
# and Rails aborts the whole `db:migrate` with "Multiple migrations have
|
|
356
|
+
# the version number ...", so the upgrade silently applies nothing.
|
|
351
357
|
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
|
358
|
+
highest_existing = [ "db/migrate", "db/error_dashboard_migrate" ].flat_map do |dir|
|
|
359
|
+
full_path = File.join(destination_root, dir)
|
|
360
|
+
next [] unless Dir.exist?(full_path)
|
|
361
|
+
Dir.glob(File.join(full_path, "*.rb")).map { |f| File.basename(f)[/^\d+/].to_i }
|
|
362
|
+
end.max
|
|
363
|
+
timestamp = highest_existing + 1 if highest_existing && highest_existing >= timestamp
|
|
352
364
|
|
|
353
365
|
Dir.glob(File.join(source_dir, "*.rb")).sort.each do |source_file|
|
|
354
366
|
basename = File.basename(source_file)
|
|
@@ -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!
|
|
@@ -478,12 +478,22 @@ module RailsErrorDashboard
|
|
|
478
478
|
errors = []
|
|
479
479
|
warnings = []
|
|
480
480
|
|
|
481
|
-
# Block boot with default or blank credentials
|
|
481
|
+
# Block boot with default or blank credentials anywhere that is not local
|
|
482
|
+
# development or test.
|
|
483
|
+
#
|
|
484
|
+
# Deliberately an ALLOWLIST of safe environments rather than a check for
|
|
485
|
+
# `production`. Gating on Rails.env.production? tests one literal string,
|
|
486
|
+
# so an internet-facing `staging`, `uat`, `demo` or `preprod` box booted
|
|
487
|
+
# happily on credentials this project publishes in its own README
|
|
488
|
+
# (GHSA-qhgm-3pxf-mvc6). Every future environment name a team invents is
|
|
489
|
+
# now refused by default and has to be added here on purpose.
|
|
490
|
+
#
|
|
482
491
|
# Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time
|
|
483
492
|
if default_credentials? &&
|
|
484
|
-
defined?(Rails) && Rails.respond_to?(:env) &&
|
|
493
|
+
defined?(Rails) && Rails.respond_to?(:env) &&
|
|
494
|
+
!Rails.env.development? && !Rails.env.test? &&
|
|
485
495
|
ENV["SECRET_KEY_BASE_DUMMY"].blank?
|
|
486
|
-
errors << "Default or blank credentials cannot be used in
|
|
496
|
+
errors << "Default or blank credentials cannot be used in #{Rails.env}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables, or use authenticate_with for custom auth."
|
|
487
497
|
end
|
|
488
498
|
|
|
489
499
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
@@ -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)
|
|
@@ -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] }
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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"
|
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.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -232,9 +232,16 @@ description: 'Own your errors. Own your stack. A fully open-source, self-hosted
|
|
|
232
232
|
dashboard UI (dark/light), multi-channel notifications (Slack, Email, Discord, PagerDuty,
|
|
233
233
|
webhooks), workflow management, advanced analytics, platform detection (iOS/Android/Web/API),
|
|
234
234
|
and two-way issue sync with GitHub, GitLab, Codeberg, and Linear. Also: LLM observability,
|
|
235
|
-
AI-powered debugging help, and OpenTelemetry span export.
|
|
236
|
-
|
|
237
|
-
|
|
235
|
+
AI-powered debugging help, and OpenTelemetry span export. The dashboard, mailers
|
|
236
|
+
and notification payloads are translated into 11 languages: English, German, Spanish,
|
|
237
|
+
French, Brazilian Portuguese, Italian, Polish, Russian, Ukrainian, Japanese, and
|
|
238
|
+
Simplified Chinese — 1,515 source strings, set via config.dashboard_locale with
|
|
239
|
+
a per-user picker. RED translates through its own private I18n backend and never
|
|
240
|
+
mutates your app''s I18n; a missing translation falls back to English. Backtraces,
|
|
241
|
+
exception names and webhook payload keys stay English by design. The ten non-English
|
|
242
|
+
locales are machine-translated and not yet reviewed by native speakers. 5-minute
|
|
243
|
+
setup, works out-of-the-box. Rails 7.0-8.1, Ruby 3.2-4.0. BETA: API may change before
|
|
244
|
+
v1.0.0. Live demo: https://rails-error-dashboard.anjan.dev (gandalf/youshallnotpass)'
|
|
238
245
|
email:
|
|
239
246
|
- anjan.jagirdar@gmail.com
|
|
240
247
|
executables: []
|
|
@@ -384,6 +391,7 @@ files:
|
|
|
384
391
|
- db/migrate/20260503000001_backfill_resolved_status.rb
|
|
385
392
|
- db/migrate/20260613000001_create_storm_events.rb
|
|
386
393
|
- db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
|
|
394
|
+
- db/migrate/20260824000001_add_user_agent_to_rack_attack_events.rb
|
|
387
395
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
388
396
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
389
397
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -464,6 +472,7 @@ files:
|
|
|
464
472
|
- lib/rails_error_dashboard/queries/storm_history.rb
|
|
465
473
|
- lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
|
|
466
474
|
- lib/rails_error_dashboard/queries/user_impact_summary.rb
|
|
475
|
+
- lib/rails_error_dashboard/services/ai_agent_classifier.rb
|
|
467
476
|
- lib/rails_error_dashboard/services/analytics_cache_manager.rb
|
|
468
477
|
- lib/rails_error_dashboard/services/backtrace_parser.rb
|
|
469
478
|
- lib/rails_error_dashboard/services/backtrace_processor.rb
|
|
@@ -550,7 +559,7 @@ metadata:
|
|
|
550
559
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
551
560
|
post_install_message: |
|
|
552
561
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
553
|
-
RED (Rails Error Dashboard) v0.
|
|
562
|
+
RED (Rails Error Dashboard) v0.10.0
|
|
554
563
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
555
564
|
|
|
556
565
|
First install:
|
|
@@ -589,5 +598,5 @@ requirements: []
|
|
|
589
598
|
rubygems_version: 3.6.9
|
|
590
599
|
specification_version: 4
|
|
591
600
|
summary: Self-hosted error tracking for Rails — local variables, system health, separate
|
|
592
|
-
or shared database. A free, open-source Sentry alternative.
|
|
601
|
+
or shared database, dashboard in 11 languages. A free, open-source Sentry alternative.
|
|
593
602
|
test_files: []
|