0% прочитано

Laravel Bot Analytics Without Storing Raw User-Agent Strings

In ICanUp analytics, I need more than a Human versus Bot split: I also want to understand which known crawler families visit the site. That does not require storing the full raw User-Agent for every request. This article shows how the raw value is used transiently for classification while analytics history keeps bounded normalized dimensions such as is_bot, crawler_family, browser family, OS, and device type.

20 вересня 2026 р. 11 хв читанняLaravel

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.

For long-term analytics, I need to know which known class a request belongs to, not preserve a complete copy of every visitor technical header.
Laravel analytics privacy flow where raw User-Agent is classified transiently into Human Bot crawler family browser OS and device dimensions while raw value is discarded
The raw User-Agent is used at ingestion time for classification. Analytics history keeps only bounded normalized dimensions.

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

Classify the signal instead of storing it raw
HTTP request    |    vraw User-Agent    |    vserver-side classification    |    +--> device_type    +--> browser_family    +--> os_family    +--> is_bot    +--> crawler_family    |    vstore normalized dimensions

This 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.

Top-level traffic classification
Human request:is_bot = false Automated request:is_bot = true

This 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:

Bounded crawler families
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 = true
The values here illustrate the normalized classification model. The important property is that the application controls a bounded vocabulary instead of generating arbitrary values directly from User-Agent text.

Humans Do Not Need a crawler_family

Human traffic has no artificial crawler family
Human: is_bot = falsecrawler_family = null

I 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.

Bounded classification versus arbitrary strings
Good: crawler_family- googlebot- bingbot- ai_crawler- other_bot  Bad: raw_user_agent- thousands of arbitrary strings- versions- platform fragments- custom scanners

Persisting 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.

Stable fallback classification
Unknown automated client        |        vis_bot = true        |        vcrawler_family = other_bot

This 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.

Classification is not verified identity
User-Agent says:Googlebot Analytics classification:looks like Googlebot Security conclusion:NOT automatically verified Googlebot

That 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:

JSON - Example normalized client dimensions
{    "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.

Store the classification result, not the raw identifier
Needed for report: country_code = UA  Not needed in analytics history: raw client IP

Not 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.

AI crawler as a Bot detail
is_bot = true crawler_family:AI crawler family

This 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.

Correct crawler-family denominator
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.

One dimension inside shared analytics
Period:Last 30 days Locale:uk Content:Posts Visitor:Bot Crawler family:Googlebot

This 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.

One shared enrichment stage
eligible public request        |        vanalytics enrichment        |        +--> locale        +--> country        +--> device        +--> browser        +--> OS        +--> Human/Bot        +--> crawler family        +--> traffic source        |        vshared aggregate persistence

Classification 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.

PHP - Conceptual centralized family resolver
final class CrawlerFamilyResolver{    public function resolve(        string $userAgent,        bool $isBot,    ): ?string {        if (! $isBot) {            return null;        }         return $this->knownFamily($userAgent)            ?? 'other_bot';    }}
This illustrates the contract rather than claiming an exact current ICanUp class or method name.

Do Not Turn Parsed User-Agent Text Directly into a Dimension Value

PHP - Keep the vocabulary bounded
// 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

is_bot = false, crawler_family = null

Known search crawler

Stable normalized family

Known preview bot

Stable normalized family

Supported AI crawler

Bot with normalized family

Unknown automated client

other_bot, not an arbitrary value

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.

Testing the persistence boundary
Persisted analytics may contain: is_botcrawler_familydevice_typebrowser_familyos_familycountry_code  Persisted analytics must not contain: full raw User-Agentraw IParbitrary crawler name

Other 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.

From aggregate anomaly to targeted investigation
Analytics: other_bot spike detected        |        voperational investigation        |        vinspect detailed server evidence        |        vdecide whether classifier should change

If 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-Agent of 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.