Skip to content

feat(gtm): Tag Manager API integration with three-way tracking audit#29

Open
illia-sapryga wants to merge 2 commits intokLOsk:mainfrom
illia-sapryga:feat/gtm-integration
Open

feat(gtm): Tag Manager API integration with three-way tracking audit#29
illia-sapryga wants to merge 2 commits intokLOsk:mainfrom
illia-sapryga:feat/gtm-integration

Conversation

@illia-sapryga
Copy link
Copy Markdown
Contributor

@illia-sapryga illia-sapryga commented Apr 30, 2026

Summary

  • New audit_event_coverage cross-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 gap
  • 11 GTM read tools covering accounts, containers, tags, triggers, variables, workspaces, and version history
  • All read-only. Write tools deliberately deferred to a follow-up (rationale below)
  • 50 unit tests added covering all pure-function parsers + every audit status code + insight generation + matrix contracts (234/234 suite passes)
  • Tool count: 43 → 55

Why 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_tracking already does codebase ↔ GA4. attribution_check does Ads ↔ GA4. Neither sees GTM, so neither can tell you why an event isn't firing — only that it isn't.

audit_event_coverage joins all three sources and returns a per-event status:

Status Meaning
ok Codebase event with an active tag and GA4 fires
ok_auto_collected GA4 Enhanced Measurement event, no tag needed
no_tag_no_fire Codebase event, no GTM tag, never fires
tag_paused GTM tag exists but paused
tag_active_but_not_firing Active tag, no GA4 hits — trigger condition or DOM problem
gtm_only_firing GA4 event from a tag, not in codebase (possibly stale; verify)
gtm_only_not_firing Tag exists, not in codebase, no fires (stale tag)
ga4_only Fires in GA4 with no tag and no codebase ref (other tag manager / 3rd-party SDK)
ga4_fires_no_tag Codebase event firing without a GTM tag (gtag.js direct path)
auto_event_only Enhanced Measurement event with no codebase ref

Plus 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_submit and click_to_call tags 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: true filter on a trigger that had been silently inverted in earlier hand-inspection — surfaced by the _summarize_filter parser rendering {{Form ID}} NOT contains wf-form-footer-subscribe-one instead 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 paths
  • tagmanager.readonly added to _ALL_SCOPES so OAuth users get GTM access on next re-auth

crossref.py

  • audit_event_coverage() — joins GTM live container + GA4 event counts + codebase event list

server.py

  • 12 new @mcp.tool(annotations=_READONLY) registrations

pyproject.toml

  • google-api-python-client>=2.100.0

README.md

  • New "Google Tag Manager Tools" section with all 12 tools
  • New "What It Solves" bullet about the three-way audit
  • Setup callout for enabling Tag Manager API + adding Read access on the container
  • Project structure updated to include gtm/
  • Roadmap entry checked off
  • Tool count updated 43 → 55

tests/test_gtm.py (added in second commit)

  • 50 unit tests across 12 test classes
  • Every pure-function parser covered: _params_dict, _summarize_filter, _resolve_trigger, _trigger_group_member_ids, _element_visibility_summary, _parse_trigger, _parse_variable
  • One reachability test per audit_event_coverage status code (10 statuses)
  • Insight-generation tests for: missing tag, paused tag, dynamic event, Custom HTML
  • Matrix contract tests: required fields, container summary breakdown, GA4 error short-circuit, alphabetical event sort
  • Uses unittest.mock.patch on adloop.gtm.read.get_live_container and adloop.ga4.tracking.get_tracking_events — deterministic, no live API calls, ~40ms total

Bug caught + fixed by tests

audit_event_coverage's status logic had an unreachable branch: the ok status was matched on in_gtm AND any_active_tag AND ga4_fires without requiring in_codebase, which meant gtm_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 adding in_codebase to the ok condition. 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 to gtm_only_firing for review.

Setup (for users)

  1. Enable Tag Manager API v2 in your GCP project (console.cloud.google.com/apis/library/tagmanager.googleapis.com)
  2. In Tag Manager → your container → Admin → User Management (Container column, not Account), add your AdLoop credentials' email (the OAuth user email, or the service account email if using a service account) with Read permission
  3. Service-account access takes effect on the next API call. OAuth users will need to re-auth to pick up the new scope.

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:

  • Workspace as the dry-run sandbox (publish is the actual deploy moment, separate tool)
  • Optimistic concurrency via fingerprints (someone editing in the GTM UI wins or you abort)
  • Reverse-impact preview (modifying a trigger should auto-list every tag using it)
  • A separate gtm.allow_publish: false config gate on top of require_dry_run
  • Rollback as a first-class one-call tool

Read-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

  • Full suite passes: uv run pytest → 234 passed (was 184 baseline + 50 new from this PR)
  • _summarize_filter renders negate: true as NOT <op> (regression test for the silent meaning-inversion that hand-inspection had missed)
  • _resolve_trigger resolves built-in trigger IDs (>= 2147479553) to readable names instead of (unknown)
  • _trigger_group_member_ids extracts member IDs from a triggerGroup's parameter list
  • _element_visibility_summary handles case-insensitive selectorType (GTM returns "ID" not "id")
  • _parse_trigger adds group_member_trigger_ids for triggerGroup, element_visibility for elementVisibility
  • audit_event_coverage matrix produces all 10 status codes (one test per code)
  • Insights generated for: codebase event with no tag, paused tag, dynamic-event tag, Custom HTML tag
  • GA4 error short-circuits the audit gracefully
  • Live API smoke tests (manual): 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 container
  • Service-account auth path: works after enabling Tag Manager API v2 + adding service account email to container with Read permission
  • OAuth user auth path: tested code path but not yet validated end-to-end with a fresh OAuth re-auth that picks up the new tagmanager.readonly scope (existing OAuth users would need to delete their token to trigger re-auth)

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant