---
name: separate-people-from-automation-in-traffic
description: Make a rate mean something by counting a denominator every response passes through and a signal only a person can produce, without joining the two.
runbook:
  schema_version: v1
  semver: 1.0.0
  profile: P1
  min_runtime_profile: R1
  domain: data-pipelines
  targets:
    - postgres
  trigger: manual
  execution: human-with-agent
  risk: reversible-write
  blast_radius: service
  duration: ">30m"
  capabilities:
    - cli:psql
  publisher: std
  lang: en
  license: CC-BY-4.0
  inputs:
    window_days:
      type: string
      required: true
      description: how many days the figures cover
  steps:
    - id: s1
      kind: check
      title: Look at the shape, not the total
      tool: cli:psql
      command: psql -Atc "select count(*), avg(seconds), avg(depth) from page_visits where at > now() - interval '{{inputs.window_days}} days'"
      expect: visits, average time and average depth — a few seconds and a depth near one is not an audience
      assert:
        exit_code: 0
      on_fail: escalate:s9
      next: s2
    - id: s2
      kind: decision
      title: Is the traffic mixed?
      question: Do the averages describe somebody reading, or something fetching?
      branches:
        mixed: s3
        default: s8
    - id: s3
      kind: action
      title: Stop publishing rates over the total
      risk: reversible-write
      expect: any figure divided by the mixed total is withdrawn until there is a denominator
      on_fail: escalate:s9
      next: s4
    - id: s4
      kind: action
      title: Count the denominator where every response goes
      risk: reversible-write
      expect: one row per response served, written by the one layer every response passes through
      on_fail: escalate:s9
      next: s5
    - id: s5
      kind: action
      title: Record a signal only a person leaves
      risk: reversible-write
      expect: a signal that needs the page to run and the reader to stay, sent from the browser
      on_fail: escalate:s9
      next: s6
    - id: s6
      kind: check
      title: Time counts only while the tab is visible
      expect: a backgrounded tab adds no seconds
      on_fail: escalate:s9
      next: s7
    - id: s7
      kind: action
      title: Give both tables the same dimensions
      risk: reversible-write
      expect: source, country and client class on each, so the two are comparable without a join
      on_fail: escalate:s9
      next: s8
    - id: s8
      kind: check
      title: The rate is over the denominator it means
      tool: cli:psql
      command: psql -Atc "select count(*) from human_signals where at > now() - interval '{{inputs.window_days}} days'"
      expect: a human count to divide by, reported beside the total rather than instead of it
      assert:
        exit_code: 0
      on_fail: escalate:s9
      next: end:success
    - id: s9
      kind: escalate
      title: Report no rate rather than a wrong one
      to: team:platform
  source:
    url: https://runbooks.directory/r/std/separate-people-from-automation-in-traffic/source.md
    fetched_at: 2026-09-04
    upstream_state: current
---

## When to reach for this

Any figure that divides something by "visits" — conversion, signup rate, click-through — on
a site open to the internet. Reach for it when the numbers look implausible, and reach for it
before publishing a rate at all, because the failure is silent and the number is confidently
wrong.

## Mixed traffic makes a rate wrong in both directions

One week: eleven thousand visits, five seconds on the site, depth 1.28, geography clustered
in two countries that were not the audience. Some of it was people arriving from a social
network; some was automation hitting the domain.

While they are mixed, no rate is meaningful. Dividing two enquiries by eleven thousand is
absurd; but the right denominator is not knowable either, so the figure cannot simply be
scaled. It is not that the number is too low — it is that there is no number.

The shape gives it away before any of this: a few seconds and a depth near one is not an
audience, whatever the total says.

## Do not separate by source

The obvious move is to filter by referrer or by user agent, and it does not work.

**The source lies.** People and crawlers arrive from the same places — a link posted on a
network is followed by both. And a visit with no referrer at all is entirely normal for an
application that opens links inside itself, so treating "no referrer" as suspicious discards
real readers.

Separate by **behaviour that automation does not produce**.

## Two signals, each needing presence

Either of two, and both require the page to run and the reader to remain:

- **Depth** — they reached a quarter of the page.
- **Dwell** — they stayed ten seconds.

Count seconds only while the tab is visible. A backgrounded tab lives for hours with nobody
in front of it, and dwell measured without that condition marks every abandoned tab as a
reader.

Neither signal is proof, and neither needs to be. What they establish is that something ran
the page and stayed, which is the property the automation you are trying to exclude does not
have.

## Two tables, and why they are not joined

The denominator and the signal come from different places on purpose.

**The denominator** is written by the single layer every response passes through — including
responses to clients that never run a line of the page. That is what makes it a denominator:
it counts what was served, not what reported back.

**The signal** is sent by the browser, because that is the only place the behaviour exists.

They are not joined row to row, and that is a decision rather than an omission. Joining them
would mean threading a server-side token into every page, which means reading request
headers where the page is assembled — which makes every static page dynamic, for a counter.
The cost is real and the benefit is not: nothing here needs to know which visit produced
which signal.

So both carry the same dimensions — source, country, client class — and the report compares
them along those. Two totals in the same cut, from two honest counters, answer the question.
A join would answer it more precisely and cost the whole site's rendering strategy.

## Report both numbers

Publish the human count *beside* the total, not instead of it. The total is still a fact —
somebody's automation really did fetch those pages — and hiding it makes the next person
rediscover all of this from scratch when the two figures disagree with a third somewhere
else.
