Add consent-gated Conceptual Analytics pixel to dev docs - #550
Add consent-gated Conceptual Analytics pixel to dev docs#550andrewleesteele wants to merge 6 commits into
Conversation
Mintlify loads every .js file in the content directory on every page, which is how the pixel reaches docs pages that the marketing site's layout never renders. The docs are served under www.kernel.sh/docs, so the c15t consent cookie set on the marketing site is readable here and gates the pixel the same way. respectDNT and anonymizeIP are off in the shipped pixel config and are turned on before the loader runs. The pixel has no router hooks, so docs navigation sends its own page_view. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
The consent banner lives in the marketing site's app and never renders on a docs page, so a visitor whose first page is a docs page had no way to reach a decision and never got the pixel — which is most of the traffic this is meant to cover. An existing decision still wins. Absent one, ask the same c15t endpoint the marketing site uses and apply its rule: prompt in regulated jurisdictions, auto-grant elsewhere. The endpoint is same-origin here. Any failure leaves the pixel unloaded.
There was a problem hiding this comment.
Stale comment
Risk assessment: Medium
Verdict: Do not auto-approve.
This is not a documentation-only change. The diff adds
conceptual.js, which Mintlify loads on every docs page. That script injects a third-party analytics loader fromplfalg.kernel.sh, readsc15tconsent state from cookies andlocalStorage, calls/api/c15t/show-consent-banner, and monkey-patcheshistory.pushState/history.replaceStateto emitpage_viewevents.Why this is Medium (not Low / Very Low)
- Site-wide user-facing surface. The new file runs for all
/docs/*visitors, not an isolated page or internal tool.- Privacy / consent model. Tracking can start without a stored decision whenever the jurisdiction endpoint returns
showConsentBanner === false. The second commit widened that from fail-closed (require stored consent) to geo-gated auto-load.- Third-party script injection. A remote loader is appended to
document.headon production docs traffic.- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's own client router.- Blast radius is the full docs origin. A wrong consent parse, endpoint response, or loader behavior affects measurement and privacy for every visitor.
Why this is not High
The change is a single self-contained file, fails closed on fetch/parse errors, uses
credentials: "same-origin", and does not touch auth, billing, infra, or server-side code in this repo. Those bounds keep it at Medium rather than higher.Action: Comment only. Human review of the consent/geo-gate and third-party loader is required before merge.
Sent by Cursor Automation: Assign PR reviewers
There was a problem hiding this comment.
Stale comment
Risk assessment: Medium
Verdict: Do not auto-approve.
This is not a documentation-only change. The diff adds a single new file,
conceptual.js(110 lines). Mintlify loads every.jsfile in the content directory on every docs page, so this runs for all/docs/*visitors.What the code actually does
- Injects a third-party loader from
https://plfalg.kernel.sh/analytics/loader-v1.jsintodocument.head- Reads
c15tconsent state from cookies andlocalStorage- If no stored decision exists, calls
/api/c15t/show-consent-bannerand starts tracking whenshowConsentBanner === false- Monkey-patches
history.pushState/history.replaceStateand listens forpopstateto emitpage_vieweventsWhy this is Medium (not Low / Very Low)
- Site-wide user-facing surface. Every docs visitor executes this script, not an isolated page or internal tool.
- Privacy / consent model. Tracking can start without a stored user decision whenever the jurisdiction endpoint returns
showConsentBanner === false. The second commit widened this from fail-closed (require stored consent) to geo-gated auto-load.- Third-party script injection. A remote analytics loader is appended on production docs traffic.
- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's client router.- Blast radius is the full docs origin. A wrong consent parse, unexpected endpoint response, or loader behavior affects measurement and privacy for every visitor.
Why this is not High
The change is one self-contained file. Fetch/parse failures leave the pixel unloaded, the jurisdiction request uses
credentials: "same-origin", and this repo's auth, billing, infra, and server-side code are untouched. Those bounds keep it at Medium rather than higher.Action: Comment only. Human review of the consent/geo-gate and third-party loader is required before merge.
Sent by Cursor Automation: Assign PR reviewers
The device ID is stored per-domain, so the dashboard mints its own and the ad click that led to a signup is never credited. The docs link to dashboard sign-up in several places, so this path matters here too. Appends the ID to onkernel.com links on click. The dashboard side reads it back, which is not in this repo.
|
Pushed the cross-domain device ID handoff here too. The docs link to Verified against the live The receiving half lives in |
There was a problem hiding this comment.
Stale comment
Risk assessment: Medium
Verdict: Do not auto-approve.
Re-evaluated after the
00f2bf5synchronize (outboundca_device_idhandoff). This is still not a documentation-only change. The diff adds a single new file,conceptual.js(138 lines). Mintlify loads every.jsfile in the content directory on every docs page, so this runs for all/docs/*visitors.No prior approval from this automation exists, so there is nothing to revoke.
What the code actually does
- Injects a third-party loader from
https://plfalg.kernel.sh/analytics/loader-v1.jsintodocument.head- Reads
c15tconsent state from cookies andlocalStorage- If no stored decision exists, calls
/api/c15t/show-consent-bannerand starts tracking whenshowConsentBanner === false- Monkey-patches
history.pushState/history.replaceStateand listens forpopstateto emitpage_viewevents- New in this update: capture-phase click interceptor that mutates
a[href]towardonkernel.com/*.onkernel.comby appendingca_device_idWhy this is Medium (not Low / Very Low)
- Site-wide user-facing surface. Every docs visitor executes this script, not an isolated page or internal tool.
- Privacy / consent model. Tracking can start without a stored user decision whenever the jurisdiction endpoint returns
showConsentBanner === false.- Third-party script injection. A remote analytics loader is appended on production docs traffic.
- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's client router.- Cross-domain identifier handoff (new). A visitor identifier is written into outbound URLs. That expands privacy blast radius (query strings, logs, Referer, history) beyond same-origin measurement.
- Blast radius is the full docs origin. A wrong consent parse, unexpected endpoint response, or loader behavior affects measurement and privacy for every visitor.
What changed vs. the last assessment
Risk stays Medium, not High. The new click interceptor increases the tracking/privacy surface but does not touch auth, billing, infra, or server-side code. Those bounds still keep it below High.
Action: Comment only. Human review of the consent/geo-gate, third-party loader, and cross-domain device-ID handoff is required before merge.
Sent by Cursor Automation: Assign PR reviewers
c15t omits false booleans from its cookie rather than writing them, so a decline never appears as `c.marketing:0` — it's the absence of the key. Reading only for an explicit `:0` made a decline indistinguishable from no decision, and the jurisdiction fallback then loaded the pixel for someone who had opted out. Any stored consent record now means a decision was made, and a missing category in one means no.
There was a problem hiding this comment.
Stale comment
Risk assessment: Medium
Verdict: Do not auto-approve.
Re-evaluated after the
e2a4b02synchronize (treat a stored consent record withoutmarketingas a decline). This is still not a documentation-only change. The diff adds a single new file,conceptual.js(140 lines). As the only.jsfile in this Mintlify content root, it is loaded on every docs page and runs for all/docs/*visitors.No prior approval from this automation exists, so there is nothing to revoke. No
CODEOWNERSfile is present.What the code actually does
- Injects a third-party loader from
https://plfalg.kernel.sh/analytics/loader-v1.jsintodocument.head- Reads
c15tconsent state from cookies andlocalStorage- If a stored consent record exists, starts tracking only when
marketingis explicitly granted; otherwise treats the record as a decline and does not fall through to geo-gating- If no stored decision exists, calls
/api/c15t/show-consent-bannerand starts tracking whenshowConsentBanner === false- Monkey-patches
history.pushState/history.replaceStateand listens forpopstateto emitpage_viewevents- Capture-phase click interceptor that mutates
a[href]towardonkernel.com/*.onkernel.comby appendingca_device_idWhy this is Medium (not Low / Very Low)
- Site-wide user-facing surface. Every docs visitor executes this script, not an isolated page or internal tool.
- Privacy / consent model. Tracking can start without a stored user decision whenever the jurisdiction endpoint returns
showConsentBanner === false.- Third-party script injection. A remote analytics loader is appended on production docs traffic.
- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's client router.- Cross-domain identifier handoff. A visitor identifier is written into outbound URLs (query strings, logs, Referer, history).
- Blast radius is the full docs origin. A wrong consent parse, unexpected endpoint response, or loader behavior affects measurement and privacy for every visitor.
What changed vs. the last assessment
Risk stays Medium, not High, and did not increase.
e2a4b02is a consent-parse tightening: a presentc15tcookie orlocalStoragerecord without an explicitmarketing:1/marketing === truenow returns decline (false) instead of "no decision" (null). That closes a path where an opt-out could fall through to the geo-gate and load the pixel. It reduces one privacy-bug risk; it does not shrink the site-wide tracking, third-party loader, History API, or device-ID handoff surface.This repo's auth, billing, infra, and server-side code remain untouched, which keeps the rating below High.
Action: Comment only. Human review of the consent/geo-gate, third-party loader, and cross-domain device-ID handoff is required before merge.
Sent by Cursor Automation: Assign PR reviewers
Both from review on the marketing-site pixel, which shares this approach. Consent is decided once at page load here, but it can be withdrawn on the marketing site while a docs page stays open — and Mintlify is a single-page app, so that page can live a long time. There's no consent UI on docs to react to, so navigation page views and the device-ID handoff now re-read the decision instead of trusting the one made at load. anonymizeIP is documented as inert rather than implied to be active: the shipped script defines it and never reads it, and resolves the visitor's full address to send as client_ip regardless.
There was a problem hiding this comment.
Stale comment
Risk assessment: Medium
Verdict: Do not auto-approve.
Re-evaluated after the
fd57bccsynchronize (re-read consent on navigation/handoff; document thatanonymizeIPis inert). This is still not a documentation-only change. The diff adds a single new file,conceptual.js(150 lines). As the only.jsfile in this Mintlify content root, it is loaded on every docs page and runs for all/docs/*visitors.No prior approval from this automation exists, so there is nothing to revoke. No
CODEOWNERSfile is present.What the code actually does
- Injects a third-party loader from
https://plfalg.kernel.sh/analytics/loader-v1.jsintodocument.head- Reads
c15tconsent state from cookies andlocalStorage- If a stored consent record exists, starts tracking only when
marketingis explicitly granted; otherwise treats the record as a decline and does not fall through to geo-gating- If no stored decision exists, calls
/api/c15t/show-consent-bannerand starts tracking whenshowConsentBanner === false- Monkey-patches
history.pushState/history.replaceStateand listens forpopstateto emitpage_viewevents- Capture-phase click interceptor that mutates
a[href]towardonkernel.com/*.onkernel.comby appendingca_device_idWhy this is Medium (not Low / Very Low)
- Site-wide user-facing surface. Every docs visitor executes this script, not an isolated page or internal tool.
- Privacy / consent model. Tracking can start without a stored user decision whenever the jurisdiction endpoint returns
showConsentBanner === false.- Third-party script injection. A remote analytics loader is appended on production docs traffic.
- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's client router.- Cross-domain identifier handoff. A visitor identifier is written into outbound URLs (query strings, logs, Referer, history).
- Blast radius is the full docs origin. A wrong consent parse, unexpected endpoint response, or loader behavior affects measurement and privacy for every visitor.
What changed vs. the last assessment
Risk stays Medium, not High, and did not increase.
fd57bcctightens two runtime paths and corrects a comment:
- Navigation
page_viewand theca_device_idclick handoff now re-readstoredConsent()and skip when the stored decision is a decline, instead of trusting the page-load decision for the life of the SPA session.- Comments now state that
anonymizeIPis set but unused by the shipped pixel, so full client IP can still be sent. That documents residual privacy behavior; it does not add new codepaths.Those edits reduce one consent-withdrawal gap and make IP handling explicit. They do not shrink the site-wide tracking, third-party loader, History API, geo-gate, or device-ID handoff surface.
This repo's auth, billing, infra, and server-side code remain untouched, which keeps the rating below High.
Action: Comment only. Human review of the consent/geo-gate, third-party loader, and cross-domain device-ID handoff is required before merge.
Sent by Cursor Automation: Assign PR reviewers
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit fd57bcc. Configure here.
| function (event) { | ||
| var link = event.target.closest && event.target.closest("a[href]"); | ||
| if (!link || typeof window.ca.getDeviceId !== "function") return; | ||
| if (storedConsent() === false) return; |
There was a problem hiding this comment.
Stale device ID after withdrawal
Medium Severity
The new decline check returns before rewriting the link, but a prior click already wrote ca_device_id onto href. That param stays, so a later click on the same onkernel.com link still sends the device ID after consent is withdrawn.
Reviewed by Cursor Bugbot for commit fd57bcc. Configure here.
Same finding as kernel/website#305, same shared vendor script. The consent re-check on navigation couldn't govern the pixel's internal emissions: trackPageViews sends a page view at init and trackUnload installs scroll, click and form-submit listeners, all routed through the vendor's own track() rather than through ours. Both off, so the script emits nothing on its own and the landing page view is ours to send along with the navigation ones.
There was a problem hiding this comment.
Risk assessment: Medium
Verdict: Do not auto-approve.
Re-evaluated after the f54f885 synchronize (disable the vendor's own trackPageViews / trackUnload emissions and send the landing page_view from this script). This is still not a documentation-only change. The diff adds a single new file, conceptual.js (161 lines). As the only .js file in this Mintlify content root, it is loaded on every docs page and runs for all /docs/* visitors.
No prior approval from this automation exists, so there is nothing to revoke. No CODEOWNERS file is present.
What the code actually does
- Injects a third-party loader from
https://plfalg.kernel.sh/analytics/loader-v1.jsintodocument.head - Reads
c15tconsent state from cookies andlocalStorage - If a stored consent record exists, starts tracking only when
marketingis explicitly granted; otherwise treats the record as a decline and does not fall through to geo-gating - If no stored decision exists, calls
/api/c15t/show-consent-bannerand starts tracking whenshowConsentBanner === false - Monkey-patches
history.pushState/history.replaceStateand listens forpopstateto emitpage_viewevents - Capture-phase click interceptor that mutates
a[href]towardonkernel.com/*.onkernel.comby appendingca_device_id - Sets
__CA_CONFIG.trackPageViewsandtrackUnloadtofalse, then emits the initialpage_viewitself
Why this is Medium (not Low / Very Low)
- Site-wide user-facing surface. Every docs visitor executes this script, not an isolated page or internal tool.
- Privacy / consent model. Tracking can start without a stored user decision whenever the jurisdiction endpoint returns
showConsentBanner === false. - Third-party script injection. A remote analytics loader is appended on production docs traffic.
- Global History API patching. Wrapping
pushState/replaceStatecan interact with Mintlify's client router. - Cross-domain identifier handoff. A visitor identifier is written into outbound URLs (query strings, logs, Referer, history).
- Blast radius is the full docs origin. A wrong consent parse, unexpected endpoint response, or loader behavior affects measurement and privacy for every visitor.
What changed vs. the last assessment
Risk stays Medium, not High, and did not increase.
f54f885 turns off the vendor script's built-in page-view and unload/interaction emitters so those events go through this file's consent-aware window.ca("track", ...) path instead. That reduces one ungated vendor-emission path. It does not shrink the site-wide tracking, third-party loader, History API, geo-gate, or device-ID handoff surface.
This repo's auth, billing, infra, and server-side code remain untouched, which keeps the rating below High.
Action: Comment only. Human review of the consent/geo-gate, third-party loader, and cross-domain device-ID handoff is required before merge.
Sent by Cursor Automation: Assign PR reviewers




Summary
Adds the Conceptual Analytics front-end pixel to the dev docs site, so ad click IDs and page views are captured on
/docs/*as well as the marketing site. Pairs with kernel/website#305, which covers the marketing site.Why a custom script
www.kernel.sh/docs/*is a Next rewrite that proxies to this Mintlify app, so the marketing site's root layout — and the pixel component in it — never renders on a docs page. Docs currently have no pixel at all.Mintlify loads every
.jsfile in the content directory on every page, which is the supported way to inject a third-party script.docs.jsonintegrationsis a fixed vendor allowlist with no Conceptual entry, so a custom script is the only route short of adding a GTM container.How it decides whether to load
/api/c15t/show-consent-banner, which is same-origin here, and load only where no banner is required. In regulated jurisdictions c15t prompts; everywhere else it auto-grantsmarketingandmeasurement, so this matches the behaviour a visitor would get had they landed on a marketing page instead.The gate is needed because the consent banner lives in the marketing site's React tree and never renders on a docs page. Requiring a stored decision would have meant almost no coverage for visitors whose first page is a docs page, which is the traffic this is for.
It also turns on
respectDNTandanonymizeIP, both off in the shipped pixel config, before the loader runs, and sends apage_viewon docs navigation — the pixel sends one on init and has no router hooks, so client-side navigation between docs pages is otherwise invisible.The key in this file is the public pixel key, which is designed to be visible in browser code. The server-side conversion key is not used here and must never appear in a browser.
Testing
Verified end to end against the live
www.kernel.sh/docsorigin by injecting this script with the vendor's dev key, which validates requests but stores nothing:NONEpage_viewGDPRnode --checkpasses. Not verified in a Mintlify preview deployment — the jurisdiction endpoint is only reachable when the docs are served under the marketing domain, and the script fails closed anywhere else.Note for reviewers
The consent category is
marketing, matching kernel/website#305. Our privacy policy files ad-campaign measurement under analytics, someasurementis arguable; it is one constant in each PR if we want to switch.Note
Medium Risk
Introduces third-party analytics and consent logic that reads cookies/localStorage and mutates outbound links; behavior is fail-closed on errors but still affects privacy-sensitive traffic on docs.
Overview
Adds
conceptual.js, a Mintlify-injected script that loads the Conceptual Analytics pixel on docs pages (where the marketing layout never runs), so/docs/*can record page views and preserve ad attribution like the main site.Consent gating: If a shared-origin c15t
marketingdecision exists (cookie orlocalStorage), the pixel starts only on grant and stays off on decline. With no decision yet, it calls/api/c15t/show-consent-bannerand loads only when no banner is required (mirroring auto-grant outside regulated regions); any fetch/parse failure leaves the pixel unloaded.Tracking behavior: Before the vendor loader runs,
__CA_CONFIGturns onrespectDNT/anonymizeIPand disables built-intrackPageViewsandtrackUnloadso events are explicitly gated. The script sends an initialpage_view, hookspushState/replaceState/popstatefor SPA navigations (re-checking consent each time), and appendsca_device_idto clicks towardonkernel.comfor signup attribution when consent is not declined.Reviewed by Cursor Bugbot for commit f54f885. Bugbot is set up for automated code reviews on this repo. Configure here.