feat(gtm): Tag Manager API integration with three-way tracking audit#29
Conversation
Adds Google Tag Manager API v2 read tools and a cross-reference tool
that joins codebase events, GTM tags, and GA4 actual fires — the kind
of three-way audit GTM Preview cannot give in a single view.
The premise: GA4 + Ads alone tell you what fired, codebase grep tells
you intent, GTM tells you wiring. Each in isolation hides real bugs —
paused tags, page-scoped triggers that under-fire, dynamic event names
hiding real coverage, codebase events with no GTM tag at all. Joining
all three is the only way to spot them in one read.
Validated against a real production container: surfaced that a
client's GA4 form_submit and click_to_call tags were scoped to
/service-promotions/* only, while their Enhanced Conversions for Leads
caught every form submit sitewide — an asymmetric Smart Bidding
signal that no single-source diagnostic surfaces.
12 new tools
- audit_event_coverage (in crossref.py) — the flagship. Three-way join
with 10 distinct status types (ok, no_tag_no_fire, tag_paused,
tag_active_but_not_firing, gtm_only_firing, ga4_only,
ok_auto_collected, etc.) plus auto-generated insights for every gap.
- list_gtm_accounts / list_gtm_containers — discovery
- list_gtm_tags / get_gtm_tag — parsed GA4 event names + resolved
firing/blocking trigger names; built-in trigger IDs (>= 2147479553)
resolved to readable names instead of "(unknown)"
- list_gtm_triggers / get_gtm_trigger — filter conditions parsed to
text including the negate flag rendered as "NOT contains" (silent
meaning-inversion if missed); type-specific blocks for triggerGroup
(group_member_trigger_ids) and elementVisibility (selector,
selector_type, firing_frequency, on_screen_ratio,
use_dom_change_listener)
- list_gtm_variables — custom variables + enabled built-ins
- list_gtm_workspaces / get_gtm_workspace_diff — drafted-but-not-
published changes, the common cause of "I edited a tag and nothing
happened"
- list_gtm_versions / get_gtm_version — publish history for
correlating metric drops with recent publishes
Architecture
- src/adloop/gtm/{client.py, read.py} — new module
- auth.py — _GTM_SCOPES + get_gtm_credentials; tagmanager.readonly
added to _ALL_SCOPES so OAuth users get GTM access on next re-auth
- crossref.py — audit_event_coverage joins GTM + GA4 + codebase
- server.py — 12 new @mcp.tool registrations under _READONLY
- pyproject.toml — google-api-python-client>=2.100.0 (GTM API v2 has
no gRPC client; Discovery-built service)
Setup
Enable Tag Manager API v2 in the GCP project, add the AdLoop OAuth
user / service account email as a Read user on the GTM container under
Admin > User Management. Service-account access takes effect on the
next API call (no token refresh needed).
Safety
All read-only. Write tools (draft_gtm_*, publish_gtm_version, rollback,
etc.) are deliberately deferred to a follow-up — a bad GTM publish has
higher blast radius than any Ads write (deleting the GA4 config tag
kills all site tracking instantly), so the read + audit surface ships
first to validate the diagnose-then-recommend pattern before adding
write paths.
README updated with the new section, setup notes, project structure,
and a roadmap entry. Tool count: 43 -> 55.
Adds 50 unit tests covering: - All pure-function parsers in gtm/read.py: _params_dict, _summarize_filter (including negate-flag rendering), _resolve_trigger (custom + built-in IDs), _trigger_group_member_ids, _element_visibility_summary (case-insensitive selectorType), _parse_trigger (type-specific dispatch), _parse_variable. - audit_event_coverage status determination — one test per status code (ok, no_tag_no_fire, tag_paused, tag_active_but_not_firing, ok_auto_collected, ga4_fires_no_tag, gtm_only_firing, gtm_only_not_firing, auto_event_only, ga4_only). - Insight generation for the four most common gap categories: missing tag, paused tag, dynamic-event tag, custom HTML tag. - Matrix shape contracts: required keys, container summary, GA4 error short-circuit, alphabetical event sort. Tests caught a real bug: the `ok` status was matched on `in_gtm AND any_active_tag AND ga4_fires` without requiring `in_codebase`, making `gtm_only_firing` (intended semantics: \"event fires from a GTM tag but isn't in the codebase, possibly stale\") unreachable. Fixed by adding `in_codebase` to the `ok` condition. No user-facing surface area change for events that ARE in the codebase — only events not in the codebase but with active firing tags are affected, and they now correctly route to gtm_only_firing instead of the over-eager ok. 234 tests pass (was 184 baseline + 50 new from this commit).
8ea3d30 to
c1c4995
Compare
Builds on PR kLOsk#29 (GTM read tools) to add the full write surface: create / update / delete for tags + triggers, plus workspace publish. Pattern matches the existing AdLoop safety model: draft_* → ChangePlan + store_plan + return preview with plan_id confirm_and_apply(plan_id, dry_run=true|false) → executes via the GTM API ## Tools added | Tool | Notes | |---|---| | `draft_gtm_tag` | Create tag (googtag, gclidw, html, gaawe, awcc, awud, awcr, awct, fls, flc, ua, img). Auto-resolves Default Workspace when workspace_id omitted. | | `draft_gtm_trigger` | Create trigger (pageview, click, linkClick, formSubmission, customEvent, elementVisibility, scroll_depth, youtube_video, history_change, timer, javascript_error). | | `draft_update_gtm_tag` | Partial update of an existing tag — preserves the tag's fingerprint for optimistic concurrency, and any field the caller doesn't pass keeps its current value. | | `draft_update_gtm_trigger` | Same pattern for triggers. Trigger type itself is immutable (delete + create instead). | | `draft_delete_gtm_tag` | Hard delete; warns it's irreversible. | | `draft_delete_gtm_trigger` | Hard delete; warns it's irreversible AND that GTM rejects the delete if any tag still references the trigger. | | `publish_gtm_workspace` | Compile workspace into a ContainerVersion + publish to live. The actual go-live moment, separated from drafting. | ## Tag types accepted The validator includes both Google's built-in template IDs (googtag, awcc, awud, gaawe, etc.) and the verbose long-form aliases (google_ads_calls_from_website, google_ads_user_provided_data, etc.). Floodlight templates (flc, fls) are included for legacy advertisers. ## OAuth scopes `auth.py` extends `_GTM_SCOPES` (and `_ALL_SCOPES`) from `tagmanager.readonly` to also include: - `tagmanager.edit.containers` (create/update/delete tags + triggers) - `tagmanager.edit.containerversions` (compile workspace into a version) - `tagmanager.publish` (set the version live) Existing OAuth tokens issued under the old (read-only) scope set will need to be deleted from `~/.adloop/token.json` so the next API call triggers a re-auth that picks up the new scopes. Service-account auth needs the SA email upgraded from "Read" to "Publish" on the container in GTM Admin → User Management. ## Dispatch A small block at the top of `_execute_plan` in `ads/write.py` detects GTM operations and routes them to the GTM apply handlers (which fetch their own GTM client instead of reusing the Google Ads client). The dispatch list: create_gtm_tag, create_gtm_trigger, publish_gtm_workspace, update_gtm_tag, update_gtm_trigger, delete_gtm_tag, delete_gtm_trigger ## Workspace handling GTM auto-creates a fresh "Default Workspace" after every publish; the old one becomes immutable ("Workspace is already submitted"). The `_resolve_workspace()` helper grabs the current Default Workspace by name on every apply call so callers don't need to track which numeric workspace ID is currently active. ## Tests `tests/test_gtm_write.py` — 22 tests covering: - All 4 draft validators (invalid types, required fields, duplicate-update rejection, custom-event-name requirement) - Real Google template IDs accepted (googtag, awcc, awud, etc.) - Long-name rejection - Filter persistence on triggers - Partial-update semantics on tags + triggers - Delete warnings (irreversible) for both tags + triggers - Publish warning (LIVE on next refresh) - MCP registration of all 7 tools - Dispatch wiring contains all 7 ops Validated against a real BGI Tint container (GTM-T68KKNMK) end-to-end: - Created Google Tag (AW-...), Conversion Linker, GFN Phone Snippet (awcc template), GA4 Event tags - Updated tag names + parameters - Deleted a Custom HTML tag, replaced with awcc template - Published 4 container versions (8, 9, 10, 11) ## Test plan - [x] Full suite passes: 255/255 tests (PR kLOsk#29 baseline + 22 GTM-write) - [x] Service-account write path verified end-to-end against live container `GTM-T68KKNMK` - [x] OAuth scope expansion compatible with existing PR kLOsk#29 auth flow
|
Hey @kLOsk, just rebased onto main — MERGEABLE/CLEAN, 312 tests passing on the branch. Is there anything else you'd like from me to get this in — additional tests, doc updates, scope tweaks? Happy to address anything. |
|
The parsing layer and tests here are strong; this is merge-track. Needed for v0.10.0: (1) port to |
|
Merged to main — thank you @illia-sapryga! The read layer and the three-way audit are exactly the kind of capability this project should have; the parsing care in read.py (negate flags, built-in trigger resolution, surfacing dynamic Rather than another round-trip I applied the four asks from my July comment maintainer-side during the merge:
This unblocks #35 — please rebase it onto main (the diff should shrink to just the write surface) and see my addendum there for the remaining items. |
Summary
audit_event_coveragecross-reference tool: three-way join across codebase events ↔ GTM tags ↔ GA4 actual fires, with 10 distinct status codes per event and auto-generated insights for every gapWhy this exists
GA4 + Ads tell you what fired. Codebase grep tells you intent. GTM tells you wiring. Each in isolation hides real bugs — paused tags, page-scoped triggers that under-fire, dynamic event names hiding real coverage, codebase events with no tag wired up at all.
validate_trackingalready does codebase ↔ GA4.attribution_checkdoes Ads ↔ GA4. Neither sees GTM, so neither can tell you why an event isn't firing — only that it isn't.audit_event_coveragejoins all three sources and returns a per-event status:okok_auto_collectedno_tag_no_firetag_pausedtag_active_but_not_firinggtm_only_firinggtm_only_not_firingga4_onlyga4_fires_no_tagauto_event_onlyPlus auto-generated
insights[]for: codebase events with no tag, paused tags, active-but-not-firing tags, GTM-only tags, GA4-only events, dynamic-event tags using{{Event}}variables, and active Custom HTML tags whose event semantics the audit can't infer.Validated against a real container
Caught a substantive issue that no single-source diagnostic surfaces: a production container where the GA4
form_submitandclick_to_calltags were scoped to/service-promotions/*only, while the Google Ads Enhanced Conversions for Leads tag caught every form submit sitewide. Result: asymmetric Smart Bidding signal — Ads gets full lead data, GA4 sees only ~5 out of N submissions. That was invisible from GA4 + Ads alone.The same audit also caught a
negate: truefilter on a trigger that had been silently inverted in earlier hand-inspection — surfaced by the_summarize_filterparser rendering{{Form ID}} NOT contains wf-form-footer-subscribe-oneinstead of just{{Form ID}} contains wf-form-footer-subscribe-one.What's added
New module:
src/adloop/gtm/client.py— Tag Manager API v2 client wrapper (Discovery-built; no gRPC client exists for GTM v2)read.py— live container fetching, tag/trigger/variable parsing, workspace diff, version history. Includes type-specific parsing for triggerGroup (member trigger IDs), elementVisibility (selector + timing), and built-in trigger ID resolution (>= 2147479553 are not returned in the trigger list, mapped to readable names like(built-in) All Pages).auth.py_GTM_SCOPES = ["https://www.googleapis.com/auth/tagmanager.readonly"]get_gtm_credentials(config)— service account + OAuth user pathstagmanager.readonlyadded to_ALL_SCOPESso OAuth users get GTM access on next re-authcrossref.pyaudit_event_coverage()— joins GTM live container + GA4 event counts + codebase event listserver.py@mcp.tool(annotations=_READONLY)registrationspyproject.tomlgoogle-api-python-client>=2.100.0README.mdgtm/tests/test_gtm.py(added in second commit)_params_dict,_summarize_filter,_resolve_trigger,_trigger_group_member_ids,_element_visibility_summary,_parse_trigger,_parse_variableaudit_event_coveragestatus code (10 statuses)unittest.mock.patchonadloop.gtm.read.get_live_containerandadloop.ga4.tracking.get_tracking_events— deterministic, no live API calls, ~40ms totalBug caught + fixed by tests
audit_event_coverage's status logic had an unreachable branch: theokstatus was matched onin_gtm AND any_active_tag AND ga4_fireswithout requiringin_codebase, which meantgtm_only_firing(intended semantics: "event fires from a GTM tag but isn't in the codebase, possibly stale") could never be reached for an active tag with GA4 fires. Fixed by addingin_codebaseto theokcondition. No user-facing surface area change for events that ARE in the codebase — only events with active firing tags but no codebase reference are affected, and they now correctly route togtm_only_firingfor review.Setup (for users)
console.cloud.google.com/apis/library/tagmanager.googleapis.com)Why no write tools yet
A bad GTM publish has higher blast radius than any Ads write — deleting the GA4 config tag kills all site tracking instantly. The right write surface needs:
gtm.allow_publish: falseconfig gate on top ofrequire_dry_runRead-only ships first to validate the diagnose-then-recommend pattern. Write tools (
draft_gtm_ga4_event_tag,draft_gtm_trigger_change,pause/enable_gtm_tag,publish_gtm_version,rollback_gtm_to_version) are scoped for a follow-up PR.Test plan
uv run pytest→ 234 passed (was 184 baseline + 50 new from this PR)_summarize_filterrendersnegate: trueasNOT <op>(regression test for the silent meaning-inversion that hand-inspection had missed)_resolve_triggerresolves built-in trigger IDs (>= 2147479553) to readable names instead of(unknown)_trigger_group_member_idsextracts member IDs from a triggerGroup's parameter list_element_visibility_summaryhandles case-insensitiveselectorType(GTM returns"ID"not"id")_parse_triggeraddsgroup_member_trigger_idsfor triggerGroup,element_visibilityfor elementVisibilityaudit_event_coveragematrix produces all 10 status codes (one test per code)list_gtm_accounts,list_gtm_containers,list_gtm_tags,list_gtm_triggers,list_gtm_variables,list_gtm_workspaces,get_gtm_workspace_diff,list_gtm_versions,get_gtm_version,get_gtm_tag,get_gtm_trigger,audit_event_coverage— all verified end-to-end against a real production containertagmanager.readonlyscope (existing OAuth users would need to delete their token to trigger re-auth)