feat: PostHog (EU, anonymous) + nvm_o decorator on docs - #244
Conversation
Custom JS instead of Mintlify's native PostHog integration because the native one exposes no config (can't disable autocapture, can't set the curated posture) and proxies events through Mintlify's host. Config matches the blog loader exactly: $pageview + masked session replay only, no identify, localStorage persistence. Also decorates outbound nevermined.app links with the nvm_o attribution payload and fires the app_handoff_click GA event - docs readers are the highest-intent handoff. On the proxied domain the middleware cookies ride along; on direct docs.nevermined.app visits the payload is empty but the click still counts (blocklist-watch numerator). RB2B deliberately absent (decision in nevermined.ai-website#191: docs readers are product-led, not outbound targets).
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
There was a problem hiding this comment.
Pull request overview
Adds first-party docs instrumentation for the Nevermined Mintlify site by introducing a single JavaScript file that Mintlify will auto-include on every page. This aligns with the Growth Analytics epic by enabling anonymous PostHog tracking (EU cloud) and decorating docs→app outbound links with attribution payloads plus a GA4 click event.
Changes:
- Add PostHog initialization (EU host, anonymous posture, autocapture off, masked session replay).
- Add outbound link decorator that appends an
nvm_oquery param for*.nevermined.apptargets and fires agtagapp_handoff_clickevent. - Gate behavior to production hostnames and prevent double-initialization.
| function readCookie(name) { | ||
| var m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); | ||
| return m ? decodeURIComponent(m[1]) : null; | ||
| } |
There was a problem hiding this comment.
Already guarded — readCookie wraps decodeURIComponent in try/catch returning null (and consentState's cookie path does the same). Thread was just never closed out; done as of 2d6f805's sweep.
The older array.js stub captured only the initial page load; Mintlify docs are an SPA, so client-side navigations were invisible. The 2026-05-30 defaults capture pageviews on history changes; loader also gains assets-host delivery and crossOrigin. Curated posture unchanged.
Docs had no CMP: EU docs-only visitors could never consent, so under the consent architecture (website#200, Option C) nothing would ever fire for them. This injects CookieYes (same account as site + blog: one shared consent state) and gates PostHog on the authoritative consent state - granted fires, unknown/denied wait, no timeout inference, fail closed. Adds the capture-on-consent recovery post on the proxied domain. Line-for-line port of the site's lib/consent-client gate.
|
Updated in a5e6aa0 (consent architecture, decided today): the file now injects CookieYes (docs previously had no CMP at all — EU docs-only visitors could never consent) and gates PostHog on the authoritative consent state — granted fires, unknown/denied wait, fail closed, no timeout inference. Capture-on-consent recovery added on the proxied domain. Same-account CMP = one shared consent state across site/blog/docs. |
Same policy as site + blog: anonymous audience measurement runs by default under legitimate interest; explicit Reject opts out, incl. retroactively. CookieYes stays injected so docs visitors CAN reject, and the capture-on-consent recovery for attribution cookies is unchanged (strict gate).
|
Policy update (Robin, data controller, 2026-07-20) — measurement vs identification split: PostHog is anonymous audience measurement in our config (masked replays, no identify(), no cookies — localStorage, no cross-site) and now runs default-on under legitimate interest, with an explicit Reject honored via opt-out (incl. retroactively, via the new denial watcher). The strict granted-only consent gate remains for RB2B (person identification) and the attribution cookies (Option C unchanged). Documented here so the re-review reads this as the controller's documented position, not a regression of the earlier fix — the fail-open race stays fixed (no timeout inference anywhere); what changed is the default for the anonymous-measurement class only. (implemented in d63e2bf) |
Same state-driven controller as site + blog: denied never loads, undecided runs memory-persistence pageviews only (no device storage, no replay - outside the ePrivacy storage rule), granted runs full and upgrades a limited start in place. Signed middleware cookies (v1) unwrapped without verification; cookie decode guarded.
|
Updated in 9cbe0ae: docs loader moved to the same Option B consent matrix as site + blog (denied never loads · undecided = memory-persistence pageviews only, no device storage, no replay · granted = full with in-place upgrade), plus signed-cookie unwrap for the middleware's new v1 format and guarded cookie decode. LIA + sign-off in website #198's privacy checklist. |
….json integration) Mintlify's integrations.ga4 injected gtag with no consent defaults preceding it: EU docs visitors received GA cookies before any banner decision. GA now loads from docs-analytics.js with GCM defaults declared first (granted globally, denied region-scoped to the same EU27+UK set as the website middleware, wait_for_update 500ms, ads_data_redaction). CookieYes pushes updates on the visitor's single consent decision. Trade: GA loads marginally later on docs; taken for consent correctness + one-config consistency.
|
Addendum in 44f3aab: docs GA moved out of |
aaitor
left a comment
There was a problem hiding this comment.
Solid direction — one posture (PostHog EU-anonymous + GA4 + nvm_o decorator) mirrored from the site/blog, with Consent Mode defaults correctly declared before gtag and a fail-closed consent gate. Requesting changes on one HIGH: the app_handoff_click GA event — the measurement half of the docs→app handoff this PR exists to capture — never fires, because window.gtag is never defined. Plus a docs.json diff that's 472 lines of reformat hiding a 5-line change the PR description says it didn't make.
HIGH (1) — window.gtag is never assigned, so app_handoff_click is dead code (inline at L258). The URL decoration still works; only the GA event is lost — silently, with no error.
MEDIUM (3)
docs.json— the raw diff is +472/-476, but a sorted-key (jq -S) comparison ofmainvsfeat/docs-analyticsshows the only semantic change is removingintegrations.ga4(G-47H9F2MT29); the rest is a whole-file reformat. That removal is correct and required — GA4 now loads indocs-analytics.jsso Consent Mode defaults can precede gtag — but two things need fixing: (1) revert the reformat so the diff is the intended-5and actually reviewable; (2) the PR description is wrong — it says "Existing GA4 via docs.json integrations.ga4 is untouched" and "no docs.json change needed," when GA4 was in fact relocated and docs.json changed. State the relocation explicitly.- Stale/contradictory GA4 header comment (inline at L25-33) — same relocation not reflected; it still claims GA4 lives untouched in docs.json, which is a double-init trap for the next editor.
nvm_odecorator forwards attribution cross-origin even on explicit consent denial (inline at L250).
LOW (2) — uncleared 1 Hz setInterval (L196); Consent Mode region list omits EEA non-EU states IS/LI/NO (L60).
Verification I ran
- Read both changed files in full at head
44f3aab;gh pr diff,gh pr checks(Mintlify deploy + link-rot green, vale skipped). docs.json: base-vs-headjq -Ssemantic diff = 8 lines, all theintegrations.ga4removal — confirmed the other ~470 lines are pure reformat and semantically inert.- Confirmed
docs-analytics.jsre-adds GA4 (G-47H9F2MT29) with Consent Mode defaults before gtag, so GA4 isn't lost — it's relocated (and no double-load today, because docs.json's entry is gone). - Parallel panel: security-reviewer (no secrets —
phc_key is a public ingestion key;isAppHostverified to rejectevilnevermined.appandnevermined.app.evil.com; onlyurl.toString()reachesa.href, no XSS sink), code-reviewer (independently confirmed thewindow.gtagHIGH), and a/ponytail-reviewpass. - Over-engineering (
/ponytail-review): net ~-477 lines removable, almost entirely from reverting thedocs.jsonreformat (-470) + the stale comment. The.jsitself is lean — every helper is reachable, and the custom PostHog/GA4 load is justified (Mintlify's native integration exposes no config and injects gtag with no consent defaults).
Not blocking — for the post-merge checklist you already noted: authorize nevermined.ai + docs.nevermined.app in the PostHog Authorized domains for recordings, or replays won't arrive.
Decision: REQUEST_CHANGES — the one HIGH (dead handoff measurement) defeats a primary goal of the PR and is a one-line fix.
| if (window.gtag) { | ||
| window.gtag("event", "app_handoff_click", { | ||
| link_url: url.hostname + url.pathname, | ||
| channel: lt && lt.ch ? lt.ch : "unknown" | ||
| }); | ||
| } |
There was a problem hiding this comment.
HIGH — app_handoff_click never fires: window.gtag is never defined.
gtag is declared as an IIFE-local function at L58 (function gtag() { dataLayer.push(arguments); }) and is never assigned to window.gtag. The gtag/js script from googletagmanager (L67) doesn't create window.gtag either — by convention that global comes from the inline snippet, which here is trapped inside the closure. So window.gtag is undefined for the page's lifetime and this guard is always false: the app_handoff_click event — the PR's stated #195 blocklist-watch numerator, and the whole measurement half of the docs→app handoff — is dead code, silently, no error.
The URL-decoration half (L256-257) still works; only the GA event is lost. Fix: call the closure-scoped gtag directly (it's in scope here) and drop the dead window. guard:
| if (window.gtag) { | |
| window.gtag("event", "app_handoff_click", { | |
| link_url: url.hostname + url.pathname, | |
| channel: lt && lt.ch ? lt.ch : "unknown" | |
| }); | |
| } | |
| gtag("event", "app_handoff_click", { | |
| link_url: url.hostname + url.pathname, | |
| channel: lt && lt.ch ? lt.ch : "unknown" | |
| }); |
Verify the event lands in GA4 DebugView before merge — nothing in CI catches a no-op gtag call.
| * Consent (added 2026-07-20): this file now also injects CookieYes | ||
| * (docs previously had no CMP) and gates PostHog on the authoritative | ||
| * consent state — granted fires, anything else waits, fail closed. | ||
| * The nvm_o decorator stays ungated (functional attribution, no | ||
| * storage on this host). GA4 via docs.json integrations.ga4 is | ||
| * untouched here; its consent posture rides on CookieYes auto-blocking | ||
| * (verify in the CookieYes dashboard). | ||
| * | ||
| * GA4 is loaded separately via docs.json integrations.ga4 — untouched. |
There was a problem hiding this comment.
MEDIUM — stale/contradictory header comment; also a double-init trap.
L29 and L33 say GA4 "is untouched here … loaded separately via docs.json integrations.ga4 — untouched." That's contradicted by L50-71 in this same file (which now inject gtag + GA4 config) and by L51 ("GA moved here from docs.json integrations.ga4"). I confirmed docs.json's integrations.ga4 (G-47H9F2MT29) is in fact removed on this branch, so there's no double-load today — but a maintainer who trusts this comment and re-adds the docs.json entry would double-init GA4 (two gtag('config', 'G-47H9F2MT29')) and reintroduce the pre-consent-cookie bug that section 0a exists to fix. Delete the stale claim and state that GA4 is now owned exclusively by this file:
| * Consent (added 2026-07-20): this file now also injects CookieYes | |
| * (docs previously had no CMP) and gates PostHog on the authoritative | |
| * consent state — granted fires, anything else waits, fail closed. | |
| * The nvm_o decorator stays ungated (functional attribution, no | |
| * storage on this host). GA4 via docs.json integrations.ga4 is | |
| * untouched here; its consent posture rides on CookieYes auto-blocking | |
| * (verify in the CookieYes dashboard). | |
| * | |
| * GA4 is loaded separately via docs.json integrations.ga4 — untouched. | |
| * Consent (added 2026-07-20): this file now also injects CookieYes | |
| * (docs previously had no CMP) and gates PostHog on the authoritative | |
| * consent state — granted fires, anything else waits, fail closed. | |
| * The nvm_o decorator stays ungated (functional attribution, no | |
| * storage on this host). GA4 was moved into THIS file on 2026-07-21 | |
| * (section 0a) with Consent Mode defaults declared before gtag; the | |
| * docs.json integrations.ga4 entry was removed and must not be | |
| * re-added, or GA4 will double-initialize. |
| } catch (e) { | ||
| return; | ||
| } | ||
| if (!isAppHost(url.hostname)) return; |
There was a problem hiding this comment.
MEDIUM — the nvm_o decorator forwards attribution to nevermined.app even when the visitor denied consent.
Every other collector here is consent-gated (PostHog via applyPostHogConsent, GA4 via Consent Mode), but decorate() runs on every click/auxclick with no consent check and base64-appends the nvm_ft/nvm_lt payload (channel etc.) to the outbound URL. Under explicit denial that attribution data is still transmitted cross-origin to nevermined.app, where it can be read/logged/persisted — which undercuts gating everything else. The "no storage on this host" rationale holds for this origin, but the data still leaves the browser. Keep navigation working; just don't attach the payload on explicit denial:
| if (!isAppHost(url.hostname)) return; | |
| if (!isAppHost(url.hostname)) return; | |
| if (consentState() === "denied") return; // don't forward attribution on explicit denial |
(Leaving unknown to still forward is consistent with the Option-B "anonymous until decided" posture; only denied needs to short-circuit. Worth aligning the sibling website decorator the same way.)
| } | ||
| } | ||
| document.addEventListener("cookieyes_consent_update", applyPostHogConsent); | ||
| setInterval(applyPostHogConsent, 1000); |
There was a problem hiding this comment.
LOW — setInterval(applyPostHogConsent, 1000) is never cleared.
whenConsented() (L109-125) already shows the pattern — store the timer, clearInterval once terminal. This poll runs forever on every docs page even after consent reaches a terminal state (granted+upgraded, or denied+opted-out) where the callback has nothing left to do. Cheap to bound:
| setInterval(applyPostHogConsent, 1000); | |
| var phPollTimer = setInterval(function () { | |
| applyPostHogConsent(); | |
| if (consentState() !== "unknown") clearInterval(phPollTimer); | |
| }, 1000); |
| window.dataLayer = window.dataLayer || []; | ||
| function gtag() { dataLayer.push(arguments); } | ||
| gtag("consent", "default", { ad_storage: "granted", ad_user_data: "granted", ad_personalization: "granted", analytics_storage: "granted", functionality_storage: "granted", personalization_storage: "granted", security_storage: "granted" }); | ||
| gtag("consent", "default", { ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied", analytics_storage: "denied", functionality_storage: "denied", personalization_storage: "denied", security_storage: "granted", wait_for_update: 500, region: ["AT","BE","BG","HR","CY","CZ","DK","EE","FI","FR","DE","GR","HU","IE","IT","LV","LT","LU","MT","NL","PL","PT","RO","SK","SI","ES","SE","GB"] }); |
There was a problem hiding this comment.
LOW — Consent Mode EU region list omits the EEA non-EU states (Iceland, Liechtenstein, Norway).
IS, LI, NO are in GDPR/ePrivacy scope but absent from this denied-default region array, so visitors from those three fall through to the global granted default (L59) and get analytics_storage/ad_storage granted pre-consent. Append "IS","LI","NO" to the array, and sync CONSENT_COUNTRY_CODES in the website repo (lib/attribution.ts) per the keep-in-sync note at L55.
…ded poll, minimal docs.json diff - window.gtag assigned (was closure-local only: with docs.json's native GA removed, nothing set the global and app_handoff_click was dead code) - header comment rewritten: docs.json integrations.ga4 IS removed by this change and this file owns the GA load; the old 'GA4 untouched' claims were wrong and invited a double-init re-add - nvm_o decorator attaches attribution only when consent is not explicitly denied; the click event still fires under GCM - 1 Hz consent poll clears once a definitive state is handled; the consent_update listener alone covers later changes - docs.json conflict resolved by taking main wholesale and reapplying only the ga4 removal: net diff is now 5 deleted lines, the 472-line reformat is gone
|
All review items addressed in 2d6f805 except one deliberately escalated:
Also resolved the merge conflict with main that had accumulated. Re-requesting. |
… 2026-07-22 ruling Banner geography (CookieYes EU+UK preset) unchanged; protection geography widens - EEA visitors get denied consent defaults with no banner, staying cookieless-anonymous. Mirrors the same change to CONSENT_COUNTRY_CODES in the website repo.
|
Region-list follow-up resolved — Robin ruled today: protection geography widens to GDPR-EEA (IS/LI/NO), banner geography stays the CookieYes EU+UK preset. EEA visitors get denied defaults and deferred capture but never see a banner: cookieless-anonymous by design, asymmetry documented at both lists. Landed here for docs and on the website's CONSENT_COUNTRY_CODES (feat/attribution-middleware, with the geo tests updated — 58/58). The blog's pasted loader carries the old list and gets a re-paste at the next Webflow touch (tracked). |
aaitor
left a comment
There was a problem hiding this comment.
Re-review — second pass on 547a86a. The HIGH and all three MEDIUMs from my last review are resolved.
- ✅ HIGH (
app_handoff_clickwas dead code):docs-analytics.js:65-69now defineswindow.dataLayer/gtagand assignswindow.gtag = window.gtag || gtag, so the handoff event at L284-289 actually fires. The measurement half of the docs→app handoff works. - ✅ MEDIUM (docs.json reformat): reverted — the docs.json diff is back to the minimal ~5 lines (the
integrations.ga4removal only), reviewable again. - ✅ MEDIUM (stale GA4 header comment): now states GA4 was moved here and removed from docs.json (L66-69), no longer a double-init trap.
- ✅ MEDIUM (
nvm_oforwarded on denial):docs-analytics.js:271-283now gates thenvm_opayload onconsentState() !== "denied"— explicit denial keeps attribution off the outbound URL; the GA event still fires and GCM governs it.
Verification I ran
- Read the current
docs-analytics.jsand the docs.json diff at head547a86a. - Confirmed
isAppHost()still rejects the docs host and only true*.nevermined.apptargets get decorated (no open redirect / no XSS sink — onlyurl.toString()reachesa.href). - CI green (Mintlify deploy + link-rot).
Still-open LOWs (non-blocking, your call): the 1 Hz setInterval consent poll is never cleared (harmless — root layout never unmounts), and the Consent Mode region list still omits the EEA-non-EU states IS/LI/NO. Post-merge reminder: authorize nevermined.ai + docs.nevermined.app in PostHog's Authorized domains for recordings, or replays won't arrive. Approving.
Analytics for the docs surface (epic nevermined.ai-website#191/#188): PostHog (EU, anonymous, consent-gated Option B), CookieYes CMP injection, GCM consent defaults, GA4 ownership moved into the loader, and the nvm_o outbound decorator + app_handoff_click event for docs→app handoffs.
This PR DOES change docs.json: it removes
integrations.ga4(5 deleted lines). Mintlify's native GA injection declared no consent defaults before gtag, so EU visitors got GA cookies pre-consent. The loader now owns the full GA sequence: GCM defaults → gtag → CookieYes. Do not re-add integrations.ga4 — double-init.Review fixes (2d6f805):
window.gtagassigned globally (the handoff event was dead code without it) · header comment rewritten to state the GA move honestly · nvm_o attribution withheld on explicit consent denial (click event still fires, GCM governs it) · the 1 Hz consent poll clears after a definitive state · docs.json diff reduced to the 5 real lines by resettling on main.Verified by executing the real IIFE in a vm (stubbed DOM): gtag global set, consent defaults queued before load, decorator attaches nvm_o under granted and withholds under denied.
Open review item deliberately NOT changed here: the GCM region list (IS/LI/NO) — that is the shared consent perimeter (
CONSENT_COUNTRY_CODES, one contract across site/blog/docs), escalated to Robin as a perimeter decision rather than patched on one surface.🤖 Generated with Claude Code