Skip to content

feat(analytics): instrument the add-network conversion and document how the site is measured - #2300

Merged
palango merged 13 commits into
mainfrom
GigaHierz/expand-ga-analytics-hooks
Sep 30, 2026
Merged

palango merged 13 commits into
mainfrom
GigaHierz/expand-ga-analytics-hooks

Conversation

@GigaHierz

@GigaHierz GigaHierz commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

What this is now

The docs.json GTM swap has been removed from this PR, per review. What is left cannot change what the live site measures:

  • snippets/AddNetworkButton.jsx — dataLayer pushes for the add-network conversion: add_network_click and add_network_result (success / rejected / error / no_wallet). The highest-intent action on the site, previously untracked.
  • ANALYTICS.md — architecture, ownership, and three runbooks (GTM container, GA4 property config, Cloudflare AI Crawl Control).
  • AGENTS.md — one-line pointer to it.
  • scripts/check-orphans.sh — exclude the new top-level file.

docs.json is untouched: the site still loads integrations.ga4.

Why the swap is held

The published container (gtm.js?id=GTM-NP9GP2BT) still fires the Google tag on gtm.historyChange, and none of the event tags exist. So merging the swap today would:

  1. Double-count every in-site navigation — GA4 enhanced measurement already sends a page_view on pushState, so the history trigger produces a second hit.
  2. Silence all seven assistant_* events. widget.js calls track() only if (typeof window.gtag === 'function'). That global exists via integrations.ga4 and is undefined under the container alone. A hand-rolled gtag(){ dataLayer.push(arguments) } shim does not rescue it — no hit goes out.

…and measure nothing new in return, because the event tags aren't there. So the swap goes last, after the container is fixed and the widget pushes to dataLayer directly.

Stated plainly, as asked: the integrations swap stops the assistant_* events until the widget change lands. That sentence is now in ANALYTICS.md too, not just here.

What ANALYTICS.md now says vs. what it said

The earlier version described the intended end state as though it were live. Corrected throughout:

Claim Before Now
Page-view collection docs.json → integrations.gtm integrations.ga4 today; integrations.gtm once the swap lands
History Change trigger ticked, "without it only the first page view is counted" unticked, "do not add it" — it double-counts
docs.celo.org behind Cloudflare ticked, "proxy already in place" not proxied; DNS-only CNAME to Vercel, needs a DNS change first
MCP request volume visible in Cloudflare HTTP analytics not visible — hostname isn't behind our zone
This file's visibility "not a public docs page" public at /ANALYTICS, as /AGENTS and /CLAUDE are today
window.gtag after the swap "should still be provided by GTM's Google tag" it is not; the events stop

Remaining ops steps (not code, and not blocking this PR)

Checklist

  • Ownership table in ANALYTICS.md filled in — owners are teams, not individuals (@celo-org/devrel), matching how .github/CODEOWNERS already assigns /docs.json
  • The six runbook-1 event tags: still absent, so the AddNetworkButton pushes ship dormant. That is now harmless rather than a regression, since nothing is being switched off alongside them.
  • No secrets in the diff — both IDs are already in the page source of every published page

Verification

$ npx mintlify@4.2.920 validate
success build validation passed
$ npx mintlify@4.2.920 broken-links --check-redirects
success no broken links found
$ bash scripts/check-orphans.sh
No orphan pages found.

snippets/AddNetworkButton.jsx syntax-checked with esbuild. 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.

GigaHierz and others added 2 commits September 25, 2026 00:15
- The History Change trigger double-counts. GA4 enhanced measurement already
  sends a page_view on pushState, so the trigger makes every in-site
  navigation count twice. The runbook said the opposite and ticked it off;
  it is now unticked with the trigger to be removed from the container.
- The GTM swap removes window.gtag, which silences all seven assistant_*
  events until the widget pushes to dataLayer directly. A hand-rolled gtag
  shim does not rescue it. Stated plainly instead of as a re-verify step.
- docs.celo.org is not behind the celo.org Cloudflare zone. It is a DNS-only
  CNAME to cname.vercel-dns.com with Vercel addresses behind it; a proxied
  record would return Cloudflare addresses and hide the target. The cf-ray
  headers come from upstream of Vercel. Runbook 3 is now conditional on a
  DNS change, and the claim that MCP volume is visible goes with it.
- Mintlify serves every Markdown file under the content root, so this file
  will be public at /ANALYTICS, as /AGENTS and /CLAUDE are today.

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

Copy link
Copy Markdown
Contributor Author

All four are corrected. I re-verified the two I could check from here rather than taking them on trust, and both hold.

1. The History Change trigger double-counts — unticked and reversed

The runbook said the opposite of what is true and had it ticked off, which is the worst combination. It now says: All Pages only, do not add a History Change trigger, with the reason (GA4 enhanced measurement already sends a page_view on pushState, so the trigger fires the Google tag a second time). Your measured 1-vs-2 per navigation is quoted.

Flagging clearly: the published container still carries that trigger. I cannot change the GTM container from here — that is a UI action someone has to take before this item can be ticked. The file now says so rather than implying it is done.

2. The swap silences the assistant — stated plainly

Rewritten from a "re-verify after the swap" step into a statement of fact: window.gtag is a function today via integrations.ga4 and undefined under the container alone, so all seven assistant_* events stop on merge and stay stopped until the widget pushes to dataLayer directly (#2307). I included your finding that a hand-rolled gtag(){ dataLayer.push(arguments) } shim does not rescue it, because that is the non-obvious part and the next person will otherwise try it. The file now recommends landing the widget change first.

3. Cloudflare — confirmed, and unticked

$ dig +short docs.celo.org @1.1.1.1
cname.vercel-dns.com.
76.76.21.93
66.33.60.194

$ curl -sI https://docs.celo.org/ | grep -i 'server\|cf-ray'
cf-cache-status: HIT
cf-ray: a4057db15a5c3851-CDG
server: Vercel

A visible CNAME to Vercel with Vercel addresses behind it is proof the record is DNS-only — a proxied record returns Cloudflare addresses and hides the target. The cf-ray header sits upstream of Vercel, on the same response that says server: Vercel. Runbook 3 now opens by saying none of it is in place, and everything under it is explicitly conditional on a DNS change someone has to decide on. The MCP-volume claim in Known blind spots went with it.

4. ANALYTICS.md will be public — confirmed and owned

$ curl -s -o /dev/null -w '%{http_code}' https://docs.celo.org/AGENTS   -> 200
$ curl -s -o /dev/null -w '%{http_code}' https://docs.celo.org/CLAUDE   -> 200

Both serve full content. The header now says the file is reachable at /ANALYTICS, explains that not being in navigation only keeps it out of the sidebar and search, and adds the instruction that follows from it: do not put anything in here you would not publish.

The one thing I did not change

The AddNetworkButton.jsx events still ship dormant — the six event tags do not exist in the container. That is unchanged and still true at this head, as you said. It is harmless (a dataLayer push with no tag listening is a no-op) but it does mean this PR delivers no new measurement on its own.

Which raises the honest question for you as reviewer: given the container still has the wrong trigger and none of the event tags, is there a reason to merge this now rather than after the GTM work? The swap's only immediate effect on the live site would be to silence the assistant events. I have left it in scope because that was the call on this PR, but I would take the argument that the docs.json swap should be split out and land last.

Verification

$ npx mintlify@4.2.920 validate                        -> build validation passed
$ npx mintlify@4.2.920 broken-links --check-redirects  -> no broken links found
$ bash scripts/check-orphans.sh                        -> No orphan pages found

CI here stays red until #2335 lands — npx mintlify currently resolves to an uninstallable release, unrelated to this diff.

Re-requesting review.

@GigaHierz
GigaHierz requested a review from palango September 24, 2026 23:17

@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.

Thanks for working through the last round. The history trigger, the window.gtag gap and the public-page warning are all fixed where I pointed at them.

To answer your question in the PR: yes, I'd hold the docs.json swap. I fetched the published container (gtm.js?id=GTM-NP9GP2BT) today and it still has the Google tag firing on gtm.historyChange, and none of the event tags (add_network_*, scroll_depth, outbound_click, copy_code, ai_menu_click, is_automated, assistant_* all appear 0 times). Merging now keeps the double-counted navigations and switches off all assistant telemetry, with nothing new measured in return. Two ways forward:

  • (a) Remove the history trigger, add the event tags including the assistant ones, land the widget change, then merge this as is.
  • (b) Split it: merge ANALYTICS.md and the AddNetworkButton pushes now (they're harmless no-ops until the tags exist) and do the integrations swap as the last step.

Either way, please say in the PR body that the swap stops the assistant_* events, and update #2307: its Gap 2 still says GTM's Google tag "should still define the global", and its acceptance criterion expects typeof window.gtag === 'function' after the swap. Both turned out false.

Also still open from last time: the "Ownership table in ANALYTICS.md filled in" checkbox in the PR body.

Comment thread ANALYTICS.md Outdated
| Audience | Tool | Where |
|---|---|---|
| Humans (browsers) | GA4 `G-0CXEKQ81V2`, tags managed in Google Tag Manager | `docs.json` → `integrations.gtm` |
| Bots and AI agents | Cloudflare AI Crawl Control (free tier), proxied in front of the domain | Cloudflare zone for `celo.org` |

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.

Runbook 3 now says docs.celo.org is DNS-only, but this row still lists Cloudflare AI Crawl Control "proxied in front of the domain" as the tool for bots. Please mark it as not in place (e.g. "none today, see Runbook 3").

Comment thread ANALYTICS.md Outdated
|---|---|---|
| GA4 property | `G-0CXEKQ81V2` | `@celo-org/devrel` |
| GTM container | `GTM-NP9GP2BT` | `@celo-org/devrel` |
| Cloudflare zone / DNS for `docs.celo.org` | `celo.org` zone, `docs.celo.org` proxied | `@celo-org/devrel` |

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.

Same here: the record isn't proxied, so "docs.celo.org proxied" is wrong.

Comment thread AGENTS.md Outdated
- `snippets/` holds reusable JSX/MDX (`/snippets/ColoredText.jsx`, `/snippets/YouTube.jsx`, `/snippets/AddNetworkButton.jsx`). Import with an absolute path after the frontmatter: `import {YouTube} from '/snippets/YouTube.jsx'`.
- Static assets: `img/`, `images/`, `assets/`, `logo/`.
- **Any `.js` file under the content root runs on every published page.** Mintlify injects them automatically — there is no allowlist and no way to scope one to a single page — and the same applies to `.css`. Treat a `.js` file here as production code shipped to every reader, not as content: it has full same-origin DOM access on pages that print contract addresses and RPC endpoints. Mintlify does not support a raw `<script src>` in MDX, so third-party scripts are injected programmatically from such a file (`assistant.js` is the example). Note `submodules/developer-tooling` sits under this root too.
- Site analytics (GA4 via Google Tag Manager for humans, Cloudflare AI Crawl Control for bots/agents) are documented in [ANALYTICS.md](./ANALYTICS.md).

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.

This still says bots/agents are measured by Cloudflare AI Crawl Control. Nothing measures them today, so please drop that part or say it's planned.

Comment thread ANALYTICS.md Outdated
`chatgpt\.com|chat\.openai\.com|claude\.ai|perplexity\.ai|gemini\.google\.com|copilot\.microsoft\.com|grok\.com|x\.ai|deepseek\.com|you\.com|phind\.com|meta\.ai`
GA4's built-in "AI Assistant" channel recognizes only ChatGPT, Gemini, DeepSeek, Copilot and Grok — not Claude or Perplexity. Known limit: a large share of AI-referred sessions arrive with no referrer and land in Direct; this channel measures the floor, not the total.
- [ ] **Custom dimensions** (event-scoped): `percent_scrolled`, `link_domain`, `ai_target`, `network`, `result`, `is_automated`, plus the assistant's `answered`, `escalated`, `truncated`, `from_api`, `status` and `href`. Without these registered the assistant events still arrive, but their parameters cannot be used in any report.
- [ ] **The GTM swap silences every assistant event, and there is no shim for it.** `widget.js` calls `track()` only `if (typeof window.gtag === 'function')`. On the live site that global is a function, provided by `integrations.ga4`; on a page carrying only the GTM container it is `undefined`. Defining `gtag(){ dataLayer.push(arguments) }` by hand does *not* rescue it — no hit goes out. So all seven `assistant_*` events stop the moment the swap merges and stay stopped until the widget is changed to push to `dataLayer` directly (#2307). Land the widget change first if the gap is not acceptable.

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.

A dataLayer push only reaches GA4 if a GTM tag listens for it, which is the same reason add_network_* does nothing today. Runbook 1 has no item for the seven assistant_* events, so landing #2307 alone won't bring them back. Please add a Custom Event trigger (e.g. regex ^assistant_) plus a GA4 event tag forwarding answered, escalated, truncated, from_api, status and href, and say here that both the widget change and the tag are needed.

Comment thread ANALYTICS.md
- [ ] **Custom channel group "AI Assistants"**: condition Session source matches regex
`chatgpt\.com|chat\.openai\.com|claude\.ai|perplexity\.ai|gemini\.google\.com|copilot\.microsoft\.com|grok\.com|x\.ai|deepseek\.com|you\.com|phind\.com|meta\.ai`
GA4's built-in "AI Assistant" channel recognizes only ChatGPT, Gemini, DeepSeek, Copilot and Grok — not Claude or Perplexity. Known limit: a large share of AI-referred sessions arrive with no referrer and land in Direct; this channel measures the floor, not the total.
- [ ] **Custom dimensions** (event-scoped): `percent_scrolled`, `link_domain`, `ai_target`, `network`, `result`, `is_automated`, plus the assistant's `answered`, `escalated`, `truncated`, `from_api`, `status` and `href`. Without these registered the assistant events still arrive, but their parameters cannot be used in any report.

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.

Nit: "Without these registered the assistant events still arrive" stops being true after the swap, because under GTM they don't arrive at all (next item). Suggest: "Once the assistant events flow again, their parameters can't be used in reports until registered."

Comment thread ANALYTICS.md
- `docs.json` → `integrations.gtm.tagId` loads the GTM container on every page. GA4 itself is configured **inside** GTM (Google Tag), not in `docs.json` — having both would double-count page views.
- `snippets/AddNetworkButton.jsx` pushes `dataLayer` events: `add_network_click` on click, and `add_network_result` with `result` = `success` | `rejected` | `error` | `no_wallet` and `network` = chain name. This is the highest-intent action on the site.
- The docs assistant is instrumented on both sides, in `celo-org/docs-ai-assistant` rather than in this repo:
- **Client (GA4).** `widget.js` reuses the page's existing `window.gtag` rather than loading a second tracker, and emits `assistant_opened`, `assistant_question` (`answered`, `escalated`, `truncated`), `assistant_escalate`, `assistant_new_chat`, `assistant_copy`, `assistant_citation_click` (`href`) and `assistant_error` (`from_api`, `status`). Question text is deliberately never sent to GA4.

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.

Nit: "reuses the page's existing window.gtag" describes behaviour this PR breaks. A pointer to the Runbook 2 item about the swap would stop readers assuming it still works.

Comment thread ANALYTICS.md Outdated
- The docs assistant is instrumented on both sides, in `celo-org/docs-ai-assistant` rather than in this repo:
- **Client (GA4).** `widget.js` reuses the page's existing `window.gtag` rather than loading a second tracker, and emits `assistant_opened`, `assistant_question` (`answered`, `escalated`, `truncated`), `assistant_escalate`, `assistant_new_chat`, `assistant_copy`, `assistant_citation_click` (`href`) and `assistant_error` (`from_api`, `status`). Question text is deliberately never sent to GA4.
- **Server (Redis).** `app/api/chat/route.ts` calls `logQuestion()`, which pushes `{question, model, citedUrls, answered, timestamp, refused}` onto the Upstash Redis list `docs-assistant:questions`, trimmed to the most recent 10,000. Without Redis configured it falls back to `console.log`, which on Vercel is short-retention only. **This list is the docs-gap signal** — the uncited questions in it are the pages that need writing.
- A handful of outbound partner links carry manual UTM parameters (`tooling/libraries-sdks/reown/index.mdx`, `tooling/indexers/goldrush.mdx`). The GTM outbound-click tag below covers outbound attribution generally, so new UTMs are not required.

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.

Nit: the outbound-click tag doesn't exist yet, and it answers a different question from partner UTMs (UTMs let the partner attribute traffic to us; the tag tells us about clicks). I'd drop "so new UTMs are not required".

Comment thread ANALYTICS.md Outdated

## Runbook 3: Cloudflare in front of docs.celo.org (bot visibility)

This is the only layer that could see non-JS bot traffic. **None of it is in place today, and the earlier claim that it was is wrong.**

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.

Nit: "and the earlier claim that it was is wrong" is about the doc's history, not guidance. "None of it is in place today." is enough.

Comment thread ANALYTICS.md Outdated
- **JS-capable agentic browsers** (Comet, Atlas, computer-use agents) execute the GA4 tag and count as humans; `is_automated` catches only naive automation.
- **MCP traffic, both volume and content.** Requests to `https://docs.celo.org/mcp` are invisible to GA4, which needs JavaScript. They are not visible in our Cloudflare analytics either, because the hostname is not behind our zone (see Runbook 3) — that would only become true after the DNS change. And even then, what no layer here can show is **what was asked**: the question text, which tool was called, whether the answer cited anything.

Mintlify's own dashboard would cover part of that, but it needs the Pro plan ($450/mo), which #2250 evaluated and declined — the site was previously on Pro and deliberately moved to Starter. Query-level signal comes from the in-page assistant instead, and it is already implemented on both sides (see below).

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.

Nit: "(see below)" points past the end of the file. The assistant is described in "What is instrumented in this repo" above.

GigaHierz and others added 2 commits September 25, 2026 11:32
The container still fires the Google tag on gtm.historyChange and carries
none of the event tags, so swapping docs.json now would double-count
navigations and switch off all seven assistant_* events while measuring
nothing new in return.

What stays is inert on its own: ANALYTICS.md, the AddNetworkButton dataLayer
pushes (no-ops until tags exist) and the AGENTS.md pointer. docs.json is
back to integrations.ga4, and the swap becomes the last step once the
container is fixed and the widget no longer depends on window.gtag.

ANALYTICS.md now describes the state as it actually is rather than the
intended end state: GA4 loaded directly, the container not yet wired up,
and docs.celo.org not proxied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GigaHierz GigaHierz changed the title feat(analytics): route GA4 through GTM and instrument engagement events feat(analytics): instrument the add-network conversion and document how the site is measured Sep 25, 2026
@GigaHierz

Copy link
Copy Markdown
Contributor Author

Taken option (b): the docs.json swap is out of this PR. git diff origin/main -- docs.json is now empty, and the site keeps loading integrations.ga4.

Your reasoning decided it — the container check you ran today is the whole argument. Merging the swap while the history trigger is still attached and the event tags are absent trades working assistant telemetry and correct page-view counts for nothing.

Beyond removing the swap, I went back through ANALYTICS.md for the same class of error you found: it described the intended end state as though it were already true. Four more places had that problem and are now corrected — the architecture table said page views come through integrations.gtm, the ownership table said docs.celo.org is proxied, Runbook 1 opened with "docs.json carries its ID", and the assistant item was still framed as a post-swap check rather than a precondition.

Also ticked the ownership checkbox you flagged — the table has been filled in since e7fe61af, owners are teams rather than individuals, matching how .github/CODEOWNERS assigns /docs.json. The PR body was just stale.

#2307 still needs updating — its Gap 2 says GTM's Google tag "should still define the global" and its acceptance criterion expects typeof window.gtag === 'function' after the swap, both of which your testing disproved. I have not edited it yet; say the word and I will, or you may prefer to since it is your issue.

$ npx mintlify@4.2.920 validate                        -> build validation passed
$ npx mintlify@4.2.920 broken-links --check-redirects  -> no broken links found
$ bash scripts/check-orphans.sh                        -> No orphan pages found

@GigaHierz
GigaHierz requested a review from palango September 25, 2026 10:34

@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.

Dropping the swap was the right call, and the doc now separates what is live from what is planned. L17 and L28 are fixed, and L39 and L70 are true again now that the site keeps integrations.ga4.

Two things before this merges, both small:

  1. AGENTS.md:15 still describes the end state as live (inline).
  2. Runbook 1 has no GTM tag for the assistant_* events, so whoever does the swap by following it will lose them (inline at L61).

The rest are nits.

Comment thread AGENTS.md Outdated
- `snippets/` holds reusable JSX/MDX (`/snippets/ColoredText.jsx`, `/snippets/YouTube.jsx`, `/snippets/AddNetworkButton.jsx`). Import with an absolute path after the frontmatter: `import {YouTube} from '/snippets/YouTube.jsx'`.
- Static assets: `img/`, `images/`, `assets/`, `logo/`.
- **Any `.js` file under the content root runs on every published page.** Mintlify injects them automatically — there is no allowlist and no way to scope one to a single page — and the same applies to `.css`. Treat a `.js` file here as production code shipped to every reader, not as content: it has full same-origin DOM access on pages that print contract addresses and RPC endpoints. Mintlify does not support a raw `<script src>` in MDX, so third-party scripts are injected programmatically from such a file (`assistant.js` is the example). Note `submodules/developer-tooling` sits under this root too.
- Site analytics (GA4 via Google Tag Manager for humans, Cloudflare AI Crawl Control for bots/agents) are documented in [ANALYTICS.md](./ANALYTICS.md).

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.

This still says GA4 runs "via Google Tag Manager" and bots are measured by "Cloudflare AI Crawl Control". Neither is true today, and this file is what agents read first. Something like: "Site analytics (GA4 today; the planned GTM and Cloudflare setup) are documented in ANALYTICS.md."

Comment thread ANALYTICS.md
- [ ] **AI-menu clicks**: Click trigger on the page-level contextual menu (Copy page / ChatGPT / Claude / Cursor / VS Code / MCP — the `contextual.options` in `docs.json`) → GA4 event `ai_menu_click` with `ai_target` set from the clicked item's text. Same selector caveat as above.
- [ ] **AddNetworkButton events**: Custom Event triggers for `add_network_click` and `add_network_result` → GA4 event tags forwarding `network` and `result` as parameters (Data Layer variables).
- [ ] **Automation heuristic**: Custom HTML tag (fires before the Google Tag, e.g. on Consent/Initialization) that pushes `{ is_automated: "true" }` to the dataLayer when `navigator.webdriver === true` or the user agent contains `HeadlessChrome`; attach `is_automated` as a parameter on the Google Tag. This is a weak signal, not a count — agentic browsers such as Comet and Atlas use stock Chrome user agents and are indistinguishable client-side.
- [ ] Verify everything in GTM **Preview mode** against the live site, then **Publish**.

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.

Runbook 1 still has no item for the seven assistant_* events. Once the widget pushes to dataLayer (#2307), those pushes only reach GA4 if a GTM tag listens for them, just like add_network_*. Please add a Custom Event trigger (e.g. regex ^assistant_) plus a GA4 event tag forwarding answered, escalated, truncated, from_api, status and href, and make L71 say that both the widget change and this tag have to be in place before the swap. The PR body's to-do already lists "the six event tags, including assistant_*", but the six items here don't include them.

Comment thread snippets/AddNetworkButton.jsx Outdated
const [pending, setPending] = useState(false);

// GTM picks these up and forwards them to GA4 (see /ANALYTICS.md).
// dataLayer is absent when GTM is blocked or not yet configured.

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.

Nit: under today's integrations.ga4, the standard gtag snippet creates window.dataLayer, so these pushes probably land in gtag's dataLayer rather than going nowhere. I expect gtag.js to ignore plain {event: ...} objects, so "ships dormant" should still hold, but I haven't captured it. Could you check that clicking the button sends no /g/collect hit today, and reword the comment to match (e.g. "GA4 only sees these once a GTM tag forwards them")?

Comment thread ANALYTICS.md Outdated
- The docs assistant is instrumented on both sides, in `celo-org/docs-ai-assistant` rather than in this repo:
- **Client (GA4).** `widget.js` reuses the page's existing `window.gtag` rather than loading a second tracker, and emits `assistant_opened`, `assistant_question` (`answered`, `escalated`, `truncated`), `assistant_escalate`, `assistant_new_chat`, `assistant_copy`, `assistant_citation_click` (`href`) and `assistant_error` (`from_api`, `status`). Question text is deliberately never sent to GA4.
- **Server (Redis).** `app/api/chat/route.ts` calls `logQuestion()`, which pushes `{question, model, citedUrls, answered, timestamp, refused}` onto the Upstash Redis list `docs-assistant:questions`, trimmed to the most recent 10,000. Without Redis configured it falls back to `console.log`, which on Vercel is short-retention only. **This list is the docs-gap signal** — the uncited questions in it are the pages that need writing.
- A handful of outbound partner links carry manual UTM parameters (`tooling/libraries-sdks/reown/index.mdx`, `tooling/indexers/goldrush.mdx`). The GTM outbound-click tag below covers outbound attribution generally, so new UTMs are not required.

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.

Nit: the outbound-click tag doesn't exist yet, and it answers a different question from partner UTMs (UTMs let the partner attribute traffic to us; the tag tells us about clicks). I'd drop "so new UTMs are not required".

Comment thread ANALYTICS.md Outdated

## Runbook 3: Cloudflare in front of docs.celo.org (bot visibility)

This is the only layer that could see non-JS bot traffic. **None of it is in place today, and the earlier claim that it was is wrong.**

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.

Nit: "and the earlier claim that it was is wrong" is about the doc's history, not guidance. "None of it is in place today." is enough.

Comment thread ANALYTICS.md Outdated
- **JS-capable agentic browsers** (Comet, Atlas, computer-use agents) execute the GA4 tag and count as humans; `is_automated` catches only naive automation.
- **MCP traffic, both volume and content.** Requests to `https://docs.celo.org/mcp` are invisible to GA4, which needs JavaScript. They are not visible in our Cloudflare analytics either, because the hostname is not behind our zone (see Runbook 3) — that would only become true after the DNS change. And even then, what no layer here can show is **what was asked**: the question text, which tool was called, whether the answer cited anything.

Mintlify's own dashboard would cover part of that, but it needs the Pro plan ($450/mo), which #2250 evaluated and declined — the site was previously on Pro and deliberately moved to Starter. Query-level signal comes from the in-page assistant instead, and it is already implemented on both sides (see below).

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.

Nit: "(see below)" points past the end of the file. The assistant is described under "What is instrumented in this repo" above.

@palango

palango commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

On #2307: yes please, go ahead and update it. Gap 2 should say GTM's Google tag does not define window.gtag, and the acceptance criterion should expect the assistant_* events to arrive through dataLayer (with a matching GTM tag) rather than typeof window.gtag === 'function'.

GigaHierz and others added 2 commits September 28, 2026 12:22
…end state as live

- AGENTS.md said GA4 runs via GTM and bots are measured by Cloudflare. It
  is the first thing an agent reads, and neither is true today. Now names
  GA4 as current and the GTM/Cloudflare work as planned.
- Runbook 1 had no tag for the seven assistant_* events, so following it
  through the swap would have lost them even after the widget change. Adds
  a Custom Event trigger on ^assistant_ with a GA4 event tag forwarding the
  six parameters, and the pre-swap gate now names both halves: the widget
  push and the tag that listens for it.
- The AddNetworkButton comment claimed GTM forwards these today. Checked on
  the live site: the pushes land in gtag's dataLayer, which gtag.js creates,
  and gtag even stamps them with gtm.uniqueEventId, but no /g/collect hit
  goes out. Comment now says GA4 only sees them once a tag forwards them.
- Drops the claim that the outbound-click tag removes the need for partner
  UTMs; they answer different questions. Drops a line about the doc's own
  history, and fixes a "see below" that pointed past the end of the file.

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

Copy link
Copy Markdown
Contributor Author

Both blocking items and all four nits are in.

1. AGENTS.md:15

Fixed. You are right that this one matters more than its size — it is the first file an agent reads, and it was asserting the end state as current. Now: "Site analytics — GA4 today, plus the planned Google Tag Manager and Cloudflare setup — are documented in ANALYTICS.md."

2. The missing assistant_* tag

Good catch, and it would have been an expensive one to find later. The PR body's to-do said "the six event tags, including assistant_*" while Runbook 1's six items did not include them — so anyone working from the runbook would have ticked every box and still lost the events.

Runbook 1 now has a Custom Event trigger on regex ^assistant_ with a GA4 event tag using {{Event}} as the event name and forwarding answered, escalated, truncated, from_api, status and href. The pre-swap gate now states both halves explicitly: the widget push (#2307) and the tag that listens for it, with a line saying either one alone leaves the events dark.

3. The /g/collect nit — checked, and your instinct was right

Loaded the live site in a real browser and pushed both events exactly as the button does:

typeof window.gtag  -> "function"
window.dataLayer    -> exists (created by gtag.js), no GTM on the page

// pushed add_network_click + add_network_result
dataLayer.slice(-2) -> [
  {event:"add_network_click",  network:"Celo Mainnet", gtm.uniqueEventId:13},
  {event:"add_network_result", network:"Celo Mainnet", result:"success", gtm.uniqueEventId:14}
]

/g/collect hits before: 2  (both en=page_view)
/g/collect hits after:  2  (both en=page_view)

So "ships dormant" holds, but for a more interesting reason than "nothing is listening": gtag.js does ingest the pushes and even stamps them with gtm.uniqueEventId. They are processed and then dropped, because gtag.js has no tag mapping a plain {event: …} object to a GA4 hit. The comment now says GA4 only sees them once a tag forwards them, and cites the check.

One thing that fell out of this, unrelated to the diff: that single page load produced two /g/collect hits, both en=page_view. That is the live site today on integrations.ga4, with no GTM and no history trigger involved — which corroborates the double-counting you measured, and suggests the cause is not only the container's history trigger. Worth a look when the swap is planned; I have not chased it here.

Nits

  • Dropped "so new UTMs are not required" — agreed they answer different questions, and the tag does not exist yet.
  • Dropped "and the earlier claim that it was is wrong"; "None of it is in place today." is enough.
  • "(see below)" now points at "What is instrumented in this repo" above.
$ npx esbuild --loader:.jsx=jsx snippets/AddNetworkButton.jsx   -> compiles clean
   (control: appending a syntax error makes it fail, so the check bites)
$ npx mintlify@4.2.920 validate                        -> build validation passed
$ npx mintlify@4.2.920 broken-links --check-redirects  -> no broken links found
$ bash scripts/check-orphans.sh                        -> No orphan pages found

@GigaHierz
GigaHierz requested a review from palango September 28, 2026 11:28

@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.

All items from the last round are in: AGENTS.md no longer describes the planned setup as live, Runbook 1 has the ^assistant_ trigger and tag, and the pre-swap item says both it and #2307 are needed. Thanks for capturing the /g/collect check on the button. Small follow-up if you touch the PR body again before merging: the "stops the assistant_* events until the widget change lands" line should also mention the GTM tag, since the squash message comes from it.

@palango
palango merged commit f8f4ccc into main Sep 30, 2026
5 checks passed
@palango
palango deleted the GigaHierz/expand-ga-analytics-hooks branch September 30, 2026 15:21
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