Skip to content

feat(analytics): route GA4 through GTM and instrument engagement events - #2300

Open
GigaHierz wants to merge 6 commits into
mainfrom
GigaHierz/expand-ga-analytics-hooks
Open

feat(analytics): route GA4 through GTM and instrument engagement events#2300
GigaHierz wants to merge 6 commits into
mainfrom
GigaHierz/expand-ga-analytics-hooks

Conversation

@GigaHierz

@GigaHierz GigaHierz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

Upgrades docs.celo.org measurement beyond bare page views. One concern: analytics instrumentation.

  • docs.json: swap integrations.ga4 for integrations.gtm. The GA4 Google Tag (same property, G-0CXEKQ81V2, so reporting history stays continuous) moves inside the GTM container; keeping both would double-count page views. Container GTM-NP9GP2BT is created and published with the GA4 Google Tag.
  • snippets/AddNetworkButton.jsx: push dataLayer events for the add-network conversion — add_network_click and add_network_result (success / rejected / error / no_wallet). Highest-intent action on the site, previously untracked.
  • ANALYTICS.md (new, internal — not in navigation, so not a public page): architecture and three runbooks — GTM container tags/triggers, GA4 property config (AI-assistant channel group, custom dimensions, exit-page explorations), and Cloudflare AI Crawl Control for bot/agent traffic, which no client-side tag can see (AI crawlers don't execute JS).
  • AGENTS.md: one-line pointer to ANALYTICS.md.

Why

The site had a single GA4 measurement ID and nothing else: no referrer/drop-off/engagement instrumentation and zero visibility into AI-agent traffic, which reads llms.txt and .md variants directly. GTM (free) is the only sanctioned route to custom event tracking on Mintlify hosting without a plan upgrade; Cloudflare (free) is the only layer that can count non-JS bots.

Before merge

  • GTM container has a GA4 destination. Was wrong when this PR was opened — the container's only Google tag carried the container's own ID as its Tag ID, so merging would have stopped page-view collection silently. Fixed in the GTM UI and re-verified against the published container:

    $ curl -s "https://www.googletagmanager.com/gtag/js?id=GTM-NP9GP2BT" | grep -c "G-0CXEKQ81V2"
    1
    
    $ curl -s "https://www.googletagmanager.com/gtm.js?id=GTM-NP9GP2BT"
      "tags":[{"function":"__googtag","vtp_tagId":"G-0CXEKQ81V2","tag_id":4},{"function":"__hl","tag_id":5}]
      "predicates":[ gtm.init, gtm.historyChange, gtm.js ]
      "rules": tag 4 fires on BOTH gtm.init and gtm.historyChange
    

    The history-change rule matters on Mintlify: without it only the first page of each visit is counted. It is present.

  • Ownership table in ANALYTICS.md filled in

  • The six runbook-1 event tags created. Until they exist, the add_network_click / add_network_result dataLayer pushes this PR ships fire into a void — none of the six event names appears in the published container today.

Verification

mint broken-links on the branch head:

success no broken links found

snippets/AddNetworkButton.jsx syntax-checked with esbuild (compiles clean). No pages moved; no redirects needed.

🤖 Generated with Claude Code

@GigaHierz
GigaHierz marked this pull request as ready for review August 31, 2026 21:39
@GigaHierz
GigaHierz requested a review from a team as a code owner August 31, 2026 21:39
@GigaHierz
GigaHierz requested a review from palango September 1, 2026 13:26
@GigaHierz
GigaHierz force-pushed the GigaHierz/expand-ga-analytics-hooks branch from 687f028 to f7505f6 Compare September 2, 2026 12:32
@GigaHierz

Copy link
Copy Markdown
Contributor Author

The GA4 destination is now wired into GTM-NP9GP2BT and the container is republished. Re-verified against the live container, not the GTM UI:

$ curl -s "https://www.googletagmanager.com/gtag/js?id=GTM-NP9GP2BT" | grep -c "G-0CXEKQ81V2"
1

$ curl -s "https://www.googletagmanager.com/gtm.js?id=GTM-NP9GP2BT"
  "tags":[{"function":"__googtag","vtp_tagId":"G-0CXEKQ81V2","tag_id":4},{"function":"__hl","tag_id":5}]
  "predicates":[ gtm.init, gtm.historyChange, gtm.js ]
  "rules": tag 4 fires on both gtm.init and gtm.historyChange

vtp_tagId was GTM-NP9GP2BT — the container's own ID — when this PR was opened, so the swap from integrations.ga4 to integrations.gtm would have stopped collection with no error anywhere. It now carries the measurement ID, and the history-change rule is present, which is what keeps SPA navigations counted on Mintlify.

Page-view parity is safe to merge. Two things are still outstanding and neither blocks it:

  1. None of the six event tags exists yet — the container holds 2 tags (the Google tag and the history listener). add_network_click, add_network_result, scroll_depth, outbound_click, copy_code, ai_menu_click and is_automated all return 0 occurrences in the published payload. Until they exist the dataLayer pushes in snippets/AddNetworkButton.jsx fire into a void, so the PR ships page-view parity plus dormant instrumentation rather than the engagement data it describes. Runbook 1 in ANALYTICS.md covers each one.
  2. Ownership table in ANALYTICS.md still has four _fill in_ rows.

Also in this PR since the last review: scripts/check-orphans.sh now excludes ANALYTICS.md. The orphan check began enforcing when #2293 removed its continue-on-error, and it was failing CI on a file that is deliberately not in navigation — the same category the script already excludes for README.md, AGENTS.md and CLAUDE.md.

$ bash scripts/check-orphans.sh
No orphan pages found.
$ mint broken-links
success no broken links found

GigaHierz and others added 6 commits September 2, 2026 22:11
Swap the bare ga4 integration in docs.json for GTM (tagId placeholder
until the container is created), fire dataLayer events from
AddNetworkButton for the add-network conversion, and document the
measurement setup in ANALYTICS.md: GTM/GA4 runbooks for humans and a
Cloudflare AI Crawl Control runbook for bot/agent traffic, which no
client-side tag can see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ANALYTICS.md is internal maintainer documentation, deliberately not in
docs.json navigation. The orphan check began enforcing when #2293 removed
its continue-on-error, so it now fails on the same category of file it
already excludes for README.md, AGENTS.md and CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ook steps

Owners are teams rather than individuals: this repository is public, and
.github/CODEOWNERS already assigns /docs.json to the same team.

Also corrects three steps the doc described as pending that are done:
the GTM container exists and docs.json carries its ID, the Google tag now
resolves G-0CXEKQ81V2 with a history-change trigger, and docs.celo.org is
already proxied through Cloudflare. Records the tag-ID failure mode, since
it produces no error anywhere, and notes that cf-cache-status: HIT widens
the Cloudflare-vs-GA4 delta for reasons unrelated to bots.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MCP requests are not invisible to Cloudflare — they traverse the proxy and
return cf-ray, so volume is measurable by path today. The real blind spot is
query content, which no layer here can show.

Also sources the Starter-plan claim to #2250 rather than asserting it, and
records that Pro was evaluated and declined there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…exists

The assistant is instrumented on both sides, in celo-org/docs-ai-assistant:
widget.js emits seven GA4 events through the page's existing window.gtag,
and the chat route pushes {question, citedUrls, answered, ...} onto the
Upstash Redis list docs-assistant:questions, trimmed to the last 10,000.
This file previously said that telemetry did not exist.

Records the two real gaps: the assistant's event parameters are not in the
custom-dimension list, so they arrive but cannot be reported on; and
track() is a no-op unless window.gtag is defined, which needs re-checking
after the GA4-to-GTM swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@palango palango left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diff itself is fine, and page-view collection keeps working after the swap. What stops me approving is that ANALYTICS.md ticks off four things as confirmed that aren't true, and the swap turns off the assistant's telemetry without the PR mentioning it.

I checked the claims by loading three pages in headless Chrome and capturing the /g/collect hits GA4 actually sends: the live site as it is today, a page with only a plain gtag.js snippet for G-0CXEKQ81V2, and a page with only the GTM-NP9GP2BT snippet. Each hit carries a session sequence number (_s), so duplicates below are distinct hits, not retries.

Setup page_view per load page_view per pushState
Live site today (integrations.ga4) 2 2
Plain gtag.js, no GTM 1 1
GTM-NP9GP2BT as published 1 2

1. The History Change trigger double-counts every in-site navigation

GA4 enhanced measurement already sends a page_view on pushState for this stream. The plain gtag.js page, with no GTM and no history trigger, sends exactly one per navigation, with dr set to the previous page. Under the container the Google tag fires a second time on gtm.historyChange, so each navigation produces two hits: one from the re-run config about 2 s after the push (no dr), one from enhanced measurement about 5 s later (with dr).

ANALYTICS.md:47 says the opposite ("without the history trigger only the first page view is counted") and marks it done. The History Change trigger needs to come off the Google tag, and that line needs to change. The live site already double-counts today through Mintlify's own integration, so this isn't a regression, but as written the runbook tells the next maintainer to keep the wrong setup.

2. The swap removes window.gtag, and the assistant goes quiet

On the live site typeof window.gtag is function. On a page carrying only the GTM container it is undefined. public/widget.js in docs-ai-assistant only tracks if (typeof window.gtag === 'function'), so all seven assistant_* events stop the moment this merges. I also tried the obvious workaround, a gtag(){ dataLayer.push(arguments) } shim on the GTM page followed by gtag('event', 'assistant_opened'). No hit went out. A shim in the widget won't rescue it.

ANALYTICS.md:71 says the global "should still be provided by GTM's Google tag". It isn't. The PR should say the swap breaks assistant events until #2307 lands, and that item needs rewording. Landing the widget change first would avoid the gap altogether.

3. docs.celo.org is not behind Celo's Cloudflare zone

From both 1.1.1.1 and 8.8.8.8, docs.celo.org is a plain CNAME to cname.vercel-dns.com, and the A records behind it (76.76.21.x, 66.33.60.x) belong to Vercel. A proxied record returns Cloudflare IPs and hides the CNAME target; a visible CNAME to Vercel means the record is DNS-only. The cf-ray and cf-cache-status headers come from somewhere upstream of Vercel, not from the celo.org zone, so AI Crawl Control on that zone would see nothing.

ANALYTICS.md:82 and :84 should be unticked and the "already in place" sentence dropped. The claim at ANALYTICS.md:97 that MCP volume "is visible in Cloudflare's HTTP analytics" goes with it.

4. ANALYTICS.md will be a public page

ANALYTICS.md:3 says the file isn't public because it isn't in navigation. Mintlify serves every .md under the content root regardless: https://docs.celo.org/AGENTS and /CLAUDE are live today with full content, and /ANALYTICS will be too. Nothing in the file is secret, both IDs are already in page source, but the sentence is wrong. Either accept that it's public and say so, or move the content somewhere Mintlify won't serve.

Smaller things

  • The published container still holds only the Google tag and the history listener. Pushing add_network_click on the GTM page produced no hit, so the AddNetworkButton.jsx events ship dormant. That matches your comment; just confirming it's still true at the current head.
  • The PR checklist still shows the ownership table as open. It's filled in as of e7fe61a.

Everything else I checked holds up. The container carries G-0CXEKQ81V2 and sends a page view on load. integrations.gtm.tagId matches Mintlify's schema. The widget event names, the Redis list and its trim size match docs-ai-assistant. The plan and price claims match #2250. Google's channel-group doc lists exactly ChatGPT, Gemini, DeepSeek, Copilot and Grok for the built-in AI Assistant channel. The snippet change is clean.

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.

2 participants