I done the below in HTML for WordPress as it looks better.
Checkout conversion is down and two teams disagree about why. One says the site broke, the other says the tracking did. You have a month of GA4 events and have to settle it. Work it yourself before reading the walkthrough.
The situation
Wrenmoor sells home and garden online. Checkout completion has fallen over the past few weeks. The front end team shipped a checkout release during the period. The analytics team changed tags in the same window. Each points at the other, and the argument has been running for a week without evidence.
You have been given the raw event export and asked to say which it is, and if the loss is real, what it costs.
The data
One file, wrenmoor-ga4-events.csv, 35,994 events across 30 days, in the shape a GA4 BigQuery export produces. One row per event, uncleaned.
| Column | Meaning |
|---|---|
| event_timestamp | When the event fired |
| event_name | The funnel events, from session_start through to purchase |
| user_pseudo_id | The GA4 client identifier |
| ga_session_id | Session identifier as GA4 emits it |
| device_category, browser | Device and browser |
| session_source_medium | Acquisition source |
| item_list_name, item_id | Product context where present |
| value, currency | Basket or order value |
| transaction_id | Order reference, on purchase events |
| engagement_time_msec | Engagement time reported with the event |
What the room believes
- Checkout completion has genuinely fallen.
- It is a tracking artefact and no orders were lost.
- If it is real it affects all traffic, because it followed a release.
- The reported revenue figure is trustworthy.
They are offered as alternatives. You do not have to accept that framing.
Definition of done
- A verdict on each of the four beliefs, with evidence.
- If something broke: the exact step, the segment, and the date, found from the data rather than taken from the deployment log.
- An argument that distinguishes a site failure from a measurement failure, which is not a statistical test.
- A cost, annualised, with the counterfactual stated and varied.
- A separate list of any measurement faults you find that are real but are not the cause, with an owner for each.
Four questions worth asking before you build a funnel
What actually identifies a session in a GA4 export, and is one column enough? Is every purchase event a purchase? What should you do with sessions that record a start and no page view? And if a step is missing, how would you tell whether the page broke or the tag did?
If you want to go further
- Find the break date by scanning candidates rather than testing the one you suspect, and check whether the two agree.
- Put a confidence interval next to every segment rate before you decide which segments are affected. Count how many look alarming and how many survive.
- Take your counterfactual baseline from more than one place and see whether the cost estimate holds.
- Write the monitoring rule that would have caught this in a day instead of 18.
When you are done, read the walkthrough. It works the same export through eight sections to a bug report and a cost. Two of the four beliefs turn out to be true at once, which is the part most people miss. Compare how narrowly you were able to state the problem, because that is what decides whether it gets fixed this week or next quarter.
Checkout conversion fell and nobody could say whether it was real. Two explanations were on the table, a broken checkout or broken tracking, and the argument had been running for a week. Both turned out to be true. Only one of them was costing 936 pounds a day.
The situation. Wrenmoor sells home and garden online. 35,994 GA4 events across 30 days, 11,501 sessions, 6,171 users. Checkout completion is down, engineering says the tags changed, marketing says the site broke, and the analyst has to say which.
What the business is left with. The step that broke, the segment it broke for, the date it started, what it costs, and a separate list of the tracking faults that are real but are not this.
Attempt it first. The brief has the export and the question with none of the answers.
Contents
Section 1Problem Definition
No code yet. The question here is not where the funnel leaks, it is whether the leak is in the site or in the measurement, and those need different people to fix them.
Business objective
Checkout completion has dropped. Decide whether the drop is a real loss of orders or an artefact of how the events are recorded, and if it is real, say where and what it costs. The engineering team will not act without a specific step and a specific segment.
Success criteria
| Requirement | Why |
|---|---|
| A verdict on real against tracking, with evidence for each | The two need different owners. Handing engineering a tracking fault wastes a sprint |
| The exact step, not a stage | A funnel drop between checkout and purchase can be four different bugs. Naming the event that stops firing narrows it to one |
| The segment and the date | A fault that affects everyone is a release. A fault confined to a browser is a compatibility bug, and the date tells you which release |
| A cost, annualised | Nothing gets prioritised without one |
Hypotheses
- H1. Checkout completion has genuinely fallen.
- H2. The fall is a tracking artefact and no orders were lost.
- H3. If real, it affects all traffic, because it followed a release.
- H4. The reported revenue figure is trustworthy.
H1 and H2 are stated as alternatives on purpose. They are not, and discovering that both are true at once is the substance of this project.
The distinction that runs through everything below
A real failure removes orders from the business. A tracking failure removes orders from the report. They look identical in a dashboard and they are separated by one question: did the volume of the preceding event change too? Section 7b turns that into a test.
Section 2Data Collection
A GA4 event export, one row per event, in the shape the BigQuery export produces.
import numpy as np
import pandas as pd
from scipy import stats
d = pd.read_csv('data/wrenmoor-ga4-events.csv', parse_dates=['event_timestamp'])
d['day'] = d['event_timestamp'].dt.day
print('events :', len(d))
print('users :', d['user_pseudo_id'].nunique())
print('range :', d['event_timestamp'].min().date(), 'to', d['event_timestamp'].max().date())
print()
print(d['event_name'].value_counts().to_string())
events : 35,994
users : 6,171
range : 2026-06-01 to 2026-06-30
purchase 1,031
add_payment_info 1,213
begin_checkout 1,752
add_to_cart 2,814
view_item 6,821
page_view 10,805
session_start 11,501
| Column | Meaning |
|---|---|
| event_timestamp | When the event fired |
| event_name | One of the seven funnel events |
| user_pseudo_id | The GA4 client identifier |
| ga_session_id | Session identifier. Unique within a user and nowhere else |
| device_category | mobile, desktop or tablet |
| browser | Browser name |
| session_source_medium | Acquisition source |
| item_list_name, item_id | Product context where the event carries it |
| value, currency | Basket or order value |
| transaction_id | Order reference, on purchase events only |
| engagement_time_msec | Engagement time reported with the event |
Section 3Data Preprocessing
Event data needs a different kind of preparation from tabular data. Nothing here is missing or mistyped. The work is turning a stream of events into sessions, and the very first decision is the one that most analyses get wrong.
3aDuplicates and schema checks
print('exact duplicate rows :', d.duplicated().sum())
print('duplicate events :', d.duplicated(
subset=['event_timestamp', 'event_name', 'user_pseudo_id', 'ga_session_id']).sum())
# THE KEY. ga_session_id is a session-start timestamp. It is unique within a
# user and means nothing across users, so two people can share one value.
shared = d.groupby('ga_session_id')['user_pseudo_id'].nunique()
print('ga_session_id values used by more than one user :', (shared > 1).sum())
d['session_key'] = d['user_pseudo_id'] + '|' + d['ga_session_id'].astype(str)
print('sessions on the composite key :', d['session_key'].nunique())
print('sessions on ga_session_id :', d['ga_session_id'].nunique())
exact duplicate rows : 0
duplicate events : 0
ga_session_id values used by more than one user : 26
sessions on the composite key : 11,501
sessions on ga_session_id : 11,475
Why this matters more than 26 collisions suggest
Here the two keys differ by only 26 sessions, because ga_session_id is a timestamp and collisions need two people to start a session in the same second. On a busier property, or on an export where the id is a small integer, the same mistake merges thousands of unrelated journeys and produces a funnel in which people buy things they never added to a basket.
Use user_pseudo_id plus ga_session_id. Always. It costs one line and it is the difference between a funnel and a coincidence.
3bHandling categorical mess
for col in ['event_name', 'device_category', 'browser']:
print(col, ':', sorted(d[col].unique()))
event_name : ['add_payment_info', 'add_to_cart', 'begin_checkout', 'page_view', 'purchase', 'session_start', 'view_item']
device_category : ['desktop', 'mobile', 'tablet']
browser : ['Chrome', 'Edge', 'Firefox', 'Safari', 'Samsung Internet']
Clean. Seven event names and no variants, which is what a properly governed measurement plan looks like. Worth confirming rather than assuming, because a renamed event halfway through a month is one of the classic causes of a funnel that appears to collapse.
3cDealing with outliers
The outlier in event data is not a large number. It is an event that should not exist.
pur = d[d['event_name'] == 'purchase']
print('purchase events :', len(pur))
print('unique transactions :', pur['transaction_id'].nunique())
print('surplus events :', len(pur) - pur['transaction_id'].nunique())
# what are the surplus ones
dups = pur[pur['transaction_id'].duplicated(keep=False)]
g = dups.groupby('transaction_id')
print('groups :', g.ngroups)
print('same user :', (g['user_pseudo_id'].nunique() == 1).sum())
print('same value :', (g['value'].nunique() == 1).sum())
print('median seconds apart :',
g['event_timestamp'].apply(lambda s: (s.max() - s.min()).total_seconds()).median())
purchase events : 1,088
unique transactions : 1,031
surplus events : 57
groups : 57
same user : 57
same value : 57
median seconds apart : 7.0
57 surplus purchase events. Every one is the same user, the same transaction id and the same value, a median of 7.0 seconds after the first, never more than 10.0. A customer cannot place the same order twice in seven seconds. This is the purchase tag firing again on the confirmation page.
Tracking fault one, found and sized
Reported revenue 163,187.00. Deduplicated on transaction id, 155,140.00. Every revenue figure this property has produced is overstated by 5.19%, which is 8,047.00 pounds in this month alone.
Real, worth fixing, and not the reason checkout completion fell. Hold onto it, because section 7 uses it to show what a tracking fault looks like when you compare it with a real one.
3dHandling missing values
ses = d.drop_duplicates('session_key')[['session_key', 'device_category',
'browser', 'day']].copy()
for s in ['session_start', 'page_view', 'view_item', 'add_to_cart',
'begin_checkout', 'add_payment_info', 'purchase']:
ses[s] = ses['session_key'].isin(set(d.loc[d['event_name'] == s, 'session_key']))
print('sessions :', len(ses))
print('sessions with no page_view :', (~ses['page_view']).sum(),
f"({(~ses['page_view']).mean():.2%})")
sessions : 11,501
sessions with no page_view : 696 (6.05%)
6.0% of sessions record a session start and no page view. That is not missing data to impute, it is a consent banner: the page view fires only after consent, and some visitors leave first or decline. Treat those sessions as real and count them in the denominator. Dropping them would quietly inflate every rate below.
3eHandling skewed data
Order values are right-skewed, as they are everywhere, with a mean order of 150.48 pounds. Nothing in this analysis takes the mean of a skewed column: the funnel is built on counts of sessions, and revenue only appears at the end to convert lost orders into money. Where a mean does get used, section 7c varies it.
3fData types and normalisation
The one transformation that matters is the one above: a stream of 35,994 events becomes 11,501 sessions with seven boolean columns, one per funnel step. Everything after this is a cut of that frame.
# does a later step ever appear without the earlier one it depends on
for a, b in [('view_item', 'page_view'), ('add_to_cart', 'view_item'),
('begin_checkout', 'add_to_cart'), ('add_payment_info', 'begin_checkout'),
('purchase', 'add_payment_info')]:
print(f'{a:18s} without {b:18s} {(ses[a] & ~ses[b]).sum():5d} of {ses[a].sum():5d}')
view_item without page_view 417 of 6821
add_to_cart without view_item 0 of 2814
begin_checkout without add_to_cart 0 of 1752
add_payment_info without begin_checkout 0 of 1213
purchase without add_payment_info 0 of 1031
Every dependency holds except one: 417 sessions view an item without a page view, which is the same consent effect from 3d arriving from a different direction. No session buys without a checkout, and none checks out without a basket. The export is well formed, so any drop we find below is about the site, not about the pipeline.
Section 4Exploratory Data Analysis
4aThe primary metric
steps = ['session_start', 'page_view', 'view_item', 'add_to_cart',
'begin_checkout', 'add_payment_info', 'purchase']
prev = None
for s in steps:
k = ses[s].sum()
rate = '' if prev is None else f' step {k/prev:6.1%}'
print(f'{s:18s} {k:6,d} {k/len(ses):6.1%} of sessions{rate}')
prev = k
session_start 11,501 100.0% of sessions
page_view 10,805 94.0% of sessions step 94.0%
view_item 6,821 59.3% of sessions step 63.1%
add_to_cart 2,814 24.5% of sessions step 41.2%
begin_checkout 1,752 15.2% of sessions step 62.3%
add_payment_info 1,213 10.5% of sessions step 69.2%
purchase 1,031 9.0% of sessions step 85.0%
The funnel across the whole month. The biggest absolute losses are early, at view item and add to cart, which is normal. The step to watch is add payment info.
Read it the way a growth team would. The worst step rates are 63.1% into view item and 41.2% into add to cart, and those are where the volume is lost. They are also completely normal for a home and garden retailer, and they are not what changed.
The one that changed is 69.2% from begin checkout into add payment info. A month average hides when it moved, which is why 4b is a time series rather than another table.
4bNumerical variables
One series matters: the share of checkouts that reach the payment step, by day.
Checkouts reaching the payment step, by day, split by whether the session was Safari on a phone. One line falls off a cliff on 13 June. The other does not move.
That chart is the finding, and everything after it is confirmation. But it was drawn after the segment was known. Section 7b does it in the honest order: find the date first, without assuming one, then find the segment.
4cCategorical variables
| Segment | Share of sessions |
|---|---|
| Safari on mobile | 27.6% |
| Everything else | 72.4% |
Safari on a phone is 27.6% of all sessions. Large enough that a failure there moves the company-wide number, small enough that the company-wide number never looked catastrophic. That combination is why this ran for 18 days.
4dRelationships between variables
A funnel drop has to be attributed to a step before it can be attributed to a cause. Compare each step transition, before and after the break, for the affected segment and for everything else.
| Segment | Transition | Before | After | Change |
|---|---|---|---|---|
| mobile Safari | add_to_cart to begin_checkout | 61.9% (n=273) | 60.3% (n=375) | -1.6pp |
| mobile Safari | begin_checkout to add_payment_info | 71.6% (n=169) | 13.3% (n=226) | -58.3pp |
| mobile Safari | add_payment_info to purchase | 84.3% (n=121) | 80.0% (n=30) | -4.3pp |
| everything else | add_to_cart to begin_checkout | 61.4% (n=880) | 63.5% (n=1,286) | +2.2pp |
| everything else | begin_checkout to add_payment_info | 80.6% (n=540) | 76.7% (n=817) | -3.8pp |
| everything else | add_payment_info to purchase | 85.3% (n=435) | 85.2% (n=627) | -0.1pp |
One transition, not a stage
For Safari on mobile, add to cart into begin checkout barely moves -1.6pp, and add payment info into purchase barely moves -4.3pp.
Begin checkout into add payment info falls -58.3 percentage points. The step before is fine and the step after is fine. Whatever broke sits between the checkout page loading and the payment form being ready.
That single row is what makes the finding actionable. Telling engineering that checkout conversion is down starts a week of guessing. Telling them that begin_checkout fires and add_payment_info does not, on one browser, on one device, from one date, is a bug report.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. Completion has genuinely fallen | Confirmed, for one segment | Safari on mobile falls from 71.6% to 13.3% at the payment step, chi-square 139.3, p below the smallest number floating point can represent |
| H2. It is a tracking artefact | Also true, and separately | 57 duplicated purchase events overstate revenue by 5.19%. Real, but it inflates rather than depresses, so it cannot explain the fall |
| H3. It affects all traffic | Wrong | Of 8 device and browser combinations with enough volume, exactly one has intervals that separate |
| H4. Reported revenue is trustworthy | Wrong | Overstated 5.19% by the duplicate tag, before any adjustment for the orders the checkout failure lost |
H1 and H2 were written as alternatives and both are true. That is the most useful thing in the project: the room had spent a week arguing about which one it was, when the answer was that they were describing two different problems that happened to arrive together.
4fSubgroups
Change at the payment step by device and browser. Only one bar is drawn in red, because only one has confidence intervals that separate.
| Device | Browser | Before | After | Change | Intervals |
|---|---|---|---|---|---|
| mobile | Safari | 71.6% | 13.3% | -58.3pp | separate |
| desktop | Firefox | 83.3% | 51.5% | -31.8pp | overlap, n=33 |
| desktop | Edge | 81.8% | 73.6% | -8.3pp | overlap, n=87 |
| desktop | Safari | 82.9% | 75.4% | -7.4pp | overlap, n=61 |
| desktop | Chrome | 80.3% | 79.5% | -0.8pp | overlap, n=254 |
| mobile | Chrome | 77.3% | 77.5% | +0.2pp | overlap, n=222 |
| tablet | Safari | 80.8% | 81.5% | +0.8pp | overlap, n=65 |
| mobile | Samsung Internet | 85.7% | 87.5% | +1.8pp | overlap, n=48 |
The second-worst row is a trap
Desktop Firefox shows -31.8 percentage points, which looks like a second bug. It is 24 sessions before and 33 after, and its intervals overlap comfortably.
Scan enough small cells and one will always look alarming. The rule that saves you is to report the interval next to every rate and only act where they come apart. Here that leaves exactly one segment out of 8.
Section 5Metric Construction
| Metric | Definition | Why built this way |
|---|---|---|
| Session | user_pseudo_id plus ga_session_id | The session id alone is not unique across users. Section 3a shows what that costs |
| Step reached | The session contains at least one event of that name | Not the count of events, and not strict ordering. A customer who returns to the basket twice has not two sessions and should not count twice |
| Step rate | Sessions reaching step N divided by sessions reaching step N minus 1 | The step rate localises a fault. A rate against all sessions moves whenever anything upstream moves, which is how a payment bug gets blamed on traffic quality |
| Transactions | Distinct transaction_id, not purchase events | Section 3c found 57 surplus events. Counting events overstates orders and revenue by 5.19% |
| Interval | Wilson score interval on every rate | Device and browser cells get small fast, and 4f shows what happens without one |
def wilson(k, n, z=1.96):
"""Wilson score interval. Correct at the small counts a device and browser
split produces, where the normal approximation gives intervals that are too
narrow exactly where the argument is being made."""
if n == 0:
return 0.0, 0.0
p = k / n
denom = 1 + z * z / n
centre = p + z * z / (2 * n)
half = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
return (centre - half) / denom, (centre + half) / denom
# transactions, not purchase events
pur = d[d['event_name'] == 'purchase']
print('purchase events :', len(pur))
print('transactions :', pur['transaction_id'].nunique())
purchase events : 1,088
transactions : 1,031
Step rate against overall rate
Both belong in the report and they answer different questions. The overall rate, sessions reaching a step divided by all sessions, tells you how much volume the business gets. The step rate tells you where a change happened. Presenting only the first is the most common reason a funnel report cannot locate a bug.
Section 6Method Selection
| Question | Method | Why not the obvious alternative |
|---|---|---|
| When did it change | Scan every candidate date and take the largest chi-square | Picking the release date and testing it confirms whatever you already believed. The scan can disagree with the deployment log, and when it does that is a finding |
| Is the change real | Chi-square on the two-by-two, plus Wilson intervals | Comparing two percentages by eye cannot distinguish a real break from a small cell, which is exactly the mistake 4f is about |
| Is it real or tracking | Check the volume of the preceding event | No statistical test separates these. It is a structural argument and section 7b sets it out |
| What does it cost | Counterfactual on the pre-break completion rate | A model would add nothing. The arithmetic is transparent and section 7c varies the baseline three ways |
Section 7Analysis and Validation
7aThe baseline the business believes
Two beliefs, held by two teams, and they cannot both be right. Marketing says checkout broke after the release. Engineering says the tags were changed in the same window and the drop is measurement. Neither has produced evidence beyond a dashboard line going down.
Both are partly right, which is the least satisfying outcome for the room and the most useful for the business. Beating this baseline means separating the two claims rather than picking a winner.
7bThe analysis
Find the date without assuming it
bc = ses[ses['begin_checkout']]
best = None
for cut in range(4, 28):
a, b = bc[bc['day'] < cut], bc[bc['day'] >= cut]
if len(a) < 50 or len(b) < 50:
continue
tbl = [[a['purchase'].sum(), (~a['purchase']).sum()],
[b['purchase'].sum(), (~b['purchase']).sum()]]
chi2 = stats.chi2_contingency(tbl, correction=False)[0]
if best is None or chi2 > best[1]:
best = (cut, chi2)
print('largest break at day', best[0], 'with chi2', round(best[1], 1))
largest break at day 13 with chi2 30.4
The scan ran across every candidate date from the 4th to the 27th and picked the 13th on the data alone. It matches the checkout release, which is reassuring rather than circular, because the scan was not told the release date. Had it landed three days earlier, that would have been the finding.
Locate the step, then the segment
CUT = best[0]
ses['period'] = np.where(ses['day'] >= CUT, 'from', 'before')
# every device and browser combination with enough volume to say anything
for (dev, br), g in ses.groupby(['device_category', 'browser']):
a = g[(g['period'] == 'before') & g['begin_checkout']]
b = g[(g['period'] == 'from') & g['begin_checkout']]
if len(a) < 20 or len(b) < 20:
continue
lo_a, hi_a = wilson(a['add_payment_info'].sum(), len(a))
lo_b, hi_b = wilson(b['add_payment_info'].sum(), len(b))
flag = 'SEPARATED' if hi_b < lo_a else 'overlap'
print(f'{dev:8s} {br:17s} {a["add_payment_info"].mean():.3f} -> '
f'{b["add_payment_info"].mean():.3f} {flag}')
mobile Safari 0.716 -> 0.133 SEPARATED
desktop Firefox 0.833 -> 0.515 overlap
desktop Edge 0.818 -> 0.736 overlap
desktop Safari 0.829 -> 0.754 overlap
desktop Chrome 0.803 -> 0.795 overlap
mobile Chrome 0.773 -> 0.775 overlap
tablet Safari 0.808 -> 0.815 overlap
mobile Samsung Internet 0.857 -> 0.875 overlap
One segment out of 8. Safari on a phone, 71.6% to 13.3%, intervals 64.4% to 77.9% against 9.5% to 18.3%, nowhere near touching. Chi-square 139.3 on 169 and 226 checkouts, p below the smallest number floating point can represent.
Real, or tracking? The test that settles it
A broken payment form and a broken payment tag produce the same missing events. One thing separates them.
sm = ses[(ses['device_category'] == 'mobile') & (ses['browser'] == 'Safari')]
before = sm[sm['period'] == 'before']['begin_checkout'].sum()
after = sm[sm['period'] == 'from' ]['begin_checkout'].sum()
print(f'begin_checkout, Safari on mobile: {before} in 12 days, {after} in 18 days')
print(f'per day: {before/12:.1f} before, {after/18:.1f} after')
begin_checkout, Safari on mobile: 169 in 12 days, 226 in 18 days
per day: 14.1 before, 12.6 after
The checkout failure is real
Three things have to be true at once for this to be a tracking fault, and none of them is.
The tag still fires. begin_checkout runs at 14.1 a day before and 12.6 after. If the container had broken on Safari, that event would have gone with it.
The step after is healthy. Of the few Safari sessions that do reach payment, 80.0% still purchase, against 84.3% before. The purchase tag works fine. Only getting to it does not.
The real tracking fault behaves differently. The duplicate purchase tag from 3c runs at a similar rate on every browser, between 9.0% and 14.3%. Measurement faults do not respect browser boundaries the way rendering bugs do.
The duplicate purchase rate by segment. Flat across every browser and device, which is exactly what a tag problem looks like, and the opposite of the payment failure.
What it costs
pre_rate = sm[(sm['period'] == 'before') & sm['begin_checkout']]['purchase'].mean()
post = sm[(sm['period'] == 'from') & sm['begin_checkout']]
expected = len(post) * pre_rate
lost = round(expected - post['purchase'].sum()) # whole orders only
aov = round(pur.drop_duplicates('transaction_id')['value'].mean(), 2)
print(f'checkouts since the break : {len(post)}')
print(f'expected orders : {expected:.0f}')
print(f'actual orders : {post["purchase"].sum()}')
print(f'lost orders : {lost:.0f}')
print(f'lost revenue : {lost * aov:,.0f} over 18 days')
print(f'annualised : {lost * aov / 18 * 365:,.0f}')
checkouts since the break : 226
expected orders : 136
actual orders : 24
lost orders : 112
lost revenue : 16,854 over 18 days
annualised : 341,757
112 orders in 18 days, 16,854 pounds, running at 936 a day. Left alone for a year that is 341,757 pounds.
7cSensitivity and robustness
The cost rests on a counterfactual: what those checkouts would have converted at. Take that baseline from three different places and vary the order value.
| Baseline used | Rate | Lost orders | Lost revenue | Annualised |
|---|---|---|---|---|
| baseline rate from the 12 days before | 60.4% | 112 | 16,854 | 341,757 |
| baseline rate from all other browsers after the break | 65.4% | 124 | 18,660 | 378,374 |
| baseline rate from mobile Chrome after the break | 67.6% | 129 | 19,412 | 393,631 |
| AOV 120.00 | 60.4% | 112 | 13,440 | 272,533 |
| AOV 150.48 | 60.4% | 112 | 16,854 | 341,757 |
| AOV 180.00 | 60.4% | 112 | 20,160 | 408,800 |
Every version lands between 112 and 129 lost orders, and every version of the annualised figure is a number that gets a bug fixed the same week. The conclusion does not depend on the choice.
Why three baselines and not one
The pre-break rate for the same segment is the natural choice and it is also the most vulnerable, because it assumes nothing else changed on the 13th. Taking the baseline from other browsers over the same days removes that assumption entirely, and it gives a larger number, not a smaller one. When your most conservative assumption is the one you led with, the estimate is safe.
7dWhat would change the answer
- A second release on the same day. The scan finds a break, not a cause. The deployment log has to confirm what shipped on the 13th, and if two things shipped, this analysis cannot separate them.
- A Safari version split. The export carries a browser name and no version. If the failure is confined to one iOS version the fix is narrower and the traffic at risk is smaller. Adding browser version to the export costs nothing and would answer it.
- Sessions that never reached checkout. This measures the loss among people who got to the checkout page. If the failure also deterred returning customers from starting, the true cost is higher and this figure is a floor.
- The duplicate tag interacting with the counterfactual. It inflates purchases uniformly, so it inflates the pre-break baseline slightly and makes the estimate marginally conservative. Deduplicating first, as section 5 does, removes it.
Catch the duplicate before it reaches a report
GA4 dataLayer Validator
The duplicate purchase tag is the single most common ecommerce tagging fault and it is invisible in a dashboard, because a report of 5.19% too much revenue looks like a good month. Paste your dataLayer push in and this checks the event shape before it ships.
Free, no signup. Pairs with the Measurement Plan Generator.
Section 8Decision and Handoff
Two problems, one urgent
Real, and costing money now. Since 13 June, sessions on Safari on a phone reach the checkout page and cannot get to the payment step. The rate fell from 71.6% to 13.3%, chi-square 139.3. It has cost 112 orders and 16,854 pounds in 18 days, 341,757 annualised.
Real, and not urgent. The purchase tag fires twice on 5.5% of orders, overstating reported revenue by 5.19%. It has been doing so for as long as the property has existed and it inflates rather than depresses, so it is not the drop. It does mean every revenue figure quoted from GA4 is wrong.
What to do, and who owns it
| Action | Evidence | Owner | Urgency |
|---|---|---|---|
| Fix the payment step on Safari on mobile. Start with whatever shipped on 13 June and test the payment form on an iPhone | begin_checkout fires normally at 12.6 a day and add_payment_info does not follow. Confined to one browser and device out of 8 with volume | Front end | 936 a day |
| Remove the second purchase tag fire on the confirmation page | 57 surplus events, same transaction id, median 7.0 seconds apart | Analytics | This sprint |
| Restate reported revenue for the affected period | 8,047.00 pounds overstated this month, 5.19% | Analytics, Finance | Before the next board pack |
| Add browser version to the export | The failure is browser-specific and the export cannot say which version | Analytics | Next change |
| Alert on step rates by device and browser, not on the total | The company-wide number never looked bad enough to investigate for 18 days | Analytics | Next quarter |
What not to do
- Do not treat the duplicate tag as the explanation. It inflates orders. The problem is that orders fell.
- Do not act on desktop Firefox. It is the second-worst row and its intervals overlap. 33 sessions cannot support a bug report.
- Do not deduplicate by dropping purchase rows blindly. Deduplicate on transaction id and keep the first. Dropping every row in a duplicate group would remove real orders too.
- Do not report this as a checkout conversion problem. It is one transition, on one browser, on one device, from one date. The narrower the statement, the faster it gets fixed.
Reproducibility
| Item | Value |
|---|---|
| File | wrenmoor-ga4-events.csv, 35,994 events |
| Window | 2026-06-01 to 2026-06-30 |
| Session key | user_pseudo_id plus ga_session_id |
| Transactions | Distinct transaction_id, not purchase events |
| Break date | Found by scanning days 4 to 27, not assumed |
| Methods | Chi-square contingency, Wilson score intervals, changepoint scan, counterfactual |
| Libraries | pandas, numpy, scipy.stats |
What to take from this
- Key your sessions on user plus session id. Every funnel built on the session id alone is a coincidence, and on a busy property it is a spectacular one.
- Localise to a transition before you attribute a cause. The step before and the step after were both healthy, and that pair of facts is what turned a vague complaint into a bug report.
- Real failures respect browser boundaries. Tracking failures do not. That asymmetry is the cheapest diagnostic in this whole project.
- Check the volume of the event before the missing one. If it still fires, the container is alive and the problem is the site.
- Find the break date from the data. Testing the release date you already suspect will confirm it whether or not it is true.
- Put an interval next to every rate. One of the 8 segments here looked broken and was not.
The argument in the room was whether the problem was real or tracking. The answer was both, they were unrelated, and only one of them was worth interrupting a sprint for. Getting to that took locating a single transition, on a single segment, from a single date, and everything else in this project exists to make that statement safe to make.
So the next one gets caught in a day
Digital Tracking Documentation Template
This ran for 18 days because nobody owned a document saying which events fire where, on what, and what should alert when they stop. The template is that document, with the event table, the ownership and the QA steps already laid out.
Free download, no signup.
Companion projects. Post-Test Analysis takes a finished A/B test to a ship decision. The Profit Leak Audit builds a contribution margin per order. Channel Reallocation audits a channel report and finds every ranking in it reverses. This one is the pair to that last: there the data was broken, here the data was fine and the site was not.
Have fun with this, Andrei.
[…] The Funnel That Leaks […]