feat(gtm): Tag Manager API integration with three-way tracking audit#29
Open
illia-sapryga wants to merge 2 commits intokLOsk:mainfrom
Open
feat(gtm): Tag Manager API integration with three-way tracking audit#29illia-sapryga wants to merge 2 commits intokLOsk:mainfrom
illia-sapryga wants to merge 2 commits intokLOsk:mainfrom
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).
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)