Class: RailsErrorDashboard::Services::AiAgentClassifier
- Inherits:
-
Object
- Object
- RailsErrorDashboard::Services::AiAgentClassifier
- Defined in:
- lib/rails_error_dashboard/services/ai_agent_classifier.rb
Overview
Classifies a User-Agent string into a coarse traffic kind, and names the agent when it is a recognised one.
WHY THIS EXISTS (issue #170): tracking which AI agents read an app is the
reason people reach for Rack::Attack's track rules now. Counting IPs
cannot answer it — one agent is a rotating fleet of hundreds of addresses,
so unique-IP totals overstate the population badly. The user agent is the
signal that actually identifies the reader.
Deliberately plain string matching, NOT the browser gem: browser is an
optional dependency that degrades gracefully everywhere else in this gem,
and it does not know these agents anyway. This runs on the flush path, not
the request path, but it stays allocation-cheap regardless.
The bot lists are necessarily a snapshot. An unrecognised agent falls back to :other rather than being guessed at — a wrong attribution is worse than an honest "unknown" when the whole point is measurement.
Constant Summary collapse
- AI_ASSISTANTS =
Order matters: the first match wins, so more specific patterns lead.
AI agents split into two behaviours worth telling apart, because they answer different questions:
- :ai_assistant — fetches on demand, because a human asked something now
- :ai_crawler — bulk-fetches to build a training corpus or index
{ "ChatGPT-User" => /ChatGPT-User/i, "Claude-User" => /Claude-User/i, "Claude Code" => /Claude-?Code/i, "Perplexity-User" => /Perplexity-User/i, "Gemini-User" => /Gemini-User/i, # Observed in production traffic by the reporter of #170: # "GitHubCopilotRuntime-WebFetch", 7 requests from 5 IPs. Classified as # an assistant rather than a crawler because it fetches a specific URL a # developer's Copilot session asked for, one page at a time — not a bulk # corpus crawl. # # The pattern is deliberately anchored on "GitHubCopilot" rather than a # bare /Copilot/i. Microsoft applies the Copilot brand very broadly, and # its Copilot features are documented as riding Bingbot infrastructure or # sending ordinary Edge/Chromium user agents with no bot signal — so a # loose pattern would mislabel plain browser traffic as an AI agent, # which is exactly the wrong-attribution failure this class avoids. # # No authoritative UA documentation exists for this agent: it is absent # from GitHub's own docs, from ai-robots-txt/ai.robots.txt, and from the # Dark Visitors catalogue as of 2026-08-29. The pattern therefore matches # only what has actually been observed, plus the "-WebFetch" sibling # suffix, instead of guessing at variants. "GitHub Copilot" => /GitHubCopilot/i }.freeze
- AI_CRAWLERS =
{ "GPTBot" => /GPTBot/i, "OAI-SearchBot" => /OAI-SearchBot/i, "ClaudeBot" => /ClaudeBot/i, "anthropic-ai" => /anthropic-ai/i, "PerplexityBot" => /PerplexityBot/i, "Google-Extended" => /Google-Extended/i, "Applebot-Extended" => /Applebot-Extended/i, "Bytespider" => /Bytespider/i, "CCBot" => /CCBot/i, "Meta-ExternalAgent" => /Meta-ExternalAgent/i, "Amazonbot" => /Amazonbot/i, "cohere-ai" => /cohere-ai/i, "DuckAssistBot" => /DuckAssistBot/i, "YouBot" => /YouBot/i, "Diffbot" => /Diffbot/i, "Timpibot" => /Timpibot/i }.freeze
- CRAWLERS =
Conventional search/SEO crawlers. Not AI traffic, but worth naming so they can be excluded rather than silently inflating an "unknown" bucket.
{ "Googlebot" => /Googlebot/i, "Bingbot" => /bingbot/i, "DuckDuckBot" => /DuckDuckBot/i, "Baiduspider" => /Baiduspider/i, "YandexBot" => /YandexBot/i, "AhrefsBot" => /AhrefsBot/i, "SemrushBot" => /SemrushBot/i, "Applebot" => /Applebot/i, "facebookexternalhit" => /facebookexternalhit/i, "LLMS-Txt-Scanner" => /LLMS-Txt-Scanner/i }.freeze
- BROWSER_HINTS =
Checked only after every bot pattern has missed, because plenty of bots embed a full browser UA string and would match these first.
/Mozilla|Chrome|Safari|Firefox|Edge|Opera|Gecko|WebKit/i- LIBRARIES =
Non-browser HTTP clients — usually scripts, monitors or scrapers.
{ "curl" => /\bcurl\//i, "wget" => /\bWget\//i, "python-requests" => /python-requests/i, "httpx" => /\bhttpx\//i, "Go-http-client" => /Go-http-client/i, "Java" => /\bJava\//i, "okhttp" => /\bokhttp\//i, "axios" => /\baxios\//i, "Faraday" => /Faraday/i, "RubyGems" => /Ruby\b/i }.freeze
- KINDS =
%i[ai_assistant ai_crawler crawler browser library other].freeze
Class Method Summary collapse
-
.ai?(user_agent) ⇒ Boolean
Whether this agent is an LLM reader of either flavour.
-
.classify(user_agent) ⇒ Hash
{ kind:, name:, ai: } — one pass for callers that want all three.
-
.kind(user_agent) ⇒ Symbol
One of KINDS.
-
.name(user_agent) ⇒ String?
Canonical name for a recognised agent, or nil when unrecognised.
Class Method Details
.ai?(user_agent) ⇒ Boolean
Whether this agent is an LLM reader of either flavour. This is the predicate the dashboard's "AI agents" figure counts.
150 151 152 |
# File 'lib/rails_error_dashboard/services/ai_agent_classifier.rb', line 150 def ai?(user_agent) %i[ai_assistant ai_crawler].include?(kind(user_agent)) end |
.classify(user_agent) ⇒ Hash
Returns { kind:, name:, ai: } — one pass for callers that want all three.
155 156 157 158 159 160 161 162 |
# File 'lib/rails_error_dashboard/services/ai_agent_classifier.rb', line 155 def classify(user_agent) k = kind(user_agent) { kind: k, name: name(user_agent), ai: %i[ai_assistant ai_crawler].include?(k) } end |
.kind(user_agent) ⇒ Symbol
Returns one of KINDS.
113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/rails_error_dashboard/services/ai_agent_classifier.rb', line 113 def kind(user_agent) ua = user_agent.to_s return :other if ua.strip.empty? return :ai_assistant if match_name(AI_ASSISTANTS, ua) return :ai_crawler if match_name(AI_CRAWLERS, ua) return :crawler if match_name(CRAWLERS, ua) return :library if match_name(LIBRARIES, ua) return :browser if ua.match?(BROWSER_HINTS) :other rescue => e :other end |
.name(user_agent) ⇒ String?
Canonical name for a recognised agent, or nil when unrecognised. Never invents a name — callers show the raw UA in that case.
133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/rails_error_dashboard/services/ai_agent_classifier.rb', line 133 def name(user_agent) ua = user_agent.to_s return nil if ua.strip.empty? match_name(AI_ASSISTANTS, ua) || match_name(AI_CRAWLERS, ua) || match_name(CRAWLERS, ua) || match_name(LIBRARIES, ua) rescue => e nil end |