# Most of your visitors were not people

**What happened on a pilot account, what it cost, and how to check your own data.**

---

## The number that was wrong

A B2B diagnostics company had been running the pixel for months. Their
identified-visitor count looked healthy and grew steadily. Then someone asked a
question nobody had asked before: *how many distinct people are behind these
events?*

```
224,084  "human" visitors over 90 days
 10,496  of them were crawlers          (4.7%)
     59  distinct crawler user agents, every one self-identifying
```

On the paired staging tenant the proportion was far worse: **69.4% of events
were unflagged crawlers.** Roughly seven in ten "visits" were a machine.

## Why the bot filter missed them

There was a bot filter. It matched a list of names — `googlebot`, `bingbot`,
`semrushbot` and a few dozen others. It missed:

- **Every Google crawler that is not Googlebot.** `AdsBot-Google`,
  `GoogleOther`, `Google-InspectionTool`, `Feedfetcher-Google`,
  `Google-Read-Aloud`, `Storebot-Google`. Six products, one name in the list.
- **Semrush's site auditor.** Its token is `SiteAuditBot`. The list contained
  `semrushbot`. Same vendor, different string, no match.

The filter was not broken. It was a list, and a list is only ever as complete
as the last person who edited it.

## The fix that generalises

A crawler that puts a URL inside its own user agent is telling you what it is:

```
Mozilla/5.0 (compatible; SiteAuditBot/0.97; +http://www.semrush.com/bot.html)
Mozilla/5.0 (compatible; DuckAssistBot/1.0; +https://duckduckgo.com/duckassistbot/)
```

So alongside the name list, one rule:

```
\+https?://\S*(bot|crawl|spider)
```

That single pattern caught SiteAuditBot, DuckAssistBot and the Facebook crawler
without anyone having enumerated them. It generalises to crawlers that do not
exist yet, which a list cannot.

## What it actually cost — and what it did not

This is the part most write-ups get wrong, so it is worth being exact.

**Barely affected: identified people.** A crawler has no email to resolve, so
it almost never becomes an identified visitor:

```
19,517  unflagged crawler events, all time
   124  of those ever resolved to an identity   (0.6%)
```

Against 34,841 identified visitors on that account, 124 is noise.

**Badly affected: anything counted per event.** Traffic totals, page-view
counts, "most visited pages", funnel step counts — every statistic not scoped
to an identified person was inflated, and the inflation was concentrated on the
pages crawlers like: pricing, sitemap-linked landing pages, anything in the nav.

The first version of our own internal write-up claimed identified-visitor
counts were inflated by one in twenty. That was wrong, and we only found out by
measuring instead of reasoning. If you take one thing from this: **the blast
radius of a data-quality bug is rarely where you first assume.**

## How to check your own data

Before believing any behavioural statistic, check that the number of events is
plausible against the number of distinct actors:

```bash
curl "https://app.signal.geysera.com/signal-api/v1/visitors?page_size=1" \
  -H "Authorization: Bearer sk_sig_…"
```

The response's `total` is people. If your event counts are orders of magnitude
larger than that and you are not a high-frequency consumer app, something in
between is not a person.

Two rules worth adopting permanently:

1. **`n ≈ distinct visitors`.** If one "visitor" accounts for hundreds of
   page-views in a session, look at the user agent before building a funnel on
   it.
2. **Treat unknown as unknown.** Our own `is_bot` column was NULL — never
   evaluated — for 28.9% of rows. A query written as `WHERE NOT is_bot` drops
   every one of those, because `NOT NULL` is not `TRUE`. `WHERE is_bot IS NOT
   TRUE` is what you want. A three-state column read as two states is a silent
   filter.

## What we changed

- The self-identifying-crawler rule now runs at ingest, so this cannot
  accumulate again.
- History was corrected in a migration that marks the rows it touched, so the
  change is reversible and auditable.
- Crawler-only visitors are **hidden by default and still viewable** — a
  filter, not a delete. Somebody eventually wants to know how much crawler
  traffic a page gets, and deleting the evidence to clean up a number is how
  you end up unable to answer that.
