After ICanUp analytics gained a Human versus Bot split, the next question was obvious: which bots are actually generating that traffic?
A single Bot metric was no longer detailed enough. Search crawlers matter for SEO, link preview services are useful to distinguish, and the rise of AI crawlers made their share interesting as well.
The simplest approach would be to store the full User-Agent for every view and analyze it later.
I deliberately chose a different design.

User-Agent Is Useful at Ingestion but Not Necessarily in History
When an HTTP request reaches Laravel, User-Agent carries technical information about the client.
It can help derive:
- device type;
- browser family;
- operating-system family;
- whether the request looks automated;
- whether the bot matches a known crawler family.
The raw value is genuinely useful for that step.
But it is useful while processing the request. That does not imply it needs to become a persisted analytics dimension.
I Separate Signal Collection from Persistence
HTTP request | vraw User-Agent | vserver-side classification | +--> device_type +--> browser_family +--> os_family +--> is_bot +--> crawler_family | vstore normalized dimensionsThis became a core rule in my analytics design: raw request data may be necessary to derive a signal, but historical storage should contain only information that is actually useful for reporting.
The First Level Is Simple: Human or Bot
The canonical top-level distinction remains is_bot.
Human request:is_bot = false Automated request:is_bot = trueThis is enough when I want the overall Human versus Bot distribution.
For bot analysis itself, however, a boolean is not detailed enough.
crawler_family Describes Bot Traffic Only
The second classification level is useful only for requests already identified as Bot.
Conceptually, normalized results may look like this:
is_bot = truecrawler_family = googlebot is_bot = truecrawler_family = bingbot is_bot = truecrawler_family = meta_preview is_bot = truecrawler_family = telegram_preview is_bot = truecrawler_family = ai_crawler is_bot = trueHumans Do Not Need a crawler_family
Human: is_bot = falsecrawler_family = nullI do not need a synthetic human value inside crawler_family.
is_bot already carries that distinction. The second dimension exists only to explain Bot traffic.
Why I Do Not Store the Full User-Agent
A full User-Agent is tempting because it preserves maximum information for possible future analysis.
For ordinary content analytics, however, it creates several problems.
Raw value | Normalized dimension |
|---|---|
Can produce a very large number of unique values | Uses a bounded vocabulary |
Contains technical detail the report does not need | Stores only the required classification |
Increases analytics cardinality | Keeps aggregate growth controlled |
Expands profiling surface | Better supports data minimization |
High Cardinality Is Especially Expensive for Aggregates
My analytics stores aggregates by date and dimensions.
Every new unique dimension value can create another aggregate combination.
Good: crawler_family- googlebot- bingbot- ai_crawler- other_bot Bad: raw_user_agent- thousands of arbitrary strings- versions- platform fragments- custom scannersPersisting full User-Agent strings would fragment storage by details I do not even intend to expose in the primary analytics interface.
An Unknown Bot Should Not Invent Its Own Category
An important case is an automated client whose signature is not part of the supported list.
I do not want to extract a name from its User-Agent and create a new analytics value.
Unknown automated client | vis_bot = true | vcrawler_family = other_botThis keeps even unusual or deliberately crafted headers from expanding the analytics vocabulary with arbitrary values.
Crawler Family Is Not Proof of Identity
There is another important boundary.
User-Agent is supplied by the client and can be spoofed.
A request calling itself Googlebot is not automatically verified as Googlebot.
User-Agent says:Googlebot Analytics classification:looks like Googlebot Security conclusion:NOT automatically verified GooglebotThat classification is still useful for routine analytics.
But investigating suspicious activity requires other evidence and a separate verification process.
Analytics and Server Logs Solve Different Problems
For a monthly traffic picture, normalized aggregates are enough.
When thousands of suspicious requests suddenly arrive, the technical details of individual HTTP requests become important.
Analytics | Operational logs |
|---|---|
Long-term trends | Investigation of a specific incident |
Normalized categories | Exact technical requests |
Bounded values | May contain more detailed operational context |
Content and product questions | Diagnostics and security |
I do not want long-term content analytics to become an archive of server logs merely because detailed incident investigation is occasionally necessary.
Browser, OS, and Device Follow the Same Normalization Principle
The bot approach is not unique.
The same request header can produce bounded values for other reports:
{ "device_type": "mobile", "browser_family": "Chrome", "os_family": "Android", "is_bot": false, "crawler_family": null}To answer "how many views came from Chrome?", I do not necessarily need the exact build version of every browser unless that detail has a separate analytical purpose.
Raw IP Is Not Needed for This Analytics Either
I apply the same minimization principle to geography.
A normalized two-letter country code from a trusted infrastructure source is enough for country reports.
Needed for report: country_code = UA Not needed in analytics history: raw client IPNot Every Technical Value Deserves a New Dimension
Before adding another field to analytics, I try to answer three questions.
- Which concrete decision will this metric help me make?
- Can a normalized category replace the raw value?
- Will this dimension create uncontrolled aggregate cardinality?
If the first question has no answer, storing the value "just in case" is not enough justification for me.
An AI Crawler Is Still a Bot
I do not create a third top-level visitor class for AI crawlers beside Human and Bot.
At the canonical visitor-class level, it is automated traffic.
is_bot = true crawler_family:AI crawler familyThis keeps the top-level Human versus Bot view simple while allowing search crawlers, AI crawlers, and other families to be analyzed inside Bot traffic.
Crawler Family Percentages Need the Bot Population as Denominator
When I display crawler-family shares, the denominator needs to be Bot traffic itself.
All views: 10,000 Human: 8,000Bot: 2,000 Googlebot: 1,000 Googlebot share of bot traffic:1,000 / 2,000 = 50%Dividing 1000 by all 10000 views produces a different metric with a different meaning.
Crawler Families Should Work with Existing Filters
crawler_family is more useful than one global list.
I want it to work with the same period and content filters as the rest of analytics.
Period:Last 30 days Locale:uk Content:Posts Visitor:Bot Crawler family:GooglebotThis does not require a separate bot datastore. The normalized family becomes another supported dimension in the shared analytics system.
A New Report Does Not Need a New Analytics Table
I do not want one table for Googlebot, another for AI crawlers, and a third for other bots.
Classification belongs in the shared analytics enrichment stage before persistence.
eligible public request | vanalytics enrichment | +--> locale +--> country +--> device +--> browser +--> OS +--> Human/Bot +--> crawler family +--> traffic source | vshared aggregate persistenceClassification Failure Must Not Break the Public Page
Analytics is supporting functionality for the public site.
If a strange or malformed User-Agent cannot be classified, the visitor should still receive the page.
A deterministic fallback or a missing optional dimension is preferable to turning a classifier failure into an HTTP 500 response.
Crawler Classification Should Be Centralized
The supported-family list should not grow through unrelated checks scattered across controllers.
Conceptually, I want one boundary that turns a raw signal into a bounded result.
final class CrawlerFamilyResolver{ public function resolve( string $userAgent, bool $isBot, ): ?string { if (! $isBot) { return null; } return $this->knownFamily($userAgent) ?? 'other_bot'; }}Do Not Turn Parsed User-Agent Text Directly into a Dimension Value
// Bad idea:$crawlerFamily = $parsedBotName; // Better contract:$crawlerFamily = match ($knownSignature) { 'google' => 'googlebot', 'bing' => 'bingbot', 'meta_preview' => 'meta_preview', 'telegram_preview' => 'telegram_preview', 'ai' => 'ai_crawler', default => 'other_bot',};In the second form, the application defines the available values rather than the external client.
Tests Need More Than Known Crawlers
Scenario | Expected result |
|---|---|
Normal browser |
|
Known search crawler | Stable normalized family |
Known preview bot | Stable normalized family |
Supported AI crawler | Bot with normalized family |
Unknown automated client |
|
Empty or malformed User-Agent | Public rendering does not fail |
Persisted analytics | Does not contain the full raw User-Agent |
I Also Test the Privacy Boundary of Storage
A functional test proving that Googlebot reaches the correct group is not enough.
I also want to verify that the raw value never leaks into historical analytics.
Persisted analytics may contain: is_botcrawler_familydevice_typebrowser_familyos_familycountry_code Persisted analytics must not contain: full raw User-Agentraw IParbitrary crawler nameOther Bot Is Useful but Has a Limit
A normalized other_bot value protects storage from an unlimited vocabulary.
The trade-off is obvious: if that group suddenly generates most bot traffic, the aggregate report does not identify the exact source.
I still do not want to solve that by permanently storing every raw request header.
Anomalies Belong to a Separate Investigation Path
When aggregated analytics shows an anomaly such as a sudden other_bot spike, that becomes a signal for operational investigation.
Server logs or another controlled operational source can then be inspected for the relevant time window.
Analytics: other_bot spike detected | voperational investigation | vinspect detailed server evidence | vdecide whether classifier should changeIf a new crawler is stable, analytically useful, and has a reliable signature, it can be added to the controlled classifier.
If it was a one-off scanner, long-term analytics does not need a permanent new category because of one incident.
Data Minimization Does Not Mean Giving Up Useful Analytics
I do not need to choose between knowing nothing about traffic and retaining every technical detail of every request.
There is a practical middle path:
- receive the raw signal with the request;
- turn it into the required classification;
- persist only the normalized result;
- aggregate it for reporting;
- leave detailed technical investigations to the operational layer.
What I Do Not Store in Core Analytics
- The complete raw
User-Agentof every view. - Raw IP as an analytics dimension.
- Arbitrary crawler names parsed from headers.
- Full referrer URLs with arbitrary query parameters.
- A persistent profile for unauthenticated visitors.
- Technical values merely because the browser or HTTP request makes them available.
My Short Privacy-Safe Bot Analytics Checklist
- Define the concrete product or technical question a dimension should answer.
- Use raw User-Agent only as an input signal for classification.
- Keep Human/Bot as the canonical top-level split.
- Detail only Bot traffic through a bounded crawler_family.
- Keep crawler_family null for Human traffic.
- Never create a new dimension value from arbitrary User-Agent text.
- Map unknown bots to a stable fallback.
- Do not treat User-Agent as verified crawler identity.
- Keep long-term analytics separate from operational logs.
- Do not store raw IP merely for traffic grouping.
- Test both classification results and the absence of raw values in storage.
- Control the cardinality of every new dimension.
For analytics, I do not need everything the browser tells me about itself. I need the minimum normalized information required to answer a concrete question.
Conclusion
In ICanUp, I want to understand how much traffic comes from humans, how much comes from bots, and which known crawler families visit public content.
That does not require storing the full User-Agent of every view.
The raw header is used as a transient input for classification. Historical analytics then keeps bounded values such as is_bot, crawler_family, device type, browser family, and operating-system family.
An unknown bot does not create an arbitrary new category. It maps to a predictable fallback. Human traffic does not receive a crawler family at all.
When a real investigation into a scanner or suspicious request burst is required, that belongs to operational logs rather than long-term content aggregates.
My main principle is simple: store not the maximum amount of data available, but the minimum amount that is sufficient to answer a useful question.



