Google Ads: what GA4 can show, and what it cannot
Internal working notes, pulled 1 Sep 2026. Not the client deliverable. Second person and byline treatment happens at the report-writing stage. There is no direct Google Ads access on this engagement. Everything below reaches Google Ads spend only through the GA4 data-import link, queried read-only via the GA4 Data API (analyticsdata.googleapis.com) with the existing service-account credential in scripts/lib/google-auth.ts. No mutating call was made anywhere in this task.
What would falsify these findings
These findings die if a third, independently-shaped query produces a total that does not sit inside the small band described below. The brief names two required cross-check methods and an explicit stop condition: if they disagree, report BLOCKED rather than pick one. They disagree here (see GADS-01), which is itself the headline finding of this document, not a failure to complete it.
They also die if Google Ads spend exists before the GA4-to-Ads link was created. That link-creation date cannot be read from any tool available to this task (confirmed below: the GA4 Admin API, which is the only place that date lives, returns 403 SERVICE_DISABLED for this service-account credential's GCP project). Every spend figure in this document is therefore a floor, not a lifetime total, and is labelled that way everywhere it appears rather than once at the top.
Spend, recomputed with the required method
Per the brief: do not use a yearMonth breakdown for advertiserAdCost. Run once, deliberately, to document the failure mode rather than take it on faith: a single query over the full range with dimensions [yearMonth, sessionGoogleAdsCampaignName] returns a total of $48,963.19 against a true figure near $822. That is a 59.6x inflation on this pull (the brief's own prior incident on 1 Sep reported ~128x on a narrower window; both are the same bug, the multiplier moves with the query shape). This number is not used anywhere below except to name the trap.
Method A, as specified: one query per calendar month, dimension sessionGoogleAdsCampaignName only, summed. Run across every month from August 2025 (before any Google Ads activity, to confirm zero) through 1 September 2026:
| Month | Spend |
|---|---|
| 2026-05 | $134.56 |
| 2026-06 | $310.02 |
| 2026-07 | $264.01 |
| 2026-08 | $113.18 |
| All other months (Aug 2025-Apr 2026, Sep 2026) | $0.00 |
| Method A total | $821.77 |
Re-run a second time, independently, as its own internal check: $821.77 again, stable across both passes.
Method B, as specified: a single query over the whole range, dimensions [sessionGoogleAdsAccountName, sessionGoogleAdsCustomerId].
| Account | Customer ID | Spend |
|---|---|---|
| String Protech | 6462501252 | $822.90 |
GADS-01: the two required methods disagree, by $1.13
Method A: $821.77. Method B: $822.90. Difference: $1.13 (0.14%).
Per the brief: "If your two methods disagree, STOP and report BLOCKED rather than picking one." This is reported as blocked on the exact lifetime total, deliberately, rather than defaulting to the figure that happens to match the previously-established fact ($821.77). Choosing the number that confirms the existing established fact, over the number a differently-shaped but equally valid query returns, would be exactly the kind of unverified pick-the-number-you-like error this task exists to prevent.
Four further checks were run to isolate where the $1.13 comes from, not to resolve it in either direction:
| Check | Query shape | Total |
|---|---|---|
| C2 | Single whole-range query, sessionGoogleAdsAccountName only | $822.90 |
| C3 | Single whole-range query, sessionGoogleAdsCustomerId only | $822.90 |
| C4 | Single whole-range query, sessionGoogleAdsCampaignName only (same dimension as Method A, but not split by month) | $822.90 |
| C5 | Method A re-run a third time, months with known spend only | $821.77 |
This isolates the disagreement precisely: it is not about which dimension is used (account name, customer ID, and campaign name all agree with each other at $822.90 when queried as a single whole-range call). It is about whether the range is queried as one call or as four monthly calls summed, using the identical dimension both ways. Four monthly calls, summed: $821.77, reproducibly. One whole-range call: $822.90, reproducibly. advertiserAdCost in this GA4 property is not perfectly additive across that particular partition of the same date range. No explanation for why is asserted here; GA4's own documentation is silent on exact additivity guarantees for imported cost metrics, and confirming a root cause would need access this task does not have.
What this does and does not change: campaign-level detail (clicks, sessions, transactions) is stable and identical across every method that returns it, cross-checked directly against the whole-range query:
| Campaign | Cost | Clicks | Sessions | Transactions | Revenue |
|---|---|---|---|---|---|
| [Pmax] [String protech] | $481.61 | 867 | 697 | 1 | $0 |
| [Youtube] [Não pulável] [Alcance] | $170.85 | 2 | 3 | 0 | $0 |
| [Visualização de vídeo] | $170.43 | 20 | 19 | 0 | $0 |
Clicks and transactions match the previously-established facts (867/1, 2/0, 20/0) exactly. Every conclusion this audit draws from Google Ads data (one campaign produces a transaction, two do not; cost per click on the video/reach campaigns is extreme relative to a $59.99 product) holds whether the total is $821.77 or $822.90; the $1.13 does not move any campaign's row by more than a cent. The BLOCKED status applies specifically to stating one exact lifetime-spend figure, not to the shape of the finding.
What GA4 confirms independently
Google Ads customer ID, re-confirmed directly from GA4 rather than assumed from the established fact: 6462501252, account name "String Protech." Matches exactly.
(not set) row (all non-Google-Ads-attributed traffic, same query): 9,041 sessions, 19 transactions, $1,353.38 revenue, $0 cost. 19 matches the Shopify ledger's "19 revenue-generating orders" fact closely enough to read as internally consistent, though GA4 sessions and Shopify orders are counted by different systems and this is not offered as a reconciliation.
What is NOT knowable without Google Ads account access
This is the load-bearing section of this document. Everything below was checked against every tool available to this task (GA4 Data API, GA4 Admin API) before being listed here as unavailable, not assumed unavailable.
| What | Why it is unavailable here | Checked how |
|---|---|---|
| Search terms | Search-term reports exist only inside the Google Ads platform (or its API), which this engagement has no credential for. GA4 sees sessions and cost by campaign, never the query that triggered a click. | No Google Ads API credential exists in this repo; confirmed by inspecting .env/.env.local for any GOOGLE_ADS_* variable (none present). |
| Negative keywords | Same reason: a Google Ads account-structure object, not a GA4-visible metric. | Same. |
| Asset group performance (Pmax) | Performance Max reports its asset-group-level breakdown only inside Google Ads. GA4's sessionGoogleAdsCampaignName dimension stops at the campaign, and Pmax does not expose ad-group-equivalent granularity to GA4 at all. | Confirmed by the dimension list actually queried; no GA4 dimension below campaign exists for Google Ads. |
| Conversion action configuration | Whether "transactions" here maps to Shopify purchase, Add to Cart, or something else is a Google Ads conversion-action setting, not a GA4-exposed fact. GA4's own transactions metric is GA4's own e-commerce event, not necessarily the same event Google Ads is bidding toward. | Not queryable from GA4; would require Google Ads API conversion_action resource. |
| Change history | Who changed a bid, budget, or campaign setting, and when, lives in the Google Ads Change History, a Google Ads API resource with no GA4 equivalent. | Not queryable from GA4; would require Google Ads API access this task does not have. |
| True spend start date (the GA4-to-Ads link creation date) | This is a GA4 Admin API field (googleAdsLinks), not a Data API field. Attempted directly: GET analyticsadmin.googleapis.com/v1beta/properties/{id}/googleAdsLinks returns 403 PERMISSION_DENIED, SERVICE_DISABLED: the Google Analytics Admin API is not enabled in this service account's GCP project. | data/_snapshots/2026-09-01/ga4-adslinks-admin-api.txt, direct HTTP call, 1 Sep 2026. Every spend figure in this document is a floor because of this specific, provable gap, not a general caveat. |
| Google Ads account-level owner / who holds manager-account access | Cannot be read from GA4 at all; Google Ads access control is entirely outside GA4's data model. | See ownership section below. |
Ownership: the question this task cannot answer, and why it matters
Ask the client directly to open Google Ads > Admin > Access and security > Managers, and report back what's there. This task has no path to that screen.
Why this is not a formality. Meta is the confirmed-clean comparison point, checked directly in 20-meta-ads.md: business 3280011615507066, the client's own, owns both the ad account and the pixel, both created 2025-12-03. If Google Ads is set up the same way, the client owns the asset outright and nothing here is at risk on any agency's exit.
If an agency manager account (MCC) holds the Google Ads account instead, that is a transition risk stated plainly, not hedged: on that agency's exit, the client can lose spend history, conversion configuration, and any audience lists built on Google's side, none of which are portable the way a client-owned asset is. Given that this account already shows a JetAds-shaped handover on the Meta side (paused abruptly, no clean transition visible in that record either), whether Google Ads sits on the same footing as Meta or not is worth confirming before it becomes the same kind of gap.
Findings
ID format GADS-nn. Class: BLOCKS / CORRUPTS / WASTES / SUPPRESSES. Tier: HARD (pulled from a system, dated) / DERIVED (arithmetic on HARD) / INFERRED (reasoned from HARD facts) / UNKNOWN. Remit: unowned / media / client.
| ID | Finding | Evidence | Class | Tier | Remit |
|---|---|---|---|---|---|
| GADS-01 | The brief's two required cross-check methods for lifetime Google Ads spend disagree: $821.77 (per-month sum) vs $822.90 (single whole-range query), a $1.13 / 0.14% gap, reproducible across repeated runs of each method. Per instruction, this is reported BLOCKED on the exact lifetime figure rather than resolved by picking one. Campaign-level clicks and transactions are unaffected and match the established facts exactly. | ga4-google-ads-spend.txt, ga4-google-ads-crosscheck2.txt, 1 Sep 2026 | CORRUPTS | HARD | unowned |
| GADS-02 | GA4-visible Google Ads spend is a floor, not a lifetime total, because the GA4-to-Ads link creation date is unrecoverable from any credential this task holds. Direct attempt against the GA4 Admin API's googleAdsLinks resource, the only place that date exists, returns 403 SERVICE_DISABLED. Google spend is visible in GA4 only from May 2026 onward; whether that reflects when spend actually started or only when the link started reporting cannot be distinguished with current access. | ga4-adslinks-admin-api.txt, 1 Sep 2026 | UNKNOWN | HARD (access boundary) | unowned |
| GADS-03 | $340.17-$341.28 (methods agree to within $1 on this subset) of $821.77-$822.90 total lifetime spend, 41-42% of all GA4-visible Google spend, ran on two YouTube/video campaigns ([Youtube] [Não pulável] [Alcance], [Visualização de vídeo]) that produced 22 combined clicks and 0 transactions. Cost per click on those two campaigns alone: roughly $15.50, against a $59.99 product with 0 sales to show for it. The one campaign that did convert, [Pmax] [String protech], drove 867 clicks for 1 transaction at $481.61. | ga4-google-ads-campaign-detail.txt, 1 Sep 2026 | WASTES | HARD | media |
| GADS-04 | Google Ads account ownership (client-owned business vs. agency manager account) cannot be determined from GA4 or any credential this task holds. Meta's equivalent fact is confirmed clean (client's own business owns the ad account and pixel). Whether Google Ads sits on the same footing is unconfirmed and carries real transition risk (spend history, conversion configuration, audience lists) if an agency MCC holds it. | GA4 has no access-control data model; not independently checkable from this task | UNKNOWN | UNKNOWN | client |
Appendix: raw state
Pulled 1 Sep 2026. Files in data/_snapshots/2026-09-01/:
ga4-google-ads-spend.txt: Method A (per-month) and Method B (whole-
range, account dims) full output, plus the deliberate yearMonth-trap run.
ga4-google-ads-query-script.ts: the script used for the above, so the
exact query shapes are reproducible, not just described.
ga4-google-ads-crosscheck2.txt: the five diagnostic checks (C2-C5) that
isolated the per-month-vs-whole-range disagreement.
ga4-google-ads-campaign-detail.txt: cost, clicks, sessions,
transactions, revenue by campaign, single whole-range query.
ga4-adslinks-admin-api.txt: the direct, failed attempt at the GA4 Admin
API googleAdsLinks resource, proving the link-date gap rather than asserting it.
GA4 property
444109784. Google Ads customer ID 6462501252 ("String Protech"), re-confirmed directly via sessionGoogleAdsCustomerId, matching the established fact.
Method A, full per-month detail
2026-05 [Pmax] [String protech] $78.25
2026-05 [Youtube] [Não pulável] [Alcance] $28.16
2026-05 [Visualização de vídeo] $28.14
2026-06 [Pmax] [String protech] $191.18
2026-06 [Visualização de vídeo] $59.43
2026-06 [Youtube] [Não pulável] [Alcance] $59.41
2026-07 [Pmax] [String protech] $158.19
2026-07 [Visualização de vídeo] $52.92
2026-07 [Youtube] [Não pulável] [Alcance] $52.91
2026-08 [Pmax] [String protech] $53.99
2026-08 [Youtube] [Não pulável] [Alcance] $29.62
2026-08 [Visualização de vídeo] $29.58
No spend recorded in any month from August 2025 through April 2026, or in September 2026 (1 day, partial), by this method. This does not mean spend did not happen before May 2026; per GADS-02, it means GA4 has not attributed any to Google Ads before then, which is a different and weaker claim.