The short version
  • The three counters are measuring three different things. They are not three readings of one number, so expect them to disagree.
  • Prove a metric can move before you optimise against it. Ours was pinned at zero by architecture, not by demand.
  • Look at inter-arrival times before totals. Regular gaps and repeated sequences are test suites, not customers.
  • Separate "zero" from "not measured." They justify opposite decisions, and one of them will cut a working campaign.
  • Read the actual rows once. At most companies' volumes that is an afternoon, and it is the only way to find this class of problem.

Two numbers on the same funnel should be close. Google Ads says a campaign produced three conversions. GA4 says the site produced sixty-five. The first-party database says forty-three form submissions. None of these describe the same event, and on our own site, none of them described a person.

This is the audit we ran on our own funnel in August 2026. We publish it because the interesting part is not that our numbers were wrong. It is how they were wrong, and the fact that every individual system was working exactly as designed while the aggregate was fiction.

The three counters, and why they never agree

3
counters that never agree
1
with a physical referent
6
checks in the audit

Before the audit, it helps to be precise about what each system is actually counting. They are not three measurements of one thing. They are three different things that happen to be called "conversions."

Counter What it actually counts What it cannot see
Ad platform Clicks it can take credit for, via its own attribution model and lookback window Anything that happens off the click path, or that it was never told about
Analytics Events fired in a browser, inside a session Consent refusals, ad blockers, anyone who left before the tag ran, conversions on another domain
First-party collector HTTP requests that reached an endpoint Nothing — but it has no attribution model, so it tells you that something happened, not why

Google Ads counts a conversion it was told about. Either its own tag fired, or a conversion action was imported from GA4, or an offline conversion was uploaded. It attributes that conversion to a click it remembers, within a lookback window, using its own attribution model. It is counting clicks it can take credit for.

GA4 counts an event named as a conversion. It fires client-side, in a browser, subject to consent state, ad blockers, and whether the page that fires it was reached at all. It attributes by its own model over its own session data. It is counting events in sessions.

Assistant referrals add a fourth blind spot on top of these three — we covered that separately in how to measure AI search traffic.

A first-party collector counts a row it received. Ours is a Cloudflare Worker writing to D1, drained nightly into a committed SQLite database. It has no attribution model. It is counting HTTP requests that reached an endpoint.

The third one is the only one that has a physical referent. A row exists or it does not. That property is what made the audit possible, and it is why we would build the collector again.

The first symptom: a number that had never moved

The tell was not a discrepancy. It was a constant.

We have an autonomous optimiser that reads the funnel daily and proposes bid and budget changes. Its objective metric was bookings. Bookings had been zero for the entire life of the system — 2026-06-05 through 2026-08-06 — and the optimiser had been dutifully making decisions to improve a number that never changed.

Zero is a legitimate reading. Demand can genuinely be zero. But a metric that is exactly zero across two months of spend, with no variance at all, is worth an hour of your time before you act on it. So we went looking for the mechanism rather than the demand.

visitor lands on the site browse tag is present click Book still on our domain scheduler different domain booking tag never runs Before tuning anything against a metric, prove the metric CAN move.
Where the measurement ends. The conversion completes on a domain our tag does not run on, so the metric was pinned at zero by architecture rather than by demand.

The mechanism was architectural. A booking completes on calendly.com. Our tracking script lives on roughworks.ca. At the moment the conversion happens, the page that would report it is not on screen and never was. The metric was not measuring low demand. It was structurally pinned at zero and would have read zero in a month with a hundred bookings.

That is the first rule this audit produced, and it generalises well past our funnel:

Before you tune anything against a metric, prove the metric can move. A number that cannot rise is not evidence of anything.

The fix was not a tracking change. Calendly's webhook posts directly to the collector, server to server, with no browser in the path. form_submissions became the only place a booking could ever be recorded — so that is where the optimiser now reads it from.

The second symptom: the shape of the data was wrong

With the architecture understood, we went through the conversion records one at a time. The shape gave it away long before the contents did.

Here is the query, and it is the one worth stealing from this article:

-- Real humans do not arrive on a schedule. Look at the gaps.
SELECT id,
       submitted_at,
       form_name,
       CAST((julianday(submitted_at) -
             julianday(LAG(submitted_at) OVER (ORDER BY submitted_at))
            ) * 86400 AS INT) AS gap_seconds
FROM form_submissions
ORDER BY submitted_at;

Human submissions are ragged. They cluster around working hours, they bunch after a campaign launch, and the gaps between them are wildly uneven. What came back instead was a pattern:

id submitted_at form_name gap_s
10 05:06:29.997 rebuild_or_repair_queued 232
11 05:06:31.978 rebuild_or_repair_repair 1
12 05:06:35.094 booked_call 3
13 05:06:35.473 rebuild_or_repair_rebuild 0
16 05:15:25.387 rebuild_or_repair_queued 363
17 05:15:27.419 rebuild_or_repair_repair 2
18 05:15:30.619 booked_call 3
19 05:15:31.012 rebuild_or_repair_rebuild 0
22 05:16:29.664 rebuild_or_repair_queued 17
23 05:16:31.719 rebuild_or_repair_repair 2
24 05:16:34.957 booked_call 3
25 05:16:35.330 rebuild_or_repair_rebuild 0

The same four forms, in the same order, three times, inside eleven minutes. A person does not submit four different forms in 0–3 seconds. A test suite does.

Every one of those rows carried a fixture email — [email protected], [email protected], [email protected] — and one deliberate field-length probe, an address long enough to have been written by someone checking a column width rather than trying to hire anybody.

And the three rows marked as our highest-value conversion, the booked_call events, were ids 12, 18 and 24 — the three bold rows above. Every one of our qualified leads was an end-to-end test asserting that the booking endpoint returned a 200.

The third symptom: a daily heartbeat in GA4

The same audit run against GA4's event stream produced a different fingerprint of the same class of problem.

SELECT date, source, medium, country, count
FROM daily_events
WHERE event_name = 'generate_lead'
ORDER BY date;

Sixty-five generate_lead events. Fifty-five of them, totalling 61 conversions, landed on or before 2026-07-27, spread across 45 distinct days. Roughly one per day. Overwhelmingly country = 'United States', source = '(not set)', no referrer.

That is not a market. That is a scheduled job. A CI canary — a synthetic check that submits the contact form on a schedule to prove the form still works — was firing from a US-hosted runner and being counted as demand. After 2026-07-27 the pattern drops to four events and then stops entirely on 2026-08-07, while genuine submissions kept arriving through 2026-08-24.

The canary was a good idea. A form that silently stops working is one of the most expensive failures a site can have, and a daily check that proves it still submits is worth having. The failure was that its submissions were indistinguishable from a customer's, so the same event that proved the form worked also inflated the number the ad system optimised against.

What a real conversion looked like, when we finally found one

After filtering, forty-three rows became thirteen genuine external submissions. Of those, five were already flagged as spam, and seven more read as solicitation on the email address alone — SEO outreach, directory pitches, a marketing agency selling to a marketing agency.

One row was an unambiguous prospect. It carried a gclid, a campaign id, and a keyword: modernize my website. A real person, from a real paid click, on a search term that describes the thing we sell.

One. Out of forty-three rows in the table.

That number is not a happy one to publish. It is, however, the first number in the entire system that meant anything, and a business can act on a true one much more usefully than on a flattering fiction.

The fix: a hygiene layer, not a tracking rewrite

The instinct after an audit like this is to rebuild the tracking. That was not necessary. The events were being recorded correctly. What was missing was a definition of which recorded events counted.

That definition now lives in one place and is imported everywhere else, so the operator's judgement and the optimiser's signal cannot drift apart. Two pieces do the work.

First, an exclusion list of hosts that are demonstrably not the live site:

# _local/agent/daily_report.py:100-103
NON_PROD_URL_PATTERNS = (
    "%.local/%", "%.local:%", "%localhost%", "%127.0.0.1%",
    "%staging%", "%wpengine.com%", "%.test/%",
)

Note that this is an exclude list, not an include list. That choice matters. An include list of known-good URLs silently drops a genuine lead that arrives from a URL you did not anticipate — a campaign landing page, a syndicated copy, a link someone built. An exclude list only ever removes things you have positively identified as fake, and it fails in the safe direction.

The predicate itself is deliberately NULL-safe, because a missing page_url is not evidence of a fake lead:

# _local/agent/daily_report.py:195-201
def _nonprod_sql(col: str) -> str:
    """SQL fragment matching known-non-production URLs. NULL-safe."""
    return "(" + " OR ".join(f"{col} LIKE '{p}'" for p in NON_PROD_URL_PATTERNS) + ")"

def _is_prod_sql(col: str) -> str:
    return f"({col} IS NULL OR NOT {_nonprod_sql(col)})"

Second, an event-level exclusion list, with the reasoning recorded next to it rather than in someone's memory:

# _local/agent/daily_report.py:117
EXCLUDED_LEAD_EVENTS = ("lead_unqualified", "spam", "self_test")

Each of those exists for a different reason. lead_unqualified failed the quality gate and is recorded for visibility but never counted — feeding those to a bid optimiser is the single most common way an autonomous ad system teaches itself to buy garbage. spam is operator-reviewed solicitation, which no automated gate catches reliably because they are real humans with real addresses who simply are not buyers. self_test is the operator exercising the funnel to prove a webhook, which must never read as demand.

Third, and most important, a date before which zero means nothing was measured:

# _local/agent/daily_report.py:94
COLLECTOR_TRUSTED_FROM = "2026-07-28"

Our collector recorded nothing before that date, while paid traffic had already been running for weeks. That is a 53-day hole in which the honest reading is not "no leads" but "no data." Those two readings justify opposite decisions. Zero demand says cut the budget. Zero measurement says fix the pipe and keep the budget steady until you can see. A system that cannot tell them apart will confidently cut spend on a working campaign.

The audit, as a checklist you can run this week

None of this needed specialist tooling. It needed an afternoon and a willingness to disbelieve a dashboard.

1. Prove each metric can move. For every number an automated system optimises against, trace the physical path from the user's action to the row in the database. If any step happens on a domain your tag does not run on — a payment processor, a scheduler, a third-party booking tool — the metric is pinned and you must move the measurement server-side.

2. Look at inter-arrival times before you look at totals. Regular gaps, repeated sequences, and clusters inside a few minutes are test suites and monitors. This is a two-minute query and it finds more problems than any dashboard.

3. Read every conversion record in full, at least once. At the volumes most agencies and SMBs deal in, there are tens of conversions, not millions. Read them. The fixture emails, the empty contact fields and the localhost URLs are visible to the naked eye and invisible to any aggregate.

4. Separate "zero" from "not measured." Record the date each pipe started working and refuse to reason across the boundary. This one line prevents more bad decisions than any other in the system.

5. Reconcile the counters against each other, and expect them to disagree. Set an expectation for the ratio between platform-reported clicks and analytics-reported sessions, then treat a change in that ratio as the alarm rather than the gap itself. Some loss is normal — ad blockers, consent refusals, people leaving before the tag fires. A ratio that moves sharply is telling you something about the traffic you are buying, and it will show up here weeks before it shows up anywhere else.

6. Make the filter auditable, not silent. Print the count of excluded rows. A hygiene layer nobody can see becomes a hygiene layer nobody trusts, and eventually someone works around it. If you automate the reporting around it, keep the same discipline — what to automate and what to leave alone covers where that line sits.

What we would tell a client with the same problem

Most conversion-tracking audits we are asked to run turn up some version of this. Not always a canary — sometimes a staging environment reporting into production analytics, sometimes an internal team's own form fills, sometimes a monitoring service, frequently a developer testing in a way that is entirely reasonable and entirely uninstrumented.

The pattern is consistent: the fake conversions are almost never a bug. They are a working system doing exactly what it was built to do, being counted by another working system that was never told to exclude it. Nobody made a mistake. The two systems were simply never introduced.

That is also why this class of problem survives so long. Nothing errors. No alert fires. The dashboard looks healthy — better than healthy, because synthetic conversions are perfectly reliable and arrive every single day. A funnel with a canary in it looks more stable than a real one.

This is the same discipline we apply to performance numbers, where 4,739 Lighthouse runs on unchanged pages showed how far a single measurement can be from the truth.

If you are about to make a decision on a conversion number you have never personally read the underlying rows for, read them first. It is an afternoon. On our own funnel it turned three qualified leads into zero, sixty-five conversions into four, and a confident optimiser into an honest one.

We would rather work from one real lead than three imaginary ones. You can do something with the first number.

If you want a second pair of eyes on a funnel you are not sure about, our conversion tracking audit walks the same process end to end, and the rebuild-or-repair rubric covers what to do when the answer is that the tracking is fine and the site is the problem. Measurement is also the first thing we fix on an engagement — see SEO and search.

Common questions

Why don't my GA4 conversions match Google Ads?

Because they are counting different things. Google Ads counts a conversion it was told about and can attribute to a click it remembers, within its own lookback window and attribution model. GA4 counts an event that fired in a browser, subject to consent state, ad blockers, and whether the page fired it at all. A first-party collector counts an HTTP request that reached an endpoint. Only the third has a physical referent — a row exists or it does not. Expect them to disagree, and treat a stable ratio as normal and a changing ratio as an alarm.

How do I tell if a conversion is fake?

Look at inter-arrival times before you look at totals. Query your conversion rows ordered by timestamp with a LAG window function to compute the gap between each. Humans arrive raggedly; test suites and monitors arrive on a schedule or in tight repeated sequences. Then read the rows themselves — fixture emails like [email protected], empty contact fields, and localhost or staging URLs in the page reference are visible to the naked eye and invisible to any aggregate.

What is a CI canary and why does it inflate conversions?

A CI canary is a scheduled synthetic check that submits your contact form to prove it still works. It is a genuinely good idea, because a form that silently breaks is one of the most expensive failures a site can have. It inflates conversions when its submissions are indistinguishable from a customer's, so the same event that proves the form works also feeds your ad platform's optimiser as demand. The fix is not to remove the canary but to tag its submissions so they can be excluded from every count.

Should I use an include list or an exclude list to filter test leads?

An exclude list. An include list of known-good URLs silently drops genuine leads arriving from URLs you did not anticipate — a campaign landing page, a syndicated copy, a link someone else built. An exclude list only removes things you have positively identified as non-production, so it fails in the safe direction. Make the predicate NULL-safe too: a missing page URL is not evidence of a fake lead.

Why does it matter whether zero means no leads or no data?

Because they justify opposite decisions. Zero demand says cut the budget. Zero measurement says fix the pipe and hold the budget steady until you can see. A system that cannot tell them apart will confidently cut spend on a working campaign. Record the date each measurement pipe started working, store it as a constant, and refuse to reason across that boundary.

Can a conversion metric be structurally impossible to record?

Yes, and this is more common than a tracking bug. If the final step of your funnel happens on a domain your tag does not run on — a payment processor, a scheduler, a third-party booking tool — then the browser that would report the conversion is never on the page at the moment it happens. The metric reads zero regardless of demand. Trace the physical path from the user's action to the row in your database before you optimise against any number.

How often should I audit conversion tracking?

Before any significant spend decision, and after any change to the site, the forms, the tag manager, or the consent banner. At the volumes most small businesses deal in there are tens of conversions rather than millions, so reading every record end to end is an afternoon rather than a project. The audit that matters most is the first one, because that is where the accumulated fictions surface.