Investigation date: 2026-08-24 Trigger: Littledata launched a second Google Ads purchase connection as a parallel option. It is a real web conversion action rather than an offline click upload, and it is expected to have more attributed revenue than the v1 upload. This document records what the v2 action looks like in the data, what it actually reports against v1 in the accounts running both, and how the value model reads it.
Background on why v1 was capped in the first place: UPLOAD_CLICKS conversion actions do not receive view-through attribution.
Update 2026-08-28 — the view-through mechanism is a minority contributor, not the main one.
Littledata Google Ads purchase connection: v1 vs v2, measured
measures where the uplift actually comes from across all five accounts running both connections.
Impression-side credit (ENGAGED_VIEW + IMPRESSION) accounts for 0% to 31% of the v1→v2
difference — 31% on becausemarket-prod, 0% on soda-sense — with click-side match-rate and value
gains the larger half wherever there is a gain at all. Read the split from
segments.conversion_attribution_event_type, never from metrics.view_through_conversions, which
omits engaged views and understated becausemarket-prod’s impression-side uplift 11-fold.
Both connections use the same action names, so type is the only reliable discriminator — never the name.
| v1 | v2 | |
|---|---|---|
conversion_action.type |
UPLOAD_CLICKS |
WEBPAGE |
| Google Ads UI “Conversion source” | Website (Import from clicks) | Website |
| Action name | Purchase - Littledata, or Purchase - Littledata (<customerId>) |
Purchase - Littledata (Web) |
| Click-through window | 90 days | 90 days |
| View-through window | 1 day (and structurally inert — see linked doc) | 30 days |
| Attribution owner | Littledata, keyed on the original click ID |
Naming is consistent across every account with v2: only the three
Purchase / New Customer Purchase / Returning Customer Purchase families, each suffixed (Web).
ldPurchaseConnectionVersion() in src/services/googleAds/constants.ts is the single place that
decides which connection an action belongs to.
The two connections attribute the same orders. Nothing may ever sum them.
Nine accounts carry a v2 web purchase action: soda-sense, tentbox-6623, becausemarket-prod,
roseland-furniture, jimmy-joy, gardeners-dream-online, sparklingjewels-com, plus the internal
littledata and igor-testing-tracking3.
Counting them needs two sources, and neither is complete on its own. The snapshot scan
(byAction.actionType = "WEBPAGE", category PURCHASE) is what the benchmark uses, but it can only
see an account whose v2 action has actually attributed something and whose snapshots captured it —
it missed gardeners-dream-online (v2 configured, nothing attributed since 2026-06-16) and both
internal accounts. The googleAdsGoalConfig audit reads conversion actions live and catches those,
but 39 accounts fail it outright with PERMISSION_DENIED, so v2 cannot be enumerated for them at all
(sparklingjewels-com proves at least one 403 account had it).
Treat a donor count as “accounts that survived the pipeline”, never “accounts running v2”. The report now prints the accounts it could not measure and why — see Watching the ratio.
Every rollout follows the same shape: v2 appears at a trickle, climbs over roughly two to three weeks, then plateaus near v1. Crossover happened around 2026-08-10 to 2026-08-13 for the three earliest accounts.
Steady-state comparison — settled days only, ramp-up excluded (see
computeGoogleAdsV2UpliftBenchmark):
| Account | Steady days | v2 ÷ v1 attributed revenue |
|---|---|---|
becausemarket-prod |
11 | 1.23× |
soda-sense |
10 | 1.10× |
tentbox-6623 |
7 | 0.95× |
jimmy-joy |
8 | 0.83× |
Cohort median 1.03×, P25 0.92×, P75 1.13×.
This measurement is not yet usable, and it does not set the customer-facing figure. tentbox-6623
and jimmy-joy had only just started when this was measured, so their sub-parity readings describe an
unfinished rollout rather than the connection — which is most of what drags the median to 1.03×. The
single-week comparisons in the launch screenshots (Soda Sense 1.15×, Because Market 1.17×) come from
the two accounts that had finished.
Two more accounts are excluded as not yet measurable at all: gardeners-dream-online (v2 switched on
2026-08-23, one partial day) and sparklingjewels-com (v2 only, no v1 to compare against).
Revisit from 2026-09-07, by which point the four current donors will have a fortnight of settled
steady-state history and gardeners-dream-online should qualify. Until then the value model quotes a
flat assumption (below).
googleAdsRoasFromSnapshots)Each candidate ROAS window sums the two connections separately and reads whichever reported more
over that window, recording the choice as ldPurchaseVersion alongside both raw totals.
Preferring the larger rather than always preferring v2 is deliberate: a v2 connection switched on
mid-window is still filling in, and reading the partial side would compare a fortnight of Littledata
against a full quarter of baseline. The consequence while v2 is young is that short windows
(7/14-day) already read v2 in soda-sense, becausemarket-prod and sparklingjewels-com, while
long windows still read v1 — two weeks of v2 cannot out-total 90 days of v1. It resolves itself as
v2 accumulates history.
An account running only v2 (v1 switched off) now registers as Littledata-active at all; before this change it produced no Google Ads value tier.
v1 used to be scaled by googleAdsAttributionWindowValueUpliftFactor, the result of a
conversion-window experiment that widened v1’s click window to the primary goal’s. The experiment
was a one-off before v2 launched and is retired, and the factor went with it: in 80 of its 159
successful runs the click window was not widened at all (90 → 90 days) yet the factor still came
out ~1.01, so the typical +1% was capture-to-capture drift rather than a window effect. v2 carries
the wider windows natively, which is the real fix.
For an account with no v2 activity at all, the model estimates what v2 would add using
GOOGLE_ADS_V2_ASSUMED_UPLIFT — a flat 10% launch assumption, not the measured median:
predicted additional revenue = own v1 revenue × 1.10 − Littledata value already reported
Gates, all of which must pass before anything is stored or shown:
MIN_REPORTABLE_GOOGLE_ADS_V2_MONTHLY_USD ($100) — below that, 10% of this
account’s Google Ads revenue isn’t worth its attentionIt surfaces as one line on the Google Ads hero card:
Test out Littledata’s Google Ads v2 connection to capture another 10% more attributed revenue
The percentage is interpolated from the constant, so changing the assumption changes the copy. The line
quotes no dollar figure on purpose: putting a precise monthly amount on an assumed rate would dress
the assumption up as a measurement. The dollar estimate is still stored on the tier
(googleAdsV2PredictedAdditionalRevenueUsd) for internal sorting, and is never part of value,
any ROI, or any value total — it is a counterfactual about a connection the account does not run.
checkGoogleAdsGoalConfig counts a Littledata-named WEBPAGE purchase action as Littledata being
configured and primary, so an account that finishes migrating to v2 is not told Littledata is
missing. A v1 action losing primary while a v2 action holds the goal is the migration completing, not
a demoted goal, and no longer raises a warning — narrowed to v1 actions and only when a v2 action is
actually primary, so v1-only accounts behave exactly as before.
Baseline selection was already safe: every Littledata-named action is excluded from baseline candidates by name, so the v2 action could never have been picked as a competing pixel.
Attributed revenue keeps arriving for about 90 days, and the two connections do not fill in at the
same speed: v2 carries the account’s 30-day view-through window where v1 is stuck on 1 day, so v2
keeps collecting credit long after v1 has finished. Cumulative share of each connection’s final value,
from segments.conversion_lag_bucket (query it FROM customer — FROM conversion_action rejects the
segment), measured 2026-09-18:
| days | tentbox v1/v2 | gardeners-dream v1/v2 | soda-sense v1/v2 | becausemarket v1/v2 |
|---|---|---|---|---|
| 3 | 67.1% / 61.0% | 85.6% / 78.1% | 53.4% / 49.9% | 44.7% / 31.6% |
| 14 | 87.6% / 79.2% | 93.2% / 86.9% | 77.9% / 72.4% | 60.3% / 46.4% |
| 30 | 95.9% / 91.1% | 96.9% / 92.9% | 90.0% / 87.2% | 75.2% / 63.4% |
| 90 | ~100% / ~100% | ~100% / ~100% | ~100% / ~100% | ~100% / ~100% |
A ratio taken from recent days therefore understates v2. V2_UPLIFT_SETTLE_DAYS is 30 for that
reason: it is where the gap narrows to a few points on every account measured, so the residue is small
and still in v2’s favour. Day-one maturity varies hugely by account (gardeners-dream 79%, becausemarket
40%), so no single constant is right for everyone — 30 is a floor, not a guarantee.
Two consequences worth knowing before quoting anything:
becausemarket-prod looked like its v1 baseline collapsed
65% over six weeks; maturity-correcting it leaves a real but much smaller 41% decline. Correcting also
pushes its ratio up (4.30x to 5.68x on the last week), because v2 is the slower side — so a lag
artifact is not an explanation for an inflated ratio.Every capture used to re-read all 90 days. Attribution arrives overwhelmingly early, so that spent
~2.5x the rows (tentbox 25,014 vs 9,208; gardeners-dream 13,998 vs 5,885) refreshing days that barely
move. A capture now re-reads the last GOOGLE_ADS_SNAPSHOT_HOT_WINDOW_DAYS (35) every time, and the
whole window when googleAds.snapshotColdRefreshedAt is older than a week. Nothing is dropped —
the tail still fills in, weekly rather than daily — and 35 sits just past the maturity cut, so the days
the measurement reads are always daily-fresh.
npx tsx scripts/report-google-ads-v2-uplift.ts
Prints the assumed uplift, the measured cohort median against it, the per-donor breakdown, and the
accounts carrying a v2 action that produced no donor with the reason (capture-broken, stale,
rollout-incomplete, no-overlap). Pass --refresh to bypass the one-day cache. At the revisit, if
the donor set has reached V2_UPLIFT_MIN_SAMPLE_SIZE with settled history, replace
GOOGLE_ADS_V2_ASSUMED_UPLIFT with the measured ratioP50 - 1 — the script prints the exact value.
Read the exclusions before the median. A capture-broken account is our bug, not a rollout state:
re-run scripts/run-google-ads-snapshot-backfill.ts <shop> to repair it, then re-read the ratio.
byAction and costMicros come from separate GAQL calls, so the per-action one can return nothing
while totals and spend land fine. A day whose totals report conversions against an empty breakdown is
marked byActionIncomplete, and its stored breakdown is left alone rather than overwritten. Readers
must skip those days, never read them as zero — a zero read as real drops an account out of the
cohort or depresses its ratio with nothing to show it happened. Snapshots written before 2026-09-16
predate the flag; the same shape identifies them.
The cause was search reading only the first page of a GAQL result. The API caps a page at 10k rows
and this query segments date × conversion action × attribution event type, so a 90-day window on a
busy account overran it — measured at 13,915 rows for gardeners-dream-online and 110,959 for
welovecouk. search now follows nextPageToken.
The loss was not neutral. Rows arrive with the newest conversion actions last, and v2 actions were
created after v1, so they carry higher resource IDs and sort last. A truncated read therefore dropped
v2 first and left v1 intact — systematically under-reporting the connection this document is about,
on exactly the largest accounts. gardeners-dream-online reported zero v2 conversions from 2026-06-28
until re-capture, when it came back at parity with v1.
A read that stops at the page cap now reports truncated, and every day it touched is marked
byActionIncomplete. Historical truncation cannot be detected from stored data — the account total is
metrics.conversions (primary goals) while the breakdown is all_conversions, so there is no equality
to test — so repairing it needs scripts/run-google-ads-snapshot-backfill.ts --recapture-all, which
re-reads every day regardless of coverage.
GOOGLE_ADS_CONNECTION_NAMES = ["googleAds"] — token lookup accepts the v1 connection only. The v2
actions are created by a separate ld-shopify connection, googleAdsOnline, which has its own OAuth
grant (typically support@littledata.io) and its own Connection.settings.customerManagerAccountId.
customers.googleAds.loginCustomerId syncs from the googleAds connection, so where the two disagree
we hold the wrong manager.
That lookup is deliberate and must stay: preferring the googleAdsOnline token caused a live outage,
because that user cannot see the merchant’s customer ID directly and every call 403s. The merchant
grant is always resolved first and tried first.
What it did not cover is the merchant grant going bad. jimmy-joy’s v1 grant is the merchant’s own
email, which lost access to the account, so its snapshots were empty while both connections’ conversions
were plainly visible in the Google Ads UI. captureSnapshotsWithAnyGrant now falls through to the
googleAdsOnline grant on a permission error only — an expired token or a rate limit means “this
grant, later” and is rethrown. The fallback is handed out only with the manager it must be sent with,
read from that connection’s settings and verified through resolveGoogleAdsLoginCustomerId; a token
without its route is what failed before. Both the cron and the backfill script go through it.
src/services/googleAds/constants.ts — ldPurchaseConnectionVersion, the v1/v2 type constantssrc/services/googleAds/googleAdsRoasFromSnapshots.ts — per-window version selectionsrc/services/googleAds/googleAdsV2UpliftBenchmark.ts — the assumed uplift, plus the cohort
measurement, steady-state detection and cache that exist to check itsrc/services/value/computeCustomerValue.ts — computeGoogleAdsV2PredictedUpliftExtrassrc/services/value/valueHelpers.ts — formatGoogleAdsV2PredictedUpliftLinesrc/services/audit/checkGoogleAdsGoalConfig.ts — v2 as a primary Littledata goalsrc/services/googleAds/googleAdsApi.ts — search pagination; the per-action GAQL querysrc/services/googleAds/snapshotGoogleAdsConversions.ts — byActionIncomplete and the guard that
keeps a failed capture from overwriting a good breakdown