From 3705286c24bc2568223833a4bc13194259ec2127 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:21:35 -0600 Subject: [PATCH 1/2] feat: add regional Google Analytics for public docs --- .github/workflows/docs-code-ci.yml | 2 + .github/workflows/r2-pages.yml | 2 + package-lock.json | 8 + package.json | 1 + scripts/docs-site/analytics-behavior.test.mjs | 339 ++++++++++++++++++ scripts/docs-site/analytics-consent.css | 28 ++ scripts/docs-site/analytics-consent.mjs | 176 +++++++++ scripts/docs-site/analytics-consent.test.mjs | 331 +++++++++++++++++ scripts/docs-site/analytics-events.mjs | 234 ++++++++++++ scripts/docs-site/analytics-vitals.test.mjs | 97 +++++ scripts/docs-site/analytics.mjs | 194 ++++++++++ scripts/docs-site/analytics.test.mjs | 223 ++++++++++++ scripts/docs-site/build.mjs | 18 +- scripts/docs-site/site-css.mjs | 2 + scripts/docs-site/site-js.mjs | 48 +-- scripts/docs-site/web-vitals-runtime.mjs | 7 + workers/analytics-consent.test.mjs | 27 ++ workers/analytics-consent.ts | 33 ++ workers/docs-router.ts | 3 + 19 files changed, 1747 insertions(+), 26 deletions(-) create mode 100644 scripts/docs-site/analytics-behavior.test.mjs create mode 100644 scripts/docs-site/analytics-consent.css create mode 100644 scripts/docs-site/analytics-consent.mjs create mode 100644 scripts/docs-site/analytics-consent.test.mjs create mode 100644 scripts/docs-site/analytics-events.mjs create mode 100644 scripts/docs-site/analytics-vitals.test.mjs create mode 100644 scripts/docs-site/analytics.mjs create mode 100644 scripts/docs-site/analytics.test.mjs create mode 100644 scripts/docs-site/web-vitals-runtime.mjs create mode 100644 workers/analytics-consent.test.mjs create mode 100644 workers/analytics-consent.ts diff --git a/.github/workflows/docs-code-ci.yml b/.github/workflows/docs-code-ci.yml index de533c5024..76cae1c1e9 100644 --- a/.github/workflows/docs-code-ci.yml +++ b/.github/workflows/docs-code-ci.yml @@ -75,6 +75,8 @@ jobs: docs-og-v1-${{ runner.os }}-${{ hashFiles('scripts/docs-site/og-cache.mjs', 'scripts/docs-site/og-render-worker.mjs', 'scripts/docs-site/fonts/**', 'package-lock.json') }}- - name: Build shell artifact + env: + DOCS_SITE_GA4_ENABLED: "1" run: npm run docs:build:r2:shell - name: Smoke shell artifact diff --git a/.github/workflows/r2-pages.yml b/.github/workflows/r2-pages.yml index 804d185655..56593a6aea 100644 --- a/.github/workflows/r2-pages.yml +++ b/.github/workflows/r2-pages.yml @@ -77,6 +77,8 @@ jobs: env: DOCS_SITE_CANONICAL_ORIGIN: https://docs.openclaw.ai DOCS_SITE_CNAME: docs.openclaw.ai + # Closed until the rollout owner authorizes activation after endpoint proof. + DOCS_SITE_GA4_ENABLED: ${{ vars.DOCS_SITE_GA4_ENABLED || '0' }} environment: name: cloudflare url: https://docs.openclaw.ai diff --git a/package-lock.json b/package-lock.json index e8c54c3eba..126c2c1f8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "pagefind": "1.5.2", "playwright": "^1.63.0", "tsx": "4.23.13", + "web-vitals": "6.2.2", "yaml": "2.9.0" } }, @@ -4270,6 +4271,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/web-vitals": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/package.json b/package.json index 9957f46b96..eefb91476c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "pagefind": "1.5.2", "playwright": "^1.63.0", "tsx": "4.23.13", + "web-vitals": "6.2.2", "yaml": "2.9.0" }, "dependencies": { diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs new file mode 100644 index 0000000000..71e86254ad --- /dev/null +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -0,0 +1,339 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { before, after, test } from "node:test"; +import { chromium } from "playwright"; +import { fixture, write } from "./test-helpers/redirect-fixture.mjs"; + +const origin = "https://docs.openclaw.ai"; +const sdk = process.env.DOCS_GA4_SDK_PATH ? fs.readFileSync(process.env.DOCS_GA4_SDK_PATH, "utf8") : ""; +let browser; +before(async () => { browser = await chromium.launch({ headless: true }); }); +after(async () => { await browser?.close(); }); + +async function openSite(t, { privacy, clock = false, diagram = false, showConsent = false, theme = "dark", viewport = { width: 1440, height: 900 } } = {}) { + const f = fixture(t, [], { + "guide.md": '# Guide\n\n## Install\n\n```sh title="npm"\nnpm install EXAMPLE_PUBLIC_CODE\n```\n\n[Home](/)\n\n[Contributor](https://github.com/public-contributor)\n\n## Configure\n\nConfiguration guidance.\n\n## Password\n\nPublic password documentation.\n', + }); + if (diagram) { + fs.appendFileSync(path.join(f.root, "docs/guide.md"), "\n```mermaid\nflowchart LR\n A --> B\n```\n"); + fs.symlinkSync(path.resolve("node_modules"), path.join(f.root, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + } + write(f.root, "docs/docs.json", JSON.stringify({ name: "Docs fixture", navigation: { languages: [{ language: "en", tabs: [{ tab: "Docs", groups: [{ group: "Guide", pages: ["index", "guide"] }] }] }] } })); + const built = f.build({ DOCS_SITE_GA4_ENABLED: "1", GITHUB_SHA: "1234567890abcdef1234567890abcdef12345678" }); + assert.equal(built.status, 0, built.stderr); + const site = path.join(f.root, "dist/docs-site"); + const context = await browser.newContext({ serviceWorkers: "block", viewport, reducedMotion: "reduce" }); + t.after(() => context.close()); + const collected = []; + const googleRequests = []; + await context.route("**/*", async route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { + googleRequests.push(url.href); + return route.fulfill({ contentType: "text/javascript", body: sdk }); + } + if (/\/(?:g\/)?collect$/.test(url.pathname)) { + collected.push({ url: request.url(), body: request.postData() || "", headers: request.headers() }); + return route.fulfill({ status: 204 }); + } + if (url.origin !== origin) return route.abort(); + if (url.pathname === "/ask-molty/api/chat") return route.fulfill(request.method() === "GET" + ? { contentType: "application/json", body: "{}" } + : { contentType: "text/plain", body: "[Private reference](https://private.example/PRIVATE_CHAT_VALUE?q=PRIVATE_CHAT_QUERY)" }); + if (url.pathname === "/pagefind/pagefind.js") return route.fulfill({ contentType: "text/javascript", body: ` + export async function init() {} + export async function search(q) { if(q.includes("delay")) await window.__searchWait; if(q.includes("error")) throw new Error("Fixture search error"); return {results: Array.from({length:q.includes("missing")?0:q.includes("many")?20:1},(_,i)=>({data: async () => ({url:"/guide#result-"+i,meta:{title:"Guide"},excerpt:"Public guidance"})}))}; } + ` }); + let file = path.join(site, url.pathname); + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); + if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Missing" }); + const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" })[path.extname(file)] || "application/octet-stream"; + return route.fulfill({ contentType, body: fs.readFileSync(file) }); + }); + await context.addInitScript(({ privacy, showConsent, theme }) => { + if (privacy === "gpc") Object.defineProperty(navigator, "globalPrivacyControl", { value: true }); + if (privacy === "dnt") Object.defineProperty(navigator, "doNotTrack", { value: "1" }); + const now = Date.now(); + if (!showConsent) localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: privacy === "optout" ? "denied" : "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + localStorage.setItem("theme", theme); + window.__copyFails = false; + Object.defineProperty(navigator, "clipboard", { value: { writeText: async () => { if (window.__copyFails) throw new Error("Unavailable"); } } }); + document.execCommand = () => false; + window.__opened = []; + window.open = (...args) => { window.__opened.push(args); return null; }; + }, { privacy, showConsent, theme }); + const page = await context.newPage(); + if (clock) await page.clock.install(); + await page.goto(`${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch#intro`, { referer: "https://chatgpt.com/c/PRIVATE_REFERRER_VALUE?key=example" }); + await page.waitForFunction(() => document.querySelector(".page-feedback")?.dataset.feedbackReady === "true"); + const events = name => page.evaluate(name => (window.dataLayer || []).filter(entry => entry[0] === "event" && (!name || entry[1] === name)).map(entry => ({ name: entry[1], ...entry[2] })), name); + return { page, context, events, collected, googleRequests }; +} + +test("acquisition, actual copy outcomes, public identifiers and feedback launch stay useful and safe", async t => { + const { page, context, events, collected } = await openSite(t); + const view = (await events("page_view"))[0]; + assert.equal(view.page_location, `${origin}/guide?utm_source=chatgpt&utm_medium=referral&utm_campaign=docs_launch`); + assert.equal(view.page_referrer, "https://chatgpt.com/"); + assert.equal(view.release, "1234567890ab"); + await page.locator("[data-code-copy]").click(); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "copy_action")); + await page.evaluate(() => { window.__copyFails = true; }); + await page.locator("[data-code-copy]").click(); + await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "copy_action").length === 2); + assert.deepEqual((await events("copy_action")).map(event => event.action_result), ["success", "error"]); + assert.ok((await events("copy_action")).every(event => event.method === "npm" && event.content_id === "/guide#code-1")); + const contributor = page.locator('.doc a[href="https://github.com/public-contributor"]'); + await contributor.evaluate(link => { link.target = "_blank"; }); + const popupPromise = context.waitForEvent("page"); + await contributor.click(); + await (await popupPromise).close(); + assert.ok((await events("select_content")).some(event => event.content_id === "https://github.com/public-contributor")); + await page.locator('[data-feedback-value="no"]').click(); + await page.locator("[data-feedback-detail]").fill("PRIVATE_FEEDBACK_VALUE"); + const launcher = page.getByRole("button", { name: "Open issue", exact: true }); + assert.equal(await launcher.getAttribute("href"), null); + assert.equal(await page.locator('a[href*="PRIVATE_FEEDBACK_VALUE"]').count(), 0); + await launcher.focus(); + await page.keyboard.press("Enter"); + const opened = await page.evaluate(() => window.__opened); + assert.equal(opened.length, 1); + assert.match(new URL(opened[0][0]).searchParams.get("body"), /PRIVATE_FEEDBACK_VALUE/); + assert.equal((await events("form_attempt"))[0].form_id, "docs_feedback"); + assert.equal((await events("generate_lead")).length, 0, "opening an issue is not confirmed issue creation"); + assert.doesNotMatch(JSON.stringify(await events()), /EXAMPLE_PUBLIC_CODE|PRIVATE_FEEDBACK_VALUE|PRIVATE_REFERRER_VALUE/); + if (sdk) { + await page.waitForTimeout(2000); + assert.doesNotMatch(JSON.stringify(collected), /PRIVATE_FEEDBACK_VALUE|PRIVATE_REFERRER_VALUE/); + } +}); + +test("custom search counts stable rendered results and selection, never keystrokes or unsafe terms", async t => { + const { page, events, collected } = await openSite(t); + await page.locator("[data-search-open]").first().click(); + await page.locator("[data-search-input]").fill("gui"); + await page.waitForTimeout(350); + await page.locator("[data-search-input]").fill("guide"); + await page.locator(".search-result").first().waitFor(); + assert.equal((await events("search")).length, 0); + await page.locator(".search-result").first().click(); + const search = (await events("search"))[0]; + assert.equal(search.search_term, "guide"); + assert.equal(search.result_count, 1); + assert.equal(search.search_status, "complete"); + assert.ok((await events("select_content")).some(event => event.content_type === "search_result")); + await page.locator("[data-search-open]").first().click(); + await page.locator("[data-search-input]").fill("missing PRIVATE_SEARCH_VALUE@example.com"); + await page.locator(".search-empty").waitFor(); + await page.waitForTimeout(1350); + const empty = (await events("search")).at(-1); + assert.equal(empty.result_count, 0); + assert.equal(empty.search_status, "complete"); + assert.equal(empty.search_term, undefined); + await page.locator("[data-search-input]").fill("many guide"); + await page.waitForFunction(() => document.querySelectorAll(".search-result").length === 12); + await page.waitForTimeout(1300); + const capped = (await events("search")).at(-1); + assert.equal(capped.result_count, 12); + assert.equal(capped.search_status, "partial"); + await page.locator("[data-search-input]").fill("error guide"); + await page.locator(".search-unavailable").waitFor(); + await page.waitForTimeout(1300); + const failed = (await events("search")).at(-1); + assert.equal(failed.search_status, "error"); + assert.equal(failed.result_count, undefined); + await page.locator("[data-search-input]").fill("my password is guide"); + await page.locator(".search-result").first().waitFor(); + await page.keyboard.press("Escape"); + assert.equal((await events("search")).at(-1).search_term, undefined, "a personal credential phrase is redacted even when every word is public vocabulary"); + assert.equal((await events("popup_dismiss")).at(-1).dismiss_method, "escape"); + assert.doesNotMatch(JSON.stringify(await events()), /PRIVATE_SEARCH_VALUE/); + if (sdk) { await page.waitForTimeout(2000); assert.doesNotMatch(JSON.stringify(collected), /PRIVATE_SEARCH_VALUE/); } +}); + +test("async copy and search cannot cross a withdrawn collection interval", async t => { + const { page, events, collected } = await openSite(t); + const choose = async allow => { + await page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await page.getByRole("button", { name: allow ? "Allow Google Analytics" : "Decline Google Analytics", exact: true }).click(); + }; + await page.evaluate(() => { + navigator.clipboard.writeText = () => new Promise(resolve => { window.__finishCopy = resolve; }); + }); + await page.locator("[data-code-copy]").click(); + await page.waitForFunction(() => typeof window.__finishCopy === "function"); + await choose(false); await choose(true); + await page.evaluate(() => window.__finishCopy()); + await page.waitForTimeout(150); + assert.equal((await events("copy_action")).length, 0, "a completion from the previous allowed interval is not replayed"); + for (const startsDenied of [false, true]) { + if (startsDenied) await choose(false); + await page.evaluate(() => { window.__searchWait = new Promise(resolve => { window.__finishSearch = resolve; }); }); + await page.locator("[data-search-open]").first().click(); + await page.locator("[data-search-input]").fill("delay guide"); + await page.waitForTimeout(350); + // Save preference through another same-origin tab while the search dialog + // stays open, then let the real storage listener update this runtime. + const peer = await page.context().newPage(); + await peer.goto(origin + "/"); + for (const analytics of startsDenied ? ["granted"] : ["denied", "granted"]) { + await peer.evaluate(analytics => { + const key = "openclaw.analytics.consent"; + localStorage.setItem(key, JSON.stringify({ ...JSON.parse(localStorage.getItem(key)), analytics })); + }, analytics); + await page.waitForFunction(denied => window["ga-disable-G-3SK7X2YLSJ"] === denied, analytics === "denied"); + } + await peer.close(); + await page.evaluate(() => window.__finishSearch()); + await page.locator(".search-result").first().waitFor(); + await page.keyboard.press("Escape"); + assert.equal((await events("search")).length, 0, "denied or earlier-interval searches are not collected after grant"); + } + if (sdk) { + await page.waitForTimeout(2200); + assert.ok(collected.every(request => !/(?:^|[&\n])en=(?:copy_action|search)(?:&|$)/.test(new URL(request.url).search.slice(1) + "&" + request.body))); + } +}); + +test("private assistant pauses native collection through closing, without disabling signed-in public docs", async t => { + const { page, context, events, collected } = await openSite(t); + const initialViews = (await events("page_view")).length; + await page.getByRole("button", { name: "Ask Molty", exact: true }).click(); + await page.waitForFunction(() => document.querySelector("[data-docs-chat]").dataset.chatAuthState === "ready"); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + await page.locator("[data-chat-input]").fill("PRIVATE_QUESTION_VALUE"); + await page.locator("[data-chat-submit]").click(); + const privateLink = page.getByRole("link", { name: "Private reference", exact: true }); + await privateLink.waitFor(); + const popupPromise = context.waitForEvent("page"); + await privateLink.click(); + await (await popupPromise).close(); + await page.locator("[data-chat-copy]").click(); + assert.equal((await events("copy_action")).length, 0, "private activity is dropped, not queued"); + await page.getByRole("button", { name: "Minimize", exact: true }).click(); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false); + assert.equal((await events("page_view")).length, initialViews, "closing a private panel is not a new public page"); + await page.locator("[data-code-copy]").click(); + assert.equal((await events("copy_action")).length, 1, "public actions still work for the authenticated visitor"); + await page.waitForTimeout(2100); + assert.doesNotMatch(JSON.stringify(await events()), /PRIVATE_CHAT_VALUE|PRIVATE_CHAT_QUERY|PRIVATE_QUESTION_VALUE/); + if (sdk) assert.doesNotMatch(JSON.stringify(collected), /PRIVATE_CHAT_VALUE|PRIVATE_CHAT_QUERY|PRIVATE_QUESTION_VALUE/); +}); + +test("diagram popup reports actual opening and Escape dismissal", async t => { + const { page, events } = await openSite(t, { diagram: true }); + await page.locator("[data-mermaid-expand]").click(); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "popup_view" && entry[2].popup_id === "docs_diagram")); + await page.keyboard.press("Escape"); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "popup_dismiss" && entry[2].popup_id === "docs_diagram")); + assert.equal((await events("popup_dismiss")).find(event => event.popup_id === "docs_diagram").dismiss_method, "escape"); + await page.locator('.doc a[href="/"]').click(); + await page.waitForURL(`${origin}/`); + await page.goBack(); + await page.waitForURL("**/guide**"); + await page.locator("[data-mermaid-expand]").click(); + await page.locator("[data-mermaid-overlay-close]").click(); + await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "popup_dismiss" && entry[2].popup_id === "docs_diagram").length === 2); + assert.equal((await events("popup_dismiss")).filter(event => event.popup_id === "docs_diagram").at(-1).dismiss_method, "close_button"); +}); + +test("navigation performed under the private assistant hold is not replayed on close", async t => { + const { page, events } = await openSite(t); + await page.getByRole("button", { name: "Ask Molty", exact: true }).click(); + await page.locator('.doc a[href="/"]').click(); + await page.waitForURL(`${origin}/`); + await page.getByRole("button", { name: "Minimize", exact: true }).click(); + assert.equal((await events("page_view")).length, 1); + assert.equal((await events("popup_dismiss")).find(event => event.popup_id === "docs_assistant").page_location, `${origin}/`, "public event context resumes on the current page without a historical view"); +}); + +test("regrant after an unmeasured return to the same route starts one current view and rebinds sections", async t => { + const { page, events } = await openSite(t); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view" && entry[2].section_id === "install")); + await page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + await page.locator('.doc a[href="/"]').click(); await page.waitForURL(`${origin}/`); + await page.goBack(); await page.waitForURL("**/guide**"); + assert.equal((await events("page_view")).length, 1); + await page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); + assert.equal((await events("page_view")).length, 2); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); + await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "section_view" && entry[2].section_id === "install").length === 2); +}); + +test("scroll and section milestones reset per view; bounded errors expose no raw details", async t => { + const { page, events } = await openSite(t); + await page.addStyleTag({ content: ".doc { min-height: 4000px } #configure { margin-top: 2000px }" }); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view")); + assert.ok((await events("section_view")).some(event => event.section_id === "install")); + await page.evaluate(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" })); + await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "scroll_depth").length === 3); + assert.deepEqual((await events("scroll_depth")).map(event => event.percent_scrolled), [25, 50, 75]); + await page.evaluate(() => { window.scrollTo({ top: 0, behavior: "instant" }); window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); }); + await page.waitForTimeout(100); + assert.equal((await events("scroll_depth")).length, 3); + await page.evaluate(() => { + for (let i = 0; i < 8; i++) window.dispatchEvent(new ErrorEvent("error", { message: "PRIVATE_ERROR_VALUE", error: new TypeError("PRIVATE_ERROR_VALUE") })); + }); + assert.equal((await events("client_error")).filter(event => event.error_code === "type_error").length, 1); + assert.doesNotMatch(JSON.stringify(await events()), /PRIVATE_ERROR_VALUE/); + await page.locator('.doc a[href="/"]').click(); + await page.waitForURL(`${origin}/`); + assert.equal((await events("page_view")).length, 2); + assert.ok((await events("web_vital")).every(event => ["LCP", "INP", "CLS"].includes(event.metric_name) && Number.isFinite(event[{ LCP: "lcp_ms", INP: "inp_ms", CLS: "cls_score" }[event.metric_name]]))); +}); + +test("reading milestones report only bounded visible time without inflating native engagement", async t => { + const { page, events } = await openSite(t, { clock: true }); + await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "page_view")); + await page.clock.runFor(31000); + assert.deepEqual((await events("content_engagement")).map(event => event.engagement_seconds), [30]); + await page.clock.runFor(31000); + const milestones = await events("content_engagement"); + assert.deepEqual(milestones.map(event => event.engagement_seconds), [30, 60]); + assert.ok(milestones.every(event => event.engagement_time_msec === undefined)); +}); + +for (const privacy of ["gpc", "dnt", "optout"]) test(`${privacy} prevents Google tag loading and custom collection`, async t => { + const { page, events, googleRequests } = await openSite(t, { privacy }); + await page.locator("[data-code-copy]").click(); + assert.deepEqual(await events(), []); + assert.equal(googleRequests.length, 0); +}); + +test("consent choices remain readable and keyboard accessible in both themes and mobile layouts", async t => { + for (const theme of ["light", "dark"]) for (const viewport of [{ width: 1440, height: 900 }, { width: 320, height: 568 }]) { + const { page, googleRequests } = await openSite(t, { showConsent: true, theme, viewport }); + const panel = page.locator("[data-analytics-consent]"); + await panel.waitFor({ state: "visible" }); + assert.equal(googleRequests.length, 0); + const geometry = await panel.evaluate(panel => { + const rect = panel.getBoundingClientRect(); + return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, overflow: document.documentElement.scrollWidth > innerWidth }; + }); + assert.ok(geometry.left >= 0 && geometry.right <= viewport.width && geometry.top >= 0 && geometry.bottom <= viewport.height); + assert.equal(geometry.overflow, false); + const buttons = panel.locator("[data-analytics-allow],[data-analytics-deny]"); + assert.deepEqual(await buttons.evaluateAll(buttons => buttons.map(button => { + const style = getComputedStyle(button); + return [style.color, style.backgroundColor, style.borderColor, style.fontWeight].join(";"); + })), Array(2).fill(await buttons.first().evaluate(button => { + const style = getComputedStyle(button); + return [style.color, style.backgroundColor, style.borderColor, style.fontWeight].join(";"); + }))); + if (process.env.DOCS_GA4_VISUAL_DIR) { + fs.mkdirSync(process.env.DOCS_GA4_VISUAL_DIR, { recursive: true }); + await page.screenshot({ path: path.join(process.env.DOCS_GA4_VISUAL_DIR, `${theme}-${viewport.width}.png`) }); + } + await panel.getByRole("button", { name: "Close Google Analytics choices" }).click(); + const launcher = page.getByRole("button", { name: "Google Analytics choices", exact: true }); + await launcher.focus(); + await page.keyboard.press("Enter"); + assert.equal(await page.evaluate(() => document.activeElement.matches("[data-analytics-allow]")), true); + await page.keyboard.press("Escape"); + assert.equal(await page.evaluate(() => document.activeElement.matches("[data-analytics-choices]")), true); + } +}); diff --git a/scripts/docs-site/analytics-consent.css b/scripts/docs-site/analytics-consent.css new file mode 100644 index 0000000000..7497f50681 --- /dev/null +++ b/scripts/docs-site/analytics-consent.css @@ -0,0 +1,28 @@ +.docs-analytics-consent { + position: fixed; + inset-inline-start: var(--oc-space-4); + inset-block-end: var(--oc-space-4); + z-index: var(--oc-layer-notification, 100); + inline-size: min(38rem, calc(100vw - 2 * var(--oc-space-4))); + max-block-size: calc(100dvh - 2 * var(--oc-space-4)); + overflow: auto; + padding: var(--oc-space-5); + border: 1px solid var(--oc-border-strong); + border-radius: var(--oc-radius-surface); + background: var(--oc-bg-elevated); + color: var(--oc-text-secondary); + box-shadow: var(--oc-shadow-lg); + font-size: var(--oc-font-size-sm); + line-height: 1.55; +} +.docs-analytics-consent[hidden] { display: none; } +.docs-analytics-consent__head { display: flex; align-items: start; justify-content: space-between; gap: var(--oc-space-3); } +.docs-analytics-consent h2 { margin: 0; color: var(--oc-text-primary); font-size: var(--oc-font-size-lg); } +.docs-analytics-consent p { margin-block: var(--oc-space-3); } +.docs-analytics-consent__actions { display: flex; flex-wrap: wrap; gap: var(--oc-space-3); margin-block: var(--oc-space-4); } +.docs-analytics-consent__actions > button { flex: 1; min-inline-size: 10rem; } +.docs-analytics-consent summary { cursor: pointer; color: var(--oc-text-primary); } +.docs-analytics-consent [role="status"] { color: var(--oc-text-primary); } +.docs-layout:has(.docs-analytics-consent:not([hidden])) .community-invite { visibility: hidden; } +.docs-layout:has(.docs-analytics-consent:not([hidden])) .docs-chat:not(.open) { visibility: hidden; } +.site-footer-legal [data-analytics-choices] { font: inherit; } diff --git a/scripts/docs-site/analytics-consent.mjs b/scripts/docs-site/analytics-consent.mjs new file mode 100644 index 0000000000..19082c67fa --- /dev/null +++ b/scripts/docs-site/analytics-consent.mjs @@ -0,0 +1,176 @@ +export function analyticsConsentHtml() { + return ``; +} + +// Serialized into the browser shell. Geography is supplied by our own edge; +// language, time zone, client headers, and IP guesses never grant analytics. +export function initDocsAnalyticsConsent(analytics, onChange = () => {}) { + if (location.origin !== "https://docs.openclaw.ai" || !document.querySelector(".main[data-analytics-path]")) return; + const policyVersion = "2026-10-02.v2"; + const storageKey = "openclaw.analytics.consent"; + const lifetime = 180 * 24 * 60 * 60 * 1000; + const panel = document.querySelector("[data-analytics-consent]"); + const launcher = document.querySelector("[data-analytics-choices]"); + if (!panel || !launcher) return; + const allow = panel.querySelector("[data-analytics-allow]"); + const deny = panel.querySelector("[data-analytics-deny]"); + let region = "unknown"; + let regionReady = false; + let storageFailure = false; + let explicit = null; + let incompatiblePolicy = false; + let dismissed = false; + let preferences = false; + let noticeShown = false; + let returnFocus = null; + let expiryTimer; + let initialDecision = true; + let pageHidden = false; + const privacySignal = () => navigator.globalPrivacyControl === true || navigator.doNotTrack === "1" || window.doNotTrack === "1"; + function parseChoice(raw) { + if (!raw) return null; + try { + const value = JSON.parse(raw); + const updated = Date.parse(value.updated_at); + const expires = Date.parse(value.expires_at); + const now = Date.now(); + if (value.schema_version !== 1 || value.policy_version !== policyVersion || !["granted", "denied"].includes(value.analytics) + || typeof value.updated_at !== "string" || typeof value.expires_at !== "string" + || !Number.isFinite(updated) || !Number.isFinite(expires) || updated > now || expires <= now || expires <= updated || expires - updated > lifetime + || new Date(updated).toISOString() !== value.updated_at || new Date(expires).toISOString() !== value.expires_at) return null; + return value; + } catch { return null; } + } + function readChoice() { + try { + const raw = localStorage.getItem(storageKey); + explicit = parseChoice(raw); + incompatiblePolicy = false; + if (raw) { + try { incompatiblePolicy = JSON.parse(raw)?.policy_version !== policyVersion; } + catch { incompatiblePolicy = true; } + } + } + catch { storageFailure = true; explicit = null; } + } + function scheduleExpiry() { + clearTimeout(expiryTimer); + if (!explicit) return; + // Browser delays are signed 32-bit milliseconds. Re-read and reschedule + // long-lived choices rather than overflowing a 180-day timeout. + const delay = Math.min(2147483647, Math.max(0, Date.parse(explicit.expires_at) - Date.now())); + expiryTimer = setTimeout(() => { readChoice(); update(); }, delay); + } + function canStore() { + try { + const probe = storageKey + ".probe"; + localStorage.setItem(probe, "1"); + const ok = localStorage.getItem(probe) === "1"; + localStorage.removeItem(probe); + return ok; + } catch { return false; } + } + function granted() { + if (privacySignal() || storageFailure) return false; + if (explicit) return explicit.analytics === "granted"; + if (incompatiblePolicy) return false; + return regionReady && region === "notice_opt_out" && noticeShown; + } + function update() { + const signal = privacySignal(); + const autoDefault = !signal && !storageFailure && !incompatiblePolicy && !explicit && region === "notice_opt_out"; + const notice = !preferences && autoDefault; + panel.hidden = !(preferences || (autoDefault && !noticeShown) || (!dismissed && !explicit && (regionReady || signal || storageFailure))); + if (autoDefault && !panel.hidden && !pageHidden) noticeShown = true; + panel.querySelector("[data-analytics-consent-title]").textContent = notice ? "Google Analytics on this site" : "Optional Google Analytics"; + panel.querySelector("[data-analytics-consent-body]").textContent = autoDefault + ? "Google Analytics is on to help us understand page visits, interactions, and performance. We do not use it for advertising. You can turn it off or change your choice anytime." + : "With your permission, we use Google Analytics to understand page visits, interactions, and performance on this site. We do not use it for advertising. You can change your choice anytime."; + panel.querySelector("[data-analytics-consent-status]").textContent = storageFailure + ? "Google Analytics is off in this tab, but your choice could not be saved. It may not carry over to another tab or visit." + : signal ? "Google Analytics is off because your browser sends a privacy signal." + : granted() ? "Google Analytics is on." : "Google Analytics is off."; + allow.textContent = notice ? "Keep Google Analytics on" : "Allow Google Analytics"; + deny.textContent = notice ? "Turn Google Analytics off" : "Decline Google Analytics"; + allow.disabled = signal || storageFailure; + deny.disabled = storageFailure; + launcher.setAttribute("aria-expanded", String(!panel.hidden)); + // Show the initial notice before allowing a region-derived default. + const allowed = granted(); + const eligibleFrom = initialDecision && allowed && explicit?.analytics === "granted" && Date.parse(explicit.updated_at) <= performance.timeOrigin ? 0 : performance.now(); + // A cached/departing document can receive a queued storage grant before + // pageshow. Defer positive activation until the restore owner prepares it. + // Ordinary background tabs have no pagehide and continue to update normally. + if (!allowed || !pageHidden) analytics.setConsent(allowed, eligibleFrom); + initialDecision = false; + scheduleExpiry(); + onChange(); + } + function close() { + dismissed = true; preferences = false; + update(); + if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true }); + returnFocus = null; + } + function choose(analyticsChoice) { + if (privacySignal() && analyticsChoice === "granted") return; + // Close the runtime gate even if saving the requested denial fails. + if (analyticsChoice === "denied") analytics.setConsent(false); + const now = Date.now(); + const value = { schema_version: 1, policy_version: policyVersion, analytics: analyticsChoice, updated_at: new Date(now).toISOString(), expires_at: new Date(now + lifetime).toISOString() }; + try { + const serialized = JSON.stringify(value); + localStorage.setItem(storageKey, serialized); + if (localStorage.getItem(storageKey) !== serialized) throw new Error("Preference unavailable"); + explicit = value; storageFailure = false; incompatiblePolicy = false; + close(); + } catch { + storageFailure = true; + update(); + } + } + launcher.addEventListener("click", () => { + returnFocus = document.activeElement; + preferences = true; + update(); + (allow.disabled ? panel.querySelector("[data-analytics-consent-close]") : allow).focus(); + }); + allow.addEventListener("click", () => choose("granted")); + deny.addEventListener("click", () => choose("denied")); + panel.querySelector("[data-analytics-consent-close]").addEventListener("click", close); + panel.addEventListener("keydown", event => { if (event.key === "Escape") { event.stopPropagation(); close(); } }); + addEventListener("storage", event => { if (event.key === storageKey || event.key === null) { readChoice(); update(); } }); + addEventListener("focus", () => { readChoice(); update(); }); + addEventListener("pagehide", event => { if (event.isTrusted) pageHidden = true; }, true); + // Installed before the native SDK: cached tabs must refresh a changed/expired + // choice before any native restore callback. The same pageview owner dedupes + // a following popstate; ordinary initial pageshow never forces another view. + addEventListener("pageshow", event => { + if (event.isTrusted) pageHidden = false; + const restored = event.persisted && event.isTrusted; + if (restored) analytics.prepareRestore(); + readChoice(); update(); + if (restored) { analytics.pageView(); onChange(); } + }, true); + document.addEventListener("visibilitychange", () => { readChoice(); update(); }); + readChoice(); + storageFailure ||= !canStore(); + update(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + fetch("/api/analytics-consent", { credentials: "omit", cache: "no-store", signal: controller.signal }) + .then(async response => { + if (!response.ok) return; + const value = await response.json(); + if (value?.schema_version === 1 && value.policy_version === policyVersion && ["opt_in", "notice_opt_out", "unknown"].includes(value.region_class)) region = value.region_class; + }) + .catch(() => {}) + .finally(() => { clearTimeout(timeout); regionReady = true; update(); }); +} diff --git a/scripts/docs-site/analytics-consent.test.mjs b/scripts/docs-site/analytics-consent.test.mjs new file mode 100644 index 0000000000..e9163984fd --- /dev/null +++ b/scripts/docs-site/analytics-consent.test.mjs @@ -0,0 +1,331 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { before, after, test } from "node:test"; +import { chromium } from "playwright"; +import { analyticsConsentHtml } from "./analytics-consent.mjs"; +import { siteJs } from "./site-js.mjs"; +import { siteCss } from "./site-css.mjs"; +import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; + +const origin = "https://docs.openclaw.ai"; +const sdk = process.env.DOCS_GA4_SDK_PATH ? fs.readFileSync(process.env.DOCS_GA4_SDK_PATH, "utf8") : ""; +const key = "openclaw.analytics.consent"; +let browser; +before(async () => { browser = await chromium.launch({ headless: true }); }); +after(async () => { await browser?.close(); }); + +function saved(analytics, overrides = {}) { + const now = Date.now(); + return { schema_version: 1, policy_version: "2026-10-02.v2", analytics, updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString(), ...overrides }; +} +async function openConsent(t, options = {}) { + const context = await browser.newContext({ serviceWorkers: "block", viewport: options.viewport || { width: 1280, height: 800 } }); + context.setDefaultTimeout(10000); + t.after(async () => { releaseRegion?.(); await context.close(); }); + const events = [], controls = [], sdkRequests = [], outbound = []; + let releaseRegion; + const regionWait = new Promise(resolve => { releaseRegion = resolve; }); + t.after(() => releaseRegion()); + await context.route("**/*", async route => { + const request = route.request(), url = new URL(request.url()); + if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { + sdkRequests.push(url.href); + return route.fulfill({ contentType: "text/javascript", body: sdk }); + } + if (/\/(?:g\/)?collect$/.test(url.pathname)) { + for (const line of (request.postData() || "").split("\n")) { + const fields = Object.fromEntries(new URLSearchParams([url.search.slice(1), line].filter(Boolean).join("&"))); + (fields.en ? events : controls).push({ ...fields, receivedAt: Date.now() }); + } + return route.fulfill({ status: 204 }); + } + if (url.origin !== origin) { outbound.push(url.href); return route.abort(); } + if (url.pathname === "/api/analytics-consent") { + if (options.delayRegion) await regionWait; + if (options.regionError) return route.fulfill({ status: 503 }); + return route.fulfill({ contentType: "application/json", body: JSON.stringify({ schema_version: 1, policy_version: options.policyVersion || "2026-10-02.v2", region_class: options.region || "unknown" }) }); + } + if (url.pathname === "/assets/" + webVitalsAssetName) return route.fulfill({ contentType: "text/javascript", body: webVitalsRuntime }); + if (url.pathname === "/form-complete") return route.fulfill({ contentType: "text/plain", body: "Accepted fixture submission" }); + if (url.pathname === "/assets/example.pdf") return route.fulfill({ contentType: "application/pdf", headers: { "Content-Disposition": "attachment; filename=example.pdf" }, body: "%PDF-1.4\n%%EOF\n" }); + if (url.pathname !== "/" && url.pathname !== "/second") return route.fulfill({ status: 404 }); + return route.fulfill({ contentType: "text/html", body: `Public ${url.pathname}

Public documentation

Next pageCatalogPublic referenceDownload
${analyticsConsentHtml()}` }); + }); + await context.addInitScript(({ key, choice, privacy, storageFailure, theme }) => { + // Seed once: same-origin form iframes/new tabs must never overwrite a later + // user denial with this fixture's starting preference. + if (choice !== undefined && localStorage.getItem(key) === null) localStorage.setItem(key, typeof choice === "string" ? choice : JSON.stringify(choice)); + localStorage.setItem("theme", theme || "dark"); + document.cookie = "baseline=keep; path=/"; + window.__gpc = privacy === "gpc"; + Object.defineProperty(navigator, "globalPrivacyControl", { get: () => window.__gpc }); + if (privacy === "dnt") Object.defineProperty(navigator, "doNotTrack", { value: "1" }); + if (storageFailure) { + const original = Storage.prototype[storageFailure]; + Storage.prototype[storageFailure] = function(name, ...args) { + if (name.startsWith(key)) throw new Error("Storage unavailable"); + return original.call(this, name, ...args); + }; + } + }, { key, choice: options.choice, privacy: options.privacy, storageFailure: options.storageFailure, theme: options.theme }); + const page = await context.newPage(); + if (options.clock) await page.clock.install(); + await page.goto(origin); + await page.waitForFunction(() => document.querySelector("[data-analytics-consent-status]").textContent.length > 0); + const queue = () => page.evaluate(() => (window.dataLayer || []).filter(entry => entry[0] === "event").map(entry => ({ name: entry[1], ...entry[2] }))); + return { page, context, queue, events, controls, sdkRequests, outbound, releaseRegion }; +} + +for (const region of ["opt_in", "unknown"]) test(`${region} waits for explicit choice and sends only the current page`, async t => { + const f = await openConsent(t, { region }); + await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); + assert.equal(f.sdkRequests.length, 0); + assert.equal(f.events.length + f.controls.length, 0); + assert.equal((await f.context.cookies()).filter(cookie => cookie.name.startsWith("_ga")).length, 0); + await f.page.getByRole("link", { name: "Next page" }).click(); + await f.page.waitForURL("**/second"); + assert.equal(f.sdkRequests.length, 0); + await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + assert.deepEqual((await f.queue()).filter(event => event.name === "page_view").map(event => event.page_location), [origin + "/second"]); + const commands = await f.page.evaluate(() => window.dataLayer.filter(entry => ["consent", "config"].includes(entry[0])).map(entry => Array.from(entry))); + assert.deepEqual(commands[0], ["consent", "default", { analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" }]); + assert.deepEqual(commands[1], ["consent", "update", { analytics_storage: "granted" }]); + assert.equal(commands[2][0], "config"); +}); + +test("notice/opt-out begins only after the trusted response and never fabricates explicit consent", async t => { + const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); + assert.equal(f.sdkRequests.length, 0); + f.releaseRegion(); + await f.page.getByRole("heading", { name: "Google Analytics on this site", exact: true }).waitFor(); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + assert.equal(await f.page.evaluate(key => localStorage.getItem(key), key), null); + assert.equal((await f.queue()).filter(event => event.name === "page_view").length, 1); +}); + +test("synthetic pageshow notifications never invent restored navigation views", async t => { + const f = await openConsent(t, { choice: saved("granted") }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + await f.page.evaluate(() => { + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: false })); + window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true })); + }); + assert.equal((await f.queue()).filter(event => event.name === "page_view").length, 1); +}); + +test("a late regional default cannot override an explicit decline", async t => { + const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + f.releaseRegion(); + await f.page.waitForTimeout(150); + assert.equal(f.sdkRequests.length, 0); + assert.equal((await f.queue()).length, 0); + assert.equal(JSON.parse(await f.page.evaluate(key => localStorage.getItem(key), key)).analytics, "denied"); +}); + +test("a notice dismissed before region resolution is shown before regional enablement", async t => { + const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Close Google Analytics choices", exact: true }).click(); + assert.equal(f.sdkRequests.length, 0); + f.releaseRegion(); + await f.page.getByRole("heading", { name: "Google Analytics on this site", exact: true }).waitFor(); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); +}); + +test("region timeout leaves analytics off until an explicit choice", async t => { + const f = await openConsent(t, { region: "notice_opt_out", delayRegion: true }); + await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); + assert.equal(f.sdkRequests.length, 0); + f.releaseRegion(); + await f.page.waitForTimeout(100); + assert.equal(f.sdkRequests.length, 0); +}); + +for (const [name, options] of [ + ["stored denial", { choice: saved("denied"), region: "notice_opt_out" }], + ["expired choice", { choice: saved("granted", { updated_at: "2020-01-01T00:00:00.000Z", expires_at: "2020-02-01T00:00:00.000Z" }) }], + ["future choice", { choice: saved("granted", { updated_at: "2099-01-01T00:00:00.000Z", expires_at: "2099-02-01T00:00:00.000Z" }) }], + ["old-v1 choice", { choice: saved("granted", { policy_version: "2026-10-02.v1" }), region: "notice_opt_out" }], + ["old-v1 denial", { choice: saved("denied", { policy_version: "2026-10-02.v1" }), region: "notice_opt_out" }], + ["wrong-version choice", { choice: saved("granted", { policy_version: "old" }), region: "notice_opt_out" }], + ["malformed choice", { choice: "malformed" }], + ["wrong-version region", { region: "notice_opt_out", policyVersion: "old" }], + ["failed region", { regionError: true }], + ["blocked reads", { region: "notice_opt_out", storageFailure: "getItem" }], + ["blocked writes", { region: "notice_opt_out", storageFailure: "setItem" }], + ["GPC", { choice: saved("granted"), privacy: "gpc", region: "notice_opt_out" }], + ["DNT", { choice: saved("granted"), privacy: "dnt", region: "notice_opt_out" }], +]) test(`${name} leaves the Google gate closed`, async t => { + const f = await openConsent(t, options); + await f.page.waitForTimeout(150); + assert.equal(f.sdkRequests.length, 0); + assert.equal((await f.queue()).length, 0); + assert.equal(f.events.length + f.controls.length, 0); +}); + +test("revocation stops native events and retains unrelated baseline cookies", async t => { + const f = await openConsent(t, { choice: saved("granted") }); + await f.page.evaluate(() => { + window.__noReload = true; + window.__eventCaptures = []; + const gtag = window.gtag; + // Observe entry timing without changing arguments, SDK, or transport. + window.gtag = function(...args) { + if (args[0] === "event") window.__eventCaptures.push({ event: args[1], content_id: args[2]?.content_id, at: Date.now(), denied: window["ga-disable-G-3SK7X2YLSJ"] === true }); + return gtag.apply(this, args); + }; + }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + if (sdk) await f.page.waitForTimeout(1600); + const before = f.events.length; + const cookies = (await f.context.cookies()).map(({ name, domain, path }) => ({ name, domain, path })); + if (sdk) t.diagnostic(`Observed cookie scopes: ${JSON.stringify(cookies)}`); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + const allowedCaptureAt = await f.page.evaluate(() => { window.gtag("event", "select_content", { content_type: "public_fixture", content_id: "allowed_pending" }); return Date.now(); }); + await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + const revokedAt = await f.page.evaluate(() => Date.now()); + const afterDecline = f.events.length; + const deniedAttemptAt = await f.page.evaluate(() => Date.now()); + await f.page.evaluate(() => { document.querySelector("#public-form").id = "DENIED_FORM_MARKER"; }); + await f.page.getByRole("textbox", { name: "Topic" }).fill("PRIVATE_REVOKED_VALUE"); + await f.page.getByRole("button", { name: "Submit", exact: true }).click(); + await f.page.evaluate(() => { + const link = document.createElement("a"); + link.href = "https://private.example/DENIED_URL_MARKER?q=PRIVATE_REVOKED_VALUE"; + link.target = "_blank"; + link.textContent = "Denied reference"; + document.querySelector(".doc").append(link); + }); + const deniedPopup = f.context.waitForEvent("page"); + await f.page.getByRole("link", { name: "Denied reference", exact: true }).click(); + await (await deniedPopup).close(); + await f.page.evaluate(() => { window.gtag("event", "select_content", { content_id: "DENIED_EVENT_MARKER" }); window.scrollTo(0, document.documentElement.scrollHeight); }); + await f.page.waitForTimeout(2100); + if (sdk) { + t.diagnostic(`Capture/denial chronology (ms relative to applied denial): allowed_pending=${allowedCaptureAt - revokedAt}, denied_actions=${deniedAttemptAt - revokedAt}; transport=${JSON.stringify(f.events.slice(before).map(event => ({ event: event.en, content_id: event["ep.content_id"], relative_ms: event.receivedAt - revokedAt })))}`); + const captures = await f.page.evaluate(() => window.__eventCaptures); + assert.ok(captures.filter(capture => capture.content_id !== "DENIED_EVENT_MARKER").every(capture => !capture.denied), "the app generates no new optional event while denied"); + t.diagnostic(`Custom capture chronology: ${JSON.stringify(captures.map(capture => ({ ...capture, at: capture.at - revokedAt })))}`); + assert.equal((await f.context.cookies()).filter(cookie => ["_ga", "_ga_3SK7X2YLSJ"].includes(cookie.name)).length, 0); + } + assert.ok((await f.context.cookies()).some(cookie => cookie.name === "baseline")); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true, "denied native actions cannot grant analytics"); + await f.page.getByRole("link", { name: "Next page" }).click(); + await f.page.waitForURL("**/second"); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); + await f.page.waitForTimeout(2100); + assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /PRIVATE_REVOKED_VALUE|DENIED_FORM_MARKER|DENIED_EVENT_MARKER|DENIED_URL_MARKER/); + assert.equal(await f.page.evaluate(() => window.__noReload), true, "consent changes preserve the document and its drafts"); + assert.deepEqual((await f.queue()).filter(event => event.name === "page_view").map(event => event.page_location), [origin + "/", origin + "/second"]); + t.diagnostic(`Google control requests observed separately: ${f.controls.length}`); +}); + +test("a successful denial propagates to other same-origin tabs", async t => { + const f = await openConsent(t, { choice: saved("granted") }); + const peer = await f.context.newPage(); + await peer.goto(origin); + await peer.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + await peer.waitForFunction(() => window["ga-disable-G-3SK7X2YLSJ"] === true); + await peer.evaluate(() => window.gtag("event", "select_content", { content_id: "DENIED_PEER_MARKER" })); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).click(); + await peer.waitForFunction(() => window["ga-disable-G-3SK7X2YLSJ"] === false); + await peer.waitForTimeout(2100); + assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /DENIED_PEER_MARKER/); +}); + +test("failed denial persistence remains denied without reloading or claiming a saved choice", async t => { + const f = await openConsent(t, { choice: saved("granted") }); + await f.page.getByRole("textbox", { name: "Topic" }).fill("UNSAVED_DRAFT_VALUE"); + await f.page.evaluate(key => { + window.__noReload = true; + const original = Storage.prototype.setItem; + Storage.prototype.setItem = function(name, ...values) { if (name === key) throw new Error("Storage unavailable"); return original.call(this, name, ...values); }; + }, key); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + assert.equal(await f.page.getByRole("textbox", { name: "Topic" }).inputValue(), "UNSAVED_DRAFT_VALUE"); + assert.equal(await f.page.evaluate(() => window.__noReload), true); + assert.equal(JSON.parse(await f.page.evaluate(key => localStorage.getItem(key), key)).analytics, "granted", "the old record was not overwritten"); + await f.page.getByText("Google Analytics is off in this tab, but your choice could not be saved. It may not carry over to another tab or visit.").waitFor(); +}); + +test("a newly enabled browser privacy signal overrides a stored allow", async t => { + const f = await openConsent(t, { choice: saved("granted") }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + await f.page.evaluate(() => { window.__gpc = true; window.dispatchEvent(new Event("focus")); }); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + assert.equal(await f.page.getByRole("button", { name: "Allow Google Analytics", exact: true }).isEnabled(), false); + await f.page.getByText("Google Analytics is off because your browser sends a privacy signal.").waitFor(); +}); + +test("an active opt-in tab expires its grant without reload or focus changes", async t => { + const f = await openConsent(t, { choice: saved("granted", { expires_at: new Date(Date.now() + 10000).toISOString() }), region: "opt_in", clock: true }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + await f.page.evaluate(() => { window.__noReload = true; }); + await f.page.clock.runFor(10500); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + assert.equal(await f.page.evaluate(() => window.__noReload), true); + await f.page.getByRole("heading", { name: "Optional Google Analytics", exact: true }).waitFor(); + const commands = await f.page.evaluate(() => window.dataLayer.filter(entry => entry[0] === "consent").map(entry => Array.from(entry))); + assert.deepEqual(commands.at(-1), ["consent", "update", { analytics_storage: "denied" }]); +}); + +test("180-day choice expiry reschedules safely past the browser timeout maximum", async t => { + const f = await openConsent(t, { choice: saved("granted"), region: "opt_in", clock: true }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + let remaining = await f.page.evaluate(key => Date.parse(JSON.parse(localStorage.getItem(key)).expires_at) - Date.now(), key); + // Playwright's own fast-forward call also has a signed-32-bit bound. + while (remaining > 2000) { + await f.page.clock.fastForward(Math.min(2000000000, remaining - 1000)); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false, "rescheduling does not expire a still-valid choice"); + remaining = await f.page.evaluate(key => Date.parse(JSON.parse(localStorage.getItem(key)).expires_at) - Date.now(), key); + } + await f.page.clock.runFor(1500); + assert.equal(await f.page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true, "expiry fires in the active tab at its deadline"); +}); + +test("the real SDK retains native enhanced events and native cross-domain identity", { skip: !sdk && "needs the coordinator-approved SDK" }, async t => { + const f = await openConsent(t, { choice: saved("granted") }); + for (let i = 0; i < 40 && !f.events.some(event => event.en === "page_view"); i++) await f.page.waitForTimeout(100); + await f.page.getByRole("textbox", { name: "Topic" }).fill("PRIVATE_PUBLIC_FORM_VALUE"); + await f.page.getByRole("button", { name: "Submit", exact: true }).click(); + await f.page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + await f.page.waitForTimeout(1200); + let popup = f.context.waitForEvent("page"); + await f.page.getByRole("link", { name: "Public reference", exact: true }).click(); + await (await popup).close(); + const download = f.page.waitForEvent("download"); + await f.page.getByRole("link", { name: "Download", exact: true }).click(); + await download; + popup = f.context.waitForEvent("page"); + await f.page.getByRole("link", { name: "Catalog", exact: true }).click(); + await (await popup).close(); + await f.page.waitForTimeout(2200); + const names = new Set(f.events.map(event => event.en)); + for (const name of ["page_view", "scroll", "click", "file_download", "form_start", "form_submit"]) assert.ok(names.has(name), `native ${name}`); + const decorated = f.outbound.find(url => url.startsWith("https://clawhub.ai/")); + assert.ok(new URL(decorated).searchParams.get("_gl"), "native linker decorates the cross-domain catalog journey"); + const docsCookie = (await f.context.cookies(origin)).find(cookie => cookie.name === "_ga"); + const aiCookie = (await f.context.cookies("https://openclaw.ai/")).find(cookie => cookie.name === "_ga"); + assert.ok(docsCookie && aiCookie && docsCookie.value === aiCookie.value, "native parent-domain identity reaches ai/docs/community"); + assert.doesNotMatch(JSON.stringify([...f.events, ...f.controls]), /PRIVATE_PUBLIC_FORM_VALUE/); + await f.page.getByRole("button", { name: "Google Analytics choices", exact: true }).click(); + await f.page.getByRole("button", { name: "Decline Google Analytics", exact: true }).click(); + await f.page.getByRole("link", { name: "Catalog", exact: true }).evaluate(link => { link.href = "https://clawhub.ai/catalog/public-after-denial"; }); + popup = f.context.waitForEvent("page"); + await f.page.getByRole("link", { name: "Catalog", exact: true }).click(); + await (await popup).close(); + const deniedJourney = f.outbound.find(url => url.startsWith("https://clawhub.ai/catalog/public-after-denial")); + assert.equal(new URL(deniedJourney).searchParams.has("_gl"), false, "a new denied journey does not forward analytics identity"); + t.diagnostic(`Native SDK event families: ${[...names].sort().join(", ")}; control requests: ${f.controls.length}`); +}); diff --git a/scripts/docs-site/analytics-events.mjs b/scripts/docs-site/analytics-events.mjs new file mode 100644 index 0000000000..ff56578fad --- /dev/null +++ b/scripts/docs-site/analytics-events.mjs @@ -0,0 +1,234 @@ +// Fixed semantic events only. Never observe field values, pointer movement, or +// keystrokes. Hooks below run at the owning operation's actual outcome boundary. +export function createDocsAnalyticsEvents(analytics) { + const canonicalHosts = new Set(["openclaw.ai", "docs.openclaw.ai", "community.openclaw.ai", "clawhub.ai", "www.openclaw.org", "openclaw.org"]); + const socialHosts = { "discord.com": "discord", "discord.gg": "discord", "www.reddit.com": "reddit", "x.com": "x", "www.youtube.com": "youtube", "www.instagram.com": "instagram", "www.linkedin.com": "linkedin" }; + const popupStates = new Map(); + let pagePath = ""; + let sectionObserver; + let sectionTimers = new Map(); + let readingTimer; + let scrollFrame; + let depths = new Set(); + const errorsByView = new WeakMap(); + let communitySeen = false; + let searchTimer; + let pendingSearch; + let vocabulary = new Set(); + let seenSections = new Set(); + let visibleSeconds = 0; + let readingMilestones = new Set(); + let diagramObserver; + let diagramDismissal = "navigation"; + + function uiLocation(node) { + for (const [selector, value] of [[".search-modal", "search_results"], [".community-invite", "community_invite"], [".docs-chat", "docs_assistant"], [".page-feedback", "page_feedback"], [".site-header", "header"], [".sidebar", "sidebar"], [".site-footer", "footer"], [".page-actions", "page_actions"], [".pager", "pager"], [".toc", "table_of_contents"]]) { + if (node?.closest(selector)) return value; + } + return "article"; + } + function contentId(node, type) { + const nodes = [...document.querySelectorAll(type === "code" ? ".doc .oc-code" : type === "prompt" ? ".doc .oc-prompt" : ".doc [data-heading-anchor]")]; + const target = type === "code" ? node?.closest(".oc-code") : type === "prompt" ? node?.closest(".oc-prompt") : node; + return pagePath + "#" + type + "-" + (Math.max(0, nodes.indexOf(target)) + 1); + } + function installChoice(label) { + const value = String(label || "").toLowerCase(); + const method = ["pnpm", "npm", "bun", "docker", "powershell", "curl"].find(method => value.includes(method)); + const install_platform = value.includes("windows") || value.includes("powershell") ? "windows" : value.includes("macos") ? "macos" : value.includes("linux") ? "linux" : undefined; + return { ...(method ? { method } : {}), ...(install_platform ? { install_platform } : {}) }; + } + function select(content_type, content_id, ui_location, extra = {}) { + analytics.event("select_content", { content_type, content_id, ui_location, ...extra }); + } + function artifactChoice(url) { + const extension = url.pathname.match(/\.(pdf|zip|tar|tgz|gz|dmg|pkg|exe|msi|deb|rpm)$/i)?.[1]?.toLowerCase(); + if (!extension) return null; + const install_platform = ["dmg", "pkg"].includes(extension) ? "macos" : ["exe", "msi"].includes(extension) ? "windows" : ["deb", "rpm"].includes(extension) ? "linux" : undefined; + const artifact_version = url.pathname.match(/\/releases\/download\/v?(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?)\//)?.[1]; + return { method: "download", ...(install_platform ? { install_platform } : {}), ...(artifact_version ? { artifact_version } : {}) }; + } + function copyContext(control) { + const type = control?.matches("[data-code-copy]") ? "code" : control?.matches("[data-prompt-copy]") ? "prompt" : control?.matches("[data-copy-page]") ? "page_markdown" : control?.matches("[data-heading-anchor]") ? "heading_link" : "chat_transcript"; + return { + content_type: type, + content_id: type === "chat_transcript" ? "docs_assistant" : type === "page_markdown" ? pagePath : contentId(control, type), + ui_location: uiLocation(control), + ...installChoice(control?.closest(".oc-code")?.dataset.codeLabel), + }; + } + function copy(metadata, success, captured) { + if (analytics.isCurrentCapture(captured)) analytics.event("copy_action", { ...metadata, action_result: success ? "success" : "error" }, captured.page); + } + function popup(popup_id, open, dismiss_method = "close_button", ui_location = "dialog") { + if (open && !analytics.context()) return; + if (popupStates.get(popup_id) === open) return; + const wasOpen = popupStates.get(popup_id); + popupStates.set(popup_id, open); + if (open) analytics.event("popup_view", { popup_id, ui_location }); + else if (wasOpen) analytics.event("popup_dismiss", { popup_id, dismiss_method, ui_location }); + } + function communityVisible(visible) { + if (!analytics.context()) return; + if (visible && !communitySeen) { + communitySeen = true; + popup("docs_community", true, "", "community_invite"); + } + } + function watchDiagram() { + // The renderer appends this singleton to document.body, outside the .main + // replaced by PJAX. Its observer remains valid across public navigation. + const overlay = document.querySelector("[data-mermaid-overlay]"); + if (!overlay) return; + const sync = () => { + const open = overlay.classList.contains("open"); + popup("docs_diagram", open, diagramDismissal, "article"); + if (!open) diagramDismissal = "navigation"; + }; + if (!diagramObserver) { + diagramObserver = new MutationObserver(sync); + diagramObserver.observe(overlay, { attributes: true, attributeFilter: ["class"] }); + } + sync(); + } + addEventListener("keydown", event => { + if (event.key === "Escape" && document.querySelector("[data-mermaid-overlay].open")) diagramDismissal = "escape"; + }, true); + function safeSearchTerm(query, items) { + const value = String(query).normalize("NFKC").toLowerCase().trim().replace(/\s+/g, " "); + if (!value || value.length > 80 || !/^[\p{L}\p{N} ._-]+$/u.test(value) || /\d{5}|[a-f\d]{20}/i.test(value)) return ""; + if (/\b(?:my|our|your)\s+(?:name|email|phone|address|password|token|secret|api key|credentials?)\b|\b(?:name|email|phone|password|token|secret|api key)\s+is\b|\bi am\b/i.test(value)) return ""; + const allowed = new Set(vocabulary); + for (const item of items) for (const word of String(item.meta?.title || "").toLowerCase().match(/[\p{L}\p{N}]+/gu) || []) allowed.add(word); + const words = value.match(/[\p{L}\p{N}]+/gu) || []; + return words.length > 0 && words.length <= 8 && words.every(word => allowed.has(word)) ? value : ""; + } + function cancelSearch() { clearTimeout(searchTimer); pendingSearch = null; } + function flushSearch() { + clearTimeout(searchTimer); + if (!pendingSearch) return; + const { parameters, captured } = pendingSearch; + if (analytics.isCurrentCapture(captured)) analytics.event("search", parameters, captured.page); + pendingSearch = null; + } + function searchRendered(query, items, renderedCount, search_status, captured) { + cancelSearch(); + if (!analytics.isCurrentCapture(captured)) return; + const term = safeSearchTerm(query, items); + pendingSearch = { + captured, + parameters: { + ...(term ? { search_term: term } : {}), + search_status, + search_context: "docs", + search_filter: "none", + ...(search_status !== "error" && Number.isSafeInteger(renderedCount) && renderedCount >= 0 ? { result_count: renderedCount } : {}), + }, + }; + // Count a stable, actually rendered result set, not each input event. + searchTimer = setTimeout(flushSearch, 1200); + } + function formAttempt(form_id, ui_location) { analytics.event("form_attempt", { form_id, ui_location }); } + function clientError(error_type, error_code, page = analytics.context()) { + const key = error_type + ":" + error_code; + if (!page) return; + let errors = errorsByView.get(page); + if (!errors) { errors = new Set(); errorsByView.set(page, errors); } + if (errors.has(key) || errors.size >= 5) return; + if (analytics.event("client_error", { error_type, error_code, release: page.release }, page)) errors.add(key); + } + function scrollDepth(captured) { + scrollFrame = null; + if (!analytics.isCurrentCapture(captured)) return; + const documentHeight = document.documentElement.scrollHeight - innerHeight; + if (documentHeight <= 0 || scrollY <= 0) return; + const percentage = Math.min(100, 100 * scrollY / documentHeight); + for (const percent_scrolled of [25, 50, 75]) { + if (percentage >= percent_scrolled && !depths.has(percent_scrolled) && analytics.event("scroll_depth", { percent_scrolled })) depths.add(percent_scrolled); + } + } + addEventListener("scroll", () => { if (!scrollFrame && analytics.context()) { const captured = analytics.capture(); scrollFrame = requestAnimationFrame(() => scrollDepth(captured)); } }, { passive: true }); + analytics.onPage(page => { + pagePath = page.path; + if (!page.preserveView) { + depths = new Set(); communitySeen = false; seenSections = new Set(); + visibleSeconds = 0; readingMilestones = new Set(); + } + // These document-level overlays persist outside PJAX. Keep a measured + // opening until its real close; never queue an opening from a denied state. + cancelSearch(); clearInterval(readingTimer); sectionObserver?.disconnect(); + for (const timer of sectionTimers.values()) clearTimeout(timer); + sectionTimers = new Map(); + vocabulary = new Set("a an the how to do i use with and or for in on is not can my".split(" ")); + for (const node of document.querySelectorAll(".sidebar a,.sidebar summary,.doc h1,.doc h2,.doc h3")) { + for (const word of node.textContent.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []) vocabulary.add(word); + } + let sections = []; + try { sections = JSON.parse(page.main.dataset.analyticsSections || "[]"); } catch {} + if (typeof IntersectionObserver !== "undefined") { + sectionObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + const section_id = entry.target.id; + clearTimeout(sectionTimers.get(section_id)); + if (!entry.isIntersecting || entry.intersectionRatio < 0.5 || seenSections.has(section_id)) continue; + const captured = analytics.capture(); + sectionTimers.set(section_id, setTimeout(() => { + if (!analytics.isCurrentCapture(captured)) return; + const rect = entry.target.getBoundingClientRect(); + const visible = document.elementFromPoint(Math.min(innerWidth - 1, Math.max(0, rect.left + rect.width / 2)), Math.min(innerHeight - 1, Math.max(0, rect.top + rect.height / 2))); + if (document.visibilityState === "visible" && entry.target.contains(visible) && analytics.event("section_view", { section_id })) seenSections.add(section_id); + }, 1000)); + } + }, { threshold: [0, 0.5] }); + for (const id of sections) { + const heading = document.getElementById(id); + if (heading && page.main.contains(heading)) sectionObserver.observe(heading); + } + } + readingTimer = setInterval(() => { + if (!analytics.context() || document.visibilityState !== "visible" || document.querySelector(".search-modal.open,.docs-chat.open,[data-analytics-consent]:not([hidden])")) return; + visibleSeconds++; + for (const engagement_seconds of [30, 60]) { + if (visibleSeconds >= engagement_seconds && !readingMilestones.has(engagement_seconds) + && analytics.event("content_engagement", { engagement_seconds, content_type: "documentation", content_id: page.path })) readingMilestones.add(engagement_seconds); + } + if (readingMilestones.has(60)) clearInterval(readingTimer); + }, 1000); + }); + document.addEventListener("click", e => { + const target = e.target instanceof Element ? e.target : null; + if (!target || !analytics.context()) return; + if (target.closest("[data-mermaid-expand]")) { const captured = analytics.capture(); requestAnimationFrame(() => { if (analytics.isCurrentCapture(captured)) watchDiagram(); }); } + if (target.closest("[data-mermaid-overlay].open") && !target.closest("[data-mermaid-overlay-canvas]")) diagramDismissal = target.closest("[data-mermaid-overlay-close]") ? "close_button" : "backdrop"; + const tab = target.closest(".oc-code-tab"); + if (tab) { + const group = [...document.querySelectorAll(".doc .oc-code-group")].indexOf(tab.closest(".oc-code-group")) + 1; + select("code_tab", pagePath + "#code-group-" + group + "-tab-" + [...tab.parentElement.children].indexOf(tab), "article", installChoice(tab.textContent)); + return; + } + const link = target.closest("a[href]"); + if (!link) return; + const location = uiLocation(link); + if (location === "docs_assistant") { select("assistant_link", "reference", location); return; } + if (link.matches("[data-feedback-issue-link]")) { select("feedback_issue", "open_issue", location); return; } + if (!link.closest(".doc,.sidebar,.site-header,.site-footer,.page-actions,.pager,.toc,.community-invite,.search-modal")) return; + let url; + try { url = new URL(link.href); } catch { return; } + if (!/^https?:$/.test(url.protocol)) return; + const artifact = artifactChoice(url); + if (link.matches(".search-result")) flushSearch(); + if (socialHosts[url.hostname]) select("social_link", socialHosts[url.hostname], location); + else if (canonicalHosts.has(url.hostname) && !/\/ask-molty\/|\/__elements/i.test(url.pathname)) { + select(link.matches(".search-result") ? "search_result" : artifact ? "download" : url.hostname === "docs.openclaw.ai" ? "documentation" : "site_link", url.hostname === "docs.openclaw.ai" ? url.pathname : url.hostname + url.pathname, location, artifact || {}); + } else if (link.closest(".page-actions")) select("page_action", /chatgpt/.test(url.hostname) ? "ask_chatgpt" : /claude/.test(url.hostname) ? "ask_claude" : "view_markdown", location); + else if (link.closest(".doc,.site-footer,.site-header,.sidebar,.community-invite") && !url.username && !url.password) select(artifact ? "download" : "public_reference", url.origin + url.pathname, location, artifact || {}); + }); + addEventListener("error", event => { + const tag = event.target?.tagName; + if (tag && tag !== "WINDOW") clientError("resource", ({ IMG: "image_load", SCRIPT: "script_load", LINK: "style_load" })[tag] || "resource_load"); + else clientError("runtime", ({ TypeError: "type_error", ReferenceError: "reference_error", RangeError: "range_error", SyntaxError: "syntax_error" })[event.error?.name] || "runtime_error"); + }, true); + addEventListener("unhandledrejection", () => clientError("runtime", "unhandled_rejection")); + analytics.onError(clientError); + return { copyContext, copy, popup, communityVisible, select, formAttempt, searchRendered, cancelSearch, flushSearch, clientError }; +} diff --git a/scripts/docs-site/analytics-vitals.test.mjs b/scripts/docs-site/analytics-vitals.test.mjs new file mode 100644 index 0000000000..fb51d3f730 --- /dev/null +++ b/scripts/docs-site/analytics-vitals.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import vm from "node:vm"; +import test from "node:test"; +import { createDocsAnalytics } from "./analytics.mjs"; +import { createDocsAnalyticsEvents } from "./analytics-events.mjs"; + +const turn = () => new Promise(resolve => setImmediate(resolve)); +function harness({ delayed = false, withEvents = false } = {}) { + const callbacks = {}; + const library = Object.fromEntries(["LCP", "INP", "CLS"].map(name => ["on" + name, callback => { callbacks[name] = callback; }])); + let resolveLibrary; + let rejectLibrary; + const pending = new Promise((resolve, reject) => { resolveLibrary = resolve; rejectLibrary = reject; }); + let loads = 0; + const main = { dataset: { analyticsPath: "/", analyticsTitle: "Public docs", analyticsRelease: "fixture" } }; + const sandbox = { + location: new URL("https://docs.openclaw.ai/"), URL, URLSearchParams, Date, + navigator: {}, performance: { now: () => 1000 }, + document: { querySelector: () => main, querySelectorAll: () => [], addEventListener() {}, referrer: "", cookie: "", createElement: () => ({}), head: { append() {} } }, + history: { replaceState() {} }, + addEventListener() {}, clearTimeout() {}, clearInterval() {}, setInterval: () => 0, + loadVitals: () => { loads++; return delayed ? pending : Promise.resolve(library); }, + }; + sandbox.window = sandbox; + vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals);`, sandbox); + if (withEvents) vm.runInNewContext(`globalThis.telemetry=(${createDocsAnalyticsEvents.toString()})(analytics);`, sandbox); + const vitals = () => JSON.parse(JSON.stringify((sandbox.dataLayer || []).filter(entry => entry[0] === "event" && entry[1] === "web_vital").map(entry => entry[2]))); + const report = (name, id) => callbacks[name]?.({ name, id, value: name === "CLS" ? 0.04 : 100, rating: "good", navigationType: "navigate", entries: [{ startTime: 1500 }] }); + return { analytics: sandbox.analytics, sandbox, main, callbacks, vitals, report, resolve: () => resolveLibrary(library), reject: () => rejectLibrary(new Error("Unavailable fixture module")), loads: () => loads }; +} + +test("allowed document vitals keep typed values and their original public document context", async () => { + const h = harness(); + h.analytics.setConsent(true, 0); + await turn(); + h.report("LCP", "first"); h.report("LCP", "first"); + h.sandbox.location.pathname = "/next"; h.main.dataset.analyticsPath = "/next"; + h.analytics.pageView(); + h.report("INP", "second"); h.report("CLS", "third"); + assert.equal(h.vitals().length, 3); + for (const metric of h.vitals()) { + assert.equal(metric.page_location, "https://docs.openclaw.ai/"); + assert.equal(["lcp_ms", "inp_ms", "cls_score"].filter(key => Object.hasOwn(metric, key)).length, 1); + assert.equal(metric.metric_value, undefined); + } +}); + +for (const boundary of ["private", "history", "denial"]) test(`${boundary} permanently invalidates document metrics after public recovery`, async () => { + const h = harness(); + h.analytics.setConsent(true, 0); + await turn(); + h.report("LCP", "allowed"); + if (boundary === "private") h.analytics.hold("docs_assistant", true); + else if (boundary === "history") h.analytics.suspend(); + else h.analytics.setConsent(false); + h.report("INP", "during-boundary"); + if (boundary === "private") h.analytics.hold("docs_assistant", false); + else if (boundary === "history") h.analytics.pageView(); + else h.analytics.setConsent(true); + h.report("INP", "after-boundary"); h.report("CLS", "after-boundary"); + assert.equal(h.vitals().length, 1, "only the metric captured during the fully allowed interval survives"); +}); + +test("private interruption during the asynchronous import prevents observer registration", async () => { + const h = harness({ delayed: true }); + h.analytics.setConsent(true, 0); + assert.equal(h.loads(), 1); + h.analytics.hold("docs_assistant", true); + h.analytics.hold("docs_assistant", false); + h.resolve(); + await turn(); + assert.deepEqual(Object.keys(h.callbacks), []); + assert.deepEqual(h.vitals(), []); +}); + +test("late consent never reads buffered document metrics from an earlier denied interval", async () => { + const h = harness(); + h.analytics.setConsent(true); + await turn(); + assert.equal(h.loads(), 0); + assert.deepEqual(h.vitals(), []); + assert.ok(h.sandbox.dataLayer.some(entry => entry[1] === "page_view"), "current public traffic still works after a late grant"); +}); + +test("a delayed vitals import failure shares the original view's bounded error budget", async () => { + const h = harness({ delayed: true, withEvents: true }); + h.analytics.setConsent(true, 0); + for (const code of ["type_error", "reference_error", "range_error", "syntax_error", "unhandled_rejection"]) h.sandbox.telemetry.clientError("runtime", code); + h.sandbox.location.pathname = "/next"; h.main.dataset.analyticsPath = "/next"; + h.analytics.pageView(); + h.reject(); + await turn(); + h.sandbox.telemetry.clientError("runtime", "type_error"); + const errors = h.sandbox.dataLayer.filter(entry => entry[1] === "client_error").map(entry => entry[2]); + assert.equal(errors.filter(event => event.page_location === "https://docs.openclaw.ai/").length, 5); + assert.equal(errors.filter(event => event.page_location === "https://docs.openclaw.ai/next").length, 1); +}); diff --git a/scripts/docs-site/analytics.mjs b/scripts/docs-site/analytics.mjs new file mode 100644 index 0000000000..8e597a71d6 --- /dev/null +++ b/scripts/docs-site/analytics.mjs @@ -0,0 +1,194 @@ +// Serialized into the browser shell. All page identity comes from the renderer. +export function createDocsAnalytics(loadVitals) { + const origin = "https://docs.openclaw.ai"; + const measurementId = "G-3SK7X2YLSJ"; + const listeners = []; + const privateSurfaces = new Set(); + let previousLocation = ""; + let currentLocation = ""; + let currentPage = null; + let currentMain = null; + let lastCommittedPath = location.origin + (location.pathname.replace(/\/+$/, "") || "/"); + let unmeasuredNavigation = false; + let started = false; + let consentAllowed = false; + let metricsEligibleFrom = 0; + let documentMetricsInvalid = false; + let droppedHeldView = false; + let reportError; + let collectionEpoch = 0; + + function optedOut() { + return navigator.globalPrivacyControl === true || navigator.doNotTrack === "1" || window.doNotTrack === "1"; + } + function publicPage() { + if (location.origin !== origin || !consentAllowed || optedOut()) return null; + const main = document.querySelector(".main[data-analytics-path]"); + const path = main?.dataset.analyticsPath; + if (!path || !main.dataset.analyticsTitle || path !== (location.pathname.replace(/\/+$/, "") || "/")) return null; + return { main, path, title: main.dataset.analyticsTitle, release: main.dataset.analyticsRelease || "local" }; + } + function suspend() { + collectionEpoch++; + window["ga-disable-" + measurementId] = true; + // Document aggregates cannot separate a later private/denied interval. + // Never resume these metrics in this document, even after consent returns. + documentMetricsInvalid = true; + } + function context() { return !privateSurfaces.size && publicPage() && !window["ga-disable-" + measurementId] ? currentPage : null; } + // Async outcomes remain eligible only within the same continuously allowed + // interval. A later grant must not replay denied or pre-withdrawal work. + function capture() { return { page: context(), epoch: collectionEpoch }; } + function isCurrentCapture(value) { return Boolean(value?.page && value.epoch === collectionEpoch && context()); } + function hold(reason, active) { + if (active) { privateSurfaces.add(reason); suspend(); } + else if (privateSurfaces.delete(reason) && !privateSurfaces.size) { + const sendView = !droppedHeldView; + droppedHeldView = false; + pageView(sendView); + } + } + function prepareRestore() { suspend(); currentLocation = ""; } + function setConsent(allowed, eligibleFrom = performance.now()) { + const next = allowed === true && !optedOut(); + if (next === consentAllowed) return; + consentAllowed = next; + if (consentAllowed && !started) metricsEligibleFrom = eligibleFrom; + if (!consentAllowed) suspend(); + if (!consentAllowed && started) { + // These exact names, parent domain, and root path were observed with + // this native tag. Preserve other properties and unrelated cookies. + const present = new Set(document.cookie.split(";").map(cookie => cookie.trim().split("=")[0])); + for (const name of ["_ga", "_ga_3SK7X2YLSJ"]) { + if (!present.has(name)) continue; + document.cookie = name + "=; Max-Age=0; Path=/; SameSite=Lax"; + document.cookie = name + "=; Max-Age=0; Path=/; Domain=.openclaw.ai; SameSite=Lax"; + } + } + if (started) window.gtag("consent", "update", { analytics_storage: consentAllowed ? "granted" : "denied" }); + if (consentAllowed) pageView(); + } + function event(name, parameters = {}, page = context()) { + if (!page || !context() || !started) return false; + window.gtag("event", name, { ...parameters, ...page, send_to: measurementId }); + return true; + } + function safeCampaign(value) { + const text = String(value || "").trim(); + if (!text || text.length > 80 || !/^[\p{L}\p{N} _.-]+$/u.test(text) || /\d{7}|[a-f\d]{20}|token|password|secret/i.test(text)) return ""; + return text; + } + function externalReferrer() { + try { + const url = new URL(document.referrer); + if (!/^https?:$/.test(url.protocol) || !url.hostname.includes(".") || /^(?:\d+\.){3}\d+$/.test(url.hostname)) return ""; + // Retain referral attribution without paths, credentials, or query data. + return url.origin + "/"; + } catch { return ""; } + } + function startVitals(page) { + // Buffered/document aggregates cannot be reconstructed for a partial + // permission interval. A late grant starts traffic, but not document vitals. + if (!loadVitals || documentMetricsInvalid || metricsEligibleFrom > 0) return; + const sent = new Set(); + const metricPage = { ...page }; + loadVitals().then(vitals => { + if (documentMetricsInvalid) return; + const report = metric => { + if (documentMetricsInvalid) return; + if (!["LCP", "INP", "CLS"].includes(metric.name) || !Number.isFinite(metric.value) || metric.value < 0) return; + const key = metric.name + ":" + metric.id; + if (sent.has(key)) return; + if (event("web_vital", { + metric_name: metric.name, + [{ LCP: "lcp_ms", INP: "inp_ms", CLS: "cls_score" }[metric.name]]: Math.round(metric.value * 1000) / 1000, + metric_rating: metric.rating, + navigation_type: metric.navigationType, + release: page.release, + }, metricPage)) sent.add(key); + }; + // Document metrics belong to the initial document, not a later PJAX page. + vitals.onLCP(report); vitals.onINP(report); vitals.onCLS(report); + }).catch(() => { + if (!documentMetricsInvalid) reportError?.("resource", "vitals_unavailable", page); + }); + } + function pageView(sendView = true) { + const committedPath = location.origin + (location.pathname.replace(/\/+$/, "") || "/"); + if (committedPath !== lastCommittedPath && (!consentAllowed || privateSurfaces.size || optedOut())) unmeasuredNavigation = true; + lastCommittedPath = committedPath; + if (privateSurfaces.size) { + if (started && location.origin + (location.pathname.replace(/\/+$/, "") || "/") !== currentLocation) droppedHeldView = true; + suspend(); return; + } + if (!consentAllowed || optedOut()) { if (started) suspend(); return; } + const page = publicPage(); + if (!page) { + suspend(); + currentLocation = ""; + currentPage = null; + currentMain = null; + return; + } + // Docs search is the Pagefind dialog, not a URL-query route. Google's + // native search module reads these otherwise-unused values directly from + // location even when page_location is overridden. Keep campaign/linker and + // functional parameters intact; never disable search for the shared stream. + const publicUrl = new URL(location.href); + let cleaned = false; + for (const key of ["q", "s", "search", "query", "keyword"]) { + if (publicUrl.searchParams.has(key)) { publicUrl.searchParams.delete(key); cleaned = true; } + } + if (cleaned) history.replaceState(history.state, "", publicUrl.href); + window["ga-disable-" + measurementId] = false; + const canonical = origin + page.path; + const sameView = canonical === currentLocation && !unmeasuredNavigation; + if (sameView && page.main === currentMain) return; + const locationUrl = new URL(canonical); + const query = new URLSearchParams(location.search); + for (const key of ["utm_source", "utm_medium", "utm_campaign", "utm_id", "utm_content", "utm_term"]) { + const value = safeCampaign(query.get(key)); + if (value) locationUrl.searchParams.set(key, value); + } + const fields = { + page_location: locationUrl.href, + page_title: page.title, + page_referrer: sameView ? currentPage.page_referrer : previousLocation || externalReferrer(), + release: page.release, + }; + // A query-only render refreshes DOM bindings without resetting a view's + // event budgets; a denied A->B->A journey starts one current view on grant. + if (sameView) Object.assign(currentPage, fields); else currentPage = fields; + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function () { window.dataLayer.push(arguments); }; + if (!started) { + window.gtag("consent", "default", { analytics_storage: "denied", ad_storage: "denied", ad_user_data: "denied", ad_personalization: "denied" }); + window.gtag("consent", "update", { analytics_storage: "granted" }); + } + window.gtag("set", currentPage); + if (!started) { + started = true; + window.gtag("js", new Date()); + // Suppress automatic history pageviews at the shared Google stream; + // retain its other native Enhanced Measurement capabilities. + window.gtag("config", measurementId, { + send_page_view: false, + allow_google_signals: false, + allow_ad_personalization_signals: false, + }); + const script = document.createElement("script"); + script.async = true; + script.src = "https://www.googletagmanager.com/gtag/js?id=" + measurementId; + script.referrerPolicy = "no-referrer"; + document.head.append(script); + startVitals(currentPage); + } + if (sendView && !sameView) event("page_view"); + previousLocation = canonical; + currentLocation = canonical; + currentMain = page.main; + unmeasuredNavigation = false; + for (const listener of listeners) listener({ ...page, preserveView: sameView }); + } + return { pageView, suspend, hold, prepareRestore, setConsent, event, context, capture, isCurrentCapture, publicPage, onPage: listener => listeners.push(listener), onError: listener => { reportError = listener; } }; +} diff --git a/scripts/docs-site/analytics.test.mjs b/scripts/docs-site/analytics.test.mjs new file mode 100644 index 0000000000..ec26e1b42e --- /dev/null +++ b/scripts/docs-site/analytics.test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { chromium } from "playwright"; +import { parseDocument, DomUtils } from "htmlparser2"; +import { fixture, write } from "./test-helpers/redirect-fixture.mjs"; + +const origin = "https://docs.openclaw.ai"; +const sdkPath = process.env.DOCS_GA4_SDK_PATH; +const sdk = sdkPath ? fs.readFileSync(sdkPath, "utf8") : ""; + +function analyticsFixture(t, env = {}) { + const f = fixture(t, [], { + "index.md": "# Home\n\n[Guide](/guide)\n", + "guide.md": '# Guide\n\n[Home](/)\n\n[Section](#section)\n\n[Query](/guide?q=QUERY_SECRET)\n\n[German](/de/guide)\n\n## Section\n', + "de/guide.md": "# Leitfaden\n\n[Home](/)\n", + }); + write(f.root, "docs/docs.json", JSON.stringify({ + name: "Docs fixture", + navigation: { languages: ["en", "de"].map(language => ({ + language, tabs: [{ tab: "Docs", groups: [{ group: "Guide", pages: ["index", "guide"] }] }], + })) }, + })); + const built = f.build({ DOCS_SITE_GA4_ENABLED: "1", ...env }); + assert.equal(built.status, 0, built.stderr); + return { ...f, site: path.join(f.root, "dist/docs-site") }; +} + +test("only public production docs receive analytics metadata", t => { + const f = analyticsFixture(t); + for (const route of ["index.html", "guide/index.html", "de/guide/index.html"]) { + assert.match(fs.readFileSync(path.join(f.site, route), "utf8"), /data-analytics-path=/); + } + assert.doesNotMatch(fs.readFileSync(path.join(f.site, "__elements/index.html"), "utf8"), /data-analytics-path=/); + for (const env of [ + { DOCS_SITE_PREVIEW_MAX_PAGES: "3" }, + { DOCS_SITE_CANONICAL_ORIGIN: "https://preview.example" }, + { DOCS_SITE_BASE_PATH: "/preview" }, + { DOCS_SITE_GA4_ENABLED: "0" }, + ]) { + const preview = analyticsFixture(t, env); + const pages = fs.readdirSync(preview.site, { recursive: true }).filter(file => file.endsWith(".html")); + assert.ok(pages.length > 0); + for (const file of pages) assert.doesNotMatch(fs.readFileSync(path.join(preview.site, file), "utf8"), /data-analytics-path=/); + } +}); + +test("long documents expose no more than 24 public section measurement IDs", t => { + const f = fixture(t, [], { "long.md": "# Long\n\n" + Array.from({ length: 40 }, (_, i) => `## Section ${i + 1}\n\nPublic text.\n`).join("\n") }); + const built = f.build({ DOCS_SITE_GA4_ENABLED: "1" }); + assert.equal(built.status, 0, built.stderr); + const dom = parseDocument(fs.readFileSync(path.join(f.root, "dist/docs-site/long/index.html"), "utf8")); + const main = DomUtils.findOne(node => node.attribs?.["data-analytics-sections"], dom.children, true); + const sections = JSON.parse(main.attribs["data-analytics-sections"]); + assert.equal(sections.length, 24); + assert.equal(sections.at(-1), "section-24"); +}); + +test("native GA4 owns one sanitized view per committed docs navigation", { timeout: 60000 }, async t => { + const f = analyticsFixture(t); + const browser = await chromium.launch({ headless: true }); + t.after(async () => { releasePrivate?.(); await browser.close(); }); + const context = await browser.newContext({ serviceWorkers: "block" }); + await context.addInitScript(() => { + const now = Date.now(); + localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + }); + context.setDefaultTimeout(10_000); + const requests = []; + const sdkRequests = []; + let holdPrivate = false; + let releasePrivate; + let privateRequestStarted; + const privateRequest = new Promise(resolve => { privateRequestStarted = resolve; }); + t.after(() => releasePrivate?.()); + // Fail closed: even when testing the actual downloaded Google SDK, every + // browser request is fulfilled locally or aborted. No collection leaves CI. + await context.route("**/*", async route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { + sdkRequests.push(request.url()); + return route.fulfill({ contentType: "text/javascript", body: sdk }); + } + if (/\/(?:g\/)?collect$/.test(url.pathname)) { + const lines = (request.postData() || "").split("\n"); + for (const line of lines) { + requests.push(Object.fromEntries(new URLSearchParams([url.search.slice(1), line].filter(Boolean).join("&")))); + } + return route.fulfill({ status: 204 }); + } + if (["docs.openclaw.ai", "documentation.openclaw.ai", "preview.example", "localhost"].includes(url.hostname)) { + if (url.pathname === "/private/PRIVATE_URL_SECRET") { + if (holdPrivate) { + privateRequestStarted(); + await new Promise(resolve => { releasePrivate = resolve; }); + return route.fulfill({ status: 503, body: "Unavailable" }); + } + return route.fulfill({ + contentType: "text/html", + body: 'PRIVATE_TITLE_SECRET
Public
', + }); + } + let file = path.join(f.site, url.pathname); + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); + if (fs.existsSync(file) && fs.statSync(file).isFile()) { + const contentType = ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" })[path.extname(file)] || "application/octet-stream"; + return route.fulfill({ contentType, body: fs.readFileSync(file) }); + } + return route.fulfill({ status: 404, body: "Not found" }); + } + return route.abort(); + }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", error => errors.push(error.message)); + const queuedViews = () => page.evaluate(() => (window.dataLayer || []).filter(entry => entry[0] === "event" && entry[1] === "page_view").map(entry => entry[2])); + const waitForNativeViews = async count => { + if (!sdkPath) return; + for (let attempt = 0; attempt < 60 && requests.filter(request => request.en === "page_view").length < count; attempt++) await page.waitForTimeout(100); + assert.equal(requests.filter(request => request.en === "page_view").length, count, `settled public navigation ${count}`); + }; + const entry = new URL(`${origin}/?q=QUERY_SECRET&utm_campaign=CAMPAIGN_SECRET#HASH_SECRET`); + entry.searchParams.set("token", "example"); + await page.goto(entry.href, { referer: "https://example.com/private/REFERRER_SECRET?q=REFERRER_QUERY_SECRET" }); + await page.waitForFunction(() => window.dataLayer?.some(entry => entry[0] === "event")); + await waitForNativeViews(1); + await page.locator('.doc a[href="/guide"]').click(); + await page.waitForURL(`${origin}/guide`); + await waitForNativeViews(2); + await page.locator('.doc a[href="#section"]').click(); + await page.locator('.doc a[href*="QUERY_SECRET"]').click(); + await page.waitForURL(`${origin}/guide`); + assert.equal((await queuedViews()).length, 2, "hash and query changes are not pageviews"); + await page.locator('.doc a[href="/"]').click(); + await page.waitForURL(`${origin}/`); + await waitForNativeViews(3); + await page.goBack(); + await page.waitForURL(`${origin}/guide`); + await page.waitForFunction(() => document.querySelector('.doc a[href="/de/guide"]')); + await waitForNativeViews(4); + await page.goForward(); + await page.waitForURL(`${origin}/`); + await page.waitForFunction(() => document.querySelector('.doc a[href="/guide"]')); + await waitForNativeViews(5); + await page.locator('.doc a[href="/guide"]').click(); + await page.waitForURL(`${origin}/guide`); + await waitForNativeViews(6); + await page.locator('.doc a[href="/de/guide"]').click(); + await page.waitForURL(`${origin}/de/guide`); + await waitForNativeViews(7); + await page.evaluate(() => { + const link = document.createElement("a"); + link.href = "/private/PRIVATE_URL_SECRET"; + link.textContent = "Private"; + document.querySelector(".main").append(link); + }); + await page.getByRole("link", { name: "Private", exact: true }).click(); + await page.waitForURL("**/private/PRIVATE_URL_SECRET"); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + await page.evaluate(() => { + // Exercise the native SDK's opt-out, including its engagement path. + window.gtag("event", "user_engagement"); + }); + await page.waitForTimeout(500); + await page.getByRole("link", { name: "Public", exact: true }).click(); + await page.waitForURL(`${origin}/de/guide`); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false); + // Let the public request leave the SDK before deliberately suspending a + // subsequent history transition. Disabled transport is intentionally lost. + await waitForNativeViews(8); + // History changes the URL before PJAX can classify the destination. A slow + // or failed private response must not leave measurement enabled meanwhile. + holdPrivate = true; + const back = page.goBack(); + await page.waitForURL("**/private/PRIVATE_URL_SECRET"); + await Promise.race([privateRequest, page.waitForTimeout(5000).then(() => { throw new Error("Private history fetch did not start"); })]); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true); + await page.evaluate(() => window.gtag("event", "user_engagement")); + releasePrivate(); + await back; + await page.waitForTimeout(100); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), true, "failed history fetch remains suspended"); + await page.goForward(); + await page.waitForURL(`${origin}/de/guide`); + assert.equal(await page.evaluate(() => window["ga-disable-G-3SK7X2YLSJ"]), false, "restored public document resumes measurement"); + const views = await queuedViews(); + const paths = ["/", "/guide", "/", "/guide", "/", "/guide", "/de/guide", "/de/guide"]; + assert.deepEqual(views.map(view => view.page_location), paths.map(route => origin + route)); + assert.deepEqual(views.map(view => view.page_referrer), ["https://example.com/", ...paths.slice(0, -1).map(route => origin + route)]); + assert.match(views.at(-1).page_title, /Leitfaden/); + assert.equal(sdkRequests.length, 1, "PJAX never reloads the Google tag"); + await page.evaluate(() => { + const form = document.createElement("form"); + form.innerHTML = ''; + form.addEventListener("submit", event => event.preventDefault()); + document.body.append(form); + form.requestSubmit(); + history.replaceState({}, "", "?q=SEARCH_SECRET#HASH_SECRET"); + }); + if (sdkPath) { + // Google may batch transport; the ordinary CI run checks the native queue, + // and an opt-in SDK run additionally checks its real serialized requests. + for (let attempt = 0; attempt < 40 && requests.filter(request => request.en === "page_view").length < paths.length; attempt++) { + await page.waitForTimeout(250); + } + await page.waitForTimeout(1500); + assert.deepEqual(requests.filter(request => request.en === "page_view").map(request => request.dl), paths.map(route => origin + route)); + assert.ok(requests.every(request => request.tid === "G-3SK7X2YLSJ")); + const privateValues = /QUERY_SECRET|CAMPAIGN_SECRET|HASH_SECRET|REFERRER_SECRET|REFERRER_QUERY_SECRET|FORM_SECRET|SEARCH_SECRET|PRIVATE_URL_SECRET|PRIVATE_TITLE_SECRET/; + const violations = requests.flatMap(request => Object.entries(request).filter(([, value]) => privateValues.test(value)).map(([field, value]) => ({ event: request.en, field, value }))); + assert.deepEqual(violations, [], "no private value in any native/custom serialized field"); + t.diagnostic(`Actual Google SDK: ${requests.length} intercepted events, ${paths.length} pageviews; zero collection requests forwarded.`); + } + assert.equal((await queuedViews()).length, paths.length); + assert.deepEqual(errors, []); + for (const url of ["https://documentation.openclaw.ai/", "https://preview.example/", "http://localhost/", `${origin}/__elements/`, `${origin}/missing`, `${origin}/ask-molty/api/session`]) { + await page.goto(url); + assert.equal(await page.evaluate(() => (window.dataLayer || []).length), 0, url); + assert.equal(sdkRequests.length, 1, `no tag on ${url}`); + } +}); diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index dcfa29997c..4e9529edb0 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -16,6 +16,8 @@ import { ignoredDocDirs, ignoredDocFiles, localeFlags, localeLabels, navigationL import { walkDocs } from "./document-files.mjs"; import { siteCss } from "./site-css.mjs"; import { siteJs } from "./site-js.mjs"; +import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; +import { analyticsConsentHtml } from "./analytics-consent.mjs"; import { chromeStringsForLocale } from "./chrome-strings.mjs"; import { createMarkdownRenderer, renderMdxish, copyControlContent } from "./mdx-ish.mjs"; import { createRenderCache } from "./render-cache.mjs"; @@ -319,6 +321,8 @@ function layout({ page, nav, activeTab, html, toc, prev, next }) { const description = page.summary || config.description || ""; const ogTitle = page.slug === "index" ? `${config.name} Docs` : `${page.title} · ${config.name}`; const canonicalUrl = canonicalOrigin ? `${canonicalOrigin}${pageRoute(page)}` : ""; + const analyticsEligible = process.env.DOCS_SITE_GA4_ENABLED === "1" && !previewMode && !page.hidden && !basePath && canonicalOrigin === "https://docs.openclaw.ai"; + // tableOfContents already caps this shared UI/analytics heading list at 24. const pageOgVersion = page.locale === "en" ? pageOgVersions.get(page.slug) : undefined; const pageOgPath = pageOgVersion ? `/og/${page.slug}.png` @@ -360,7 +364,7 @@ ${canonicalUrl ? ` ${siteHeader(page)}
${sidebar(page, nav, activeTab)} -
+
${homeHeroArt}
${tocHtml(toc, page.locale)} @@ -380,7 +384,8 @@ ${pager(prev, next)}
${communityInvite(page.locale)} -${siteFooter()} +${siteFooter(analyticsEligible)} +${analyticsEligible ? analyticsConsentHtml() : ""} ${searchModal()} ${page.hidden ? "" : chatWidget()} @@ -407,7 +412,7 @@ function siteHeader(page) { `; } -function siteFooter() { +function siteFooter(analyticsEligible = false) { const home = { locale: "en", slug: "index", title: "Docs" }; const socials = [ ["X", "https://x.com/openclaw", "x-social"], @@ -433,7 +438,7 @@ function siteFooter() { ${groups.map(([title, links]) => ``).join("")}
- + `; } @@ -685,7 +690,7 @@ function pageFeedback(page) { const editLink = editUrl ? `Edit source` : ""; const pagePath = pageRoute(page); const canonicalUrl = `${docsOrigin()}${pagePath}`; - return `
Was this useful?
`; + return `
Was this useful?
`; } function raiseIssueUrl(page) { @@ -829,7 +834,7 @@ function chatWidget() {
Responses are generated using AI and may contain mistakes.
-
+
`; } @@ -866,6 +871,7 @@ function writeStaticAssets() { copyDir(path.join(siteAssetsDir, "fonts"), path.join(assetsDir, "fonts")); fs.writeFileSync(path.join(assetsDir, "docs-site.css"), shellCss, "utf8"); fs.writeFileSync(path.join(assetsDir, "docs-site.js"), shellJs, "utf8"); + fs.writeFileSync(path.join(assetsDir, webVitalsAssetName), webVitalsRuntime, "utf8"); const glimmDir = path.dirname(glimmEntry); fs.writeFileSync(path.join(assetsDir, glimmAssetName), glimmRuntime, "utf8"); fs.copyFileSync(path.join(glimmDir, "..", "LICENSE"), path.join(assetsDir, "glimm-LICENSE.txt")); diff --git a/scripts/docs-site/site-css.mjs b/scripts/docs-site/site-css.mjs index 3a7b550673..e05ae8fe1f 100644 --- a/scripts/docs-site/site-css.mjs +++ b/scripts/docs-site/site-css.mjs @@ -4,6 +4,7 @@ const releaseAnnouncementCss = fs.readFileSync(new URL("./release-announcement.c const maturityCss = fs.readFileSync(new URL("./maturity.css", import.meta.url), "utf8"); const docsLayoutCss = fs.readFileSync(new URL("./docs-layout.css", import.meta.url), "utf8"); const siteLayoutCss = fs.readFileSync(new URL("./site-layout.css", import.meta.url), "utf8"); +const analyticsConsentCss = fs.readFileSync(new URL("./analytics-consent.css", import.meta.url), "utf8"); const designSystemCss = [ "@openclaw/carapace/tokens.css", "@openclaw/carapace/themes.css", @@ -263,6 +264,7 @@ html{scroll-padding-top:calc(var(--sticky-header-h) + 54px)} @media(min-width:821px){.community-invite.community-invite{border-radius:calc(var(--oc-radius-surface) * var(--oc-corner-radius-scale));corner-shape:superellipse(1.5)}}} ${maturityCss} ${siteLayoutCss} +${analyticsConsentCss} ${docsLayoutCss} ${releaseAnnouncementCss} `; diff --git a/scripts/docs-site/site-js.mjs b/scripts/docs-site/site-js.mjs index 033a7105f2..50fd304526 100644 --- a/scripts/docs-site/site-js.mjs +++ b/scripts/docs-site/site-js.mjs @@ -3,6 +3,10 @@ import { initSidebarLevels } from "./sidebar-levels.mjs"; import { mountHomeHero } from "./home-hero.mjs"; import { createHomeGlimm } from "./home-glimm.mjs"; import { glimmAssetName } from "./glimm-runtime.mjs"; +import { createDocsAnalytics } from "./analytics.mjs"; +import { createDocsAnalyticsEvents } from "./analytics-events.mjs"; +import { webVitalsAssetName } from "./web-vitals-runtime.mjs"; +import { initDocsAnalyticsConsent } from "./analytics-consent.mjs"; // The emitted runtime also supports the classic-script navigation harness. const heroRuntime = fs.readFileSync(new URL("./hero-art.mjs", import.meta.url), "utf8").replace(/^export /gm, ""); @@ -14,6 +18,10 @@ ${heroRuntime} ${initSidebarLevels.toString()} ${mountHomeHero.toString()} ${createHomeGlimm.toString()} +const docsAnalytics=(${createDocsAnalytics.toString()})(()=>import(withBase(${JSON.stringify("/assets/" + webVitalsAssetName)}))); +const docsTelemetry=(${createDocsAnalyticsEvents.toString()})(docsAnalytics); +docsAnalytics.pageView(); +queueMicrotask(()=>(${initDocsAnalyticsConsent.toString()})(docsAnalytics,()=>{try{if(docsAnalytics.publicPage())currentDocKey=location.pathname+location.search;syncCommunityInvite()}catch{}})); const homeGlimm=createHomeGlimm(()=>import(withBase(${JSON.stringify("/assets/" + glimmAssetName)}))); let disposeHomeHero=()=>{};function initHomeHero(){disposeHomeHero();const disposeHero=mountOpenClawHeroArt(document.querySelector(".docs-hero"));const disposeBackground=mountHomeHero(document.querySelector(".home-hero"));disposeHomeHero=()=>{disposeHero();disposeBackground()}} const root=document.documentElement;const saved=localStorage.getItem("theme");root.dataset.theme=saved||"dark"; @@ -71,7 +79,7 @@ const room=rect?(narrow?innerHeight-rect.height:overlapsSidebar?floatingTop-side // Keep two touch-sized navigation rows below fixed controls, including in the RTL rail. const reservesNavigation=narrow||overlapsSidebar; const navChrome=reservesNavigation?[...document.querySelectorAll(".sidebar-head,.sidebar-tools,.docs-sidebar-back")].reduce((height,el)=>{const style=getComputedStyle(el);return height+el.getBoundingClientRect().height+parseFloat(style.marginTop)+parseFloat(style.marginBottom)},0):0; -const hide=Boolean(baseHide||room-navChrome<(reservesNavigation?88:160));card.hidden=hide; +const hide=Boolean(baseHide||room-navChrome<(reservesNavigation?88:160));card.hidden=hide;docsTelemetry.communityVisible(!hide&&getComputedStyle(card).visibility!=="hidden"); document.body.classList.toggle("has-community-invite",!hide); root.style.setProperty("--community-invite-h",hide?"0px":rect.height+"px"); root.style.setProperty("--sidebar-available-height",!hide&&overlapsSidebar?Math.max(0,room)+"px":"100dvh"); @@ -79,7 +87,7 @@ updateSidebarFade();syncTocSpace(); if(hide&&ownedFocus){const target=document.body.classList.contains("nav-open")?document.querySelector("[data-nav-close]"):document.querySelector(".brand");target?.focus({preventScroll:true})} } // Hide only once the write reads back, so a rejected write leaves the card on screen. -function dismissCommunityInvite(){const serialized=JSON.stringify({dismissedAtMs:Date.now()});try{localStorage.setItem(communityInviteKey,serialized);if(localStorage.getItem(communityInviteKey)!==serialized)return}catch{return}syncCommunityInvite()} +function dismissCommunityInvite(){const serialized=JSON.stringify({dismissedAtMs:Date.now()});try{localStorage.setItem(communityInviteKey,serialized);if(localStorage.getItem(communityInviteKey)!==serialized)return}catch{return}docsTelemetry.popup("docs_community",false,"close_button","community_invite");syncCommunityInvite()} function syncSidebar(nextDoc){const current=document.querySelector(".sidebar");const incoming=nextDoc.querySelector(".sidebar");if(!current||!incoming)return;const scroll=current.scrollTop;if(sameSidebarLinks(current,incoming)){for(const selector of ["a",".docs-section",".docs-section>summary"]){const next=[...incoming.querySelectorAll(selector)];current.querySelectorAll(selector).forEach((element,i)=>{element.className=next[i].className;const value=next[i].getAttribute("aria-current");if(value===null)element.removeAttribute("aria-current");else element.setAttribute("aria-current",value)})}current.scrollTop=scroll}else{const opened=new Set([...current.querySelectorAll(".docs-section[open]")].map(section=>section.dataset.docsSection));for(const section of incoming.querySelectorAll(".docs-section"))section.open=opened.has(section.dataset.docsSection);current.replaceWith(incoming);initSidebarLevels();syncHeaderSearchPosition()}document.querySelector(".docs-sidebar-levels")?.syncSection();scrollActiveNavLink()} // Compact layouts use a sticky disclosure in the reading pane; wide layouts keep the TOC rail. const compactTocQuery=matchMedia("(max-width:1280px)");function isCompactToc(){return compactTocQuery.matches}function syncStickyHeaderOffset(){const headerHeight=document.querySelector(".site-header")?.getBoundingClientRect().height||0;root.style.setProperty("--header-row-h",headerHeight+"px");const tocHeight=isCompactToc()?document.querySelector(".toc summary")?.getBoundingClientRect().height||0:0;root.style.setProperty("--sticky-header-h",Math.ceil(headerHeight+tocHeight)+"px");syncHeaderSurface();syncTocCurrentFade()} @@ -198,7 +206,7 @@ async function openMermaidOverlay(control){const block=control.closest("[data-me async function initMermaid(force=false){const blocks=[...document.querySelectorAll("[data-mermaid]")].filter(block=>force||!block.dataset.mermaidReady);if(!blocks.length)return;let i=0;for(const block of blocks){const source=block.dataset.mermaid||"";if(force)resetMermaidBlock(block,source);const renderId="oc_mermaid_"+Date.now()+"_"+i++;try{const {svg}=await renderMermaidSvg(source,renderId);block.innerHTML=mermaidExpandButton()+svg;block.dataset.mermaidReady="true";block.classList.remove("is-error");block.classList.add("is-rendered")}catch(err){removeMermaidRenderArtifact(renderId);resetMermaidBlock(block,source);block.dataset.mermaidReady="true";block.classList.add("is-error");block.insertAdjacentHTML("afterbegin",'
Diagram could not be rendered
')}}} function setCopyFeedback(control,state){if(!control)return;if(control.matches("[data-code-copy]")||control.matches("[data-prompt-copy]")||control.matches("[data-chat-copy]")||control.matches("[data-copy-page]")||control.matches("[data-heading-anchor]")){const label=control.dataset.copyLabel||control.getAttribute("aria-label")||"Copy";control.dataset.copyLabel=label;const feedback=control.querySelector("[data-copy-feedback]");const old=control.dataset.copyDefaultText||feedback?.textContent||label;control.dataset.copyDefaultText=old;clearTimeout(control._copyStateTimer);if(state==="copying"){control.dataset.copyState="copying";control.setAttribute("aria-label","Copying");if(feedback)feedback.textContent="Copying...";return}const ok=state==="copied";control.dataset.copyState=ok?"copied":"failed";control.setAttribute("aria-label",ok?"Copied":"Copy failed");if(control.hasAttribute("title"))control.title=ok?"Copied":"Copy failed";if(feedback)feedback.textContent=ok?"Copied":"Copy failed";control._copyStateTimer=setTimeout(()=>{delete control.dataset.copyState;control.setAttribute("aria-label",label);if(control.hasAttribute("title"))control.title=label;if(feedback)feedback.textContent=old},1300);return}const old=control.textContent;control.textContent=state==="copied"?"Copied":"Copy failed";setTimeout(()=>control.textContent=old||"Copy",1300)} function showCopyFeedback(control,ok){setCopyFeedback(control,ok?"copied":"failed")} -async function copyText(text,control){try{const value=String(text||"");if(!value.trim())throw new Error("Nothing to copy");let copied=false;if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(value);copied=true}catch{}}if(!copied){const area=document.createElement("textarea");area.value=value;area.setAttribute("readonly","");area.style.position="fixed";area.style.opacity="0";document.body.appendChild(area);area.select();copied=document.execCommand("copy");area.remove();if(!copied)throw new Error("Copy failed")}showCopyFeedback(control,true);return true}catch{showCopyFeedback(control,false);return false}} +async function copyText(text,control){const analyticsCapture=docsAnalytics.capture(),analyticsCopy=docsTelemetry.copyContext(control);try{const value=String(text||"");if(!value.trim())throw new Error("Nothing to copy");let copied=false;if(navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(value);copied=true}catch{}}if(!copied){const area=document.createElement("textarea");area.value=value;area.setAttribute("readonly","");area.style.position="fixed";area.style.opacity="0";document.body.appendChild(area);area.select();copied=document.execCommand("copy");area.remove();if(!copied)throw new Error("Copy failed")}showCopyFeedback(control,true);docsTelemetry.copy(analyticsCopy,true,analyticsCapture);return true}catch{showCopyFeedback(control,false);docsTelemetry.copy(analyticsCopy,false,analyticsCapture);return false}} function codeTextForCopy(block){const lines=[...block?.querySelectorAll(".code-line")||[]];if(lines.length)return lines.map(line=>line.textContent).join("\\n");return block?.querySelector("code")?.textContent||""} function pageMarkdownForCopy(control){const source=control.closest(".article")?.querySelector("[data-page-markdown]");if(!source)return"";try{return JSON.parse(source.textContent||'""')}catch{return source.textContent||""}} function copyPageMarkdown(control){return copyText(pageMarkdownForCopy(control),control)} @@ -209,10 +217,9 @@ function feedbackStorageKey(section){return feedbackStoragePrefix+feedbackPath(s function feedbackRepo(section){const repo=section?.dataset.feedbackRepo||"openclaw/openclaw";return /^[^/\\s]+\\/[^/\\s]+$/.test(repo)?repo:"openclaw/openclaw"} function feedbackPageUrl(section){return section?.dataset.feedbackUrl||location.href} function feedbackIssueUrl(section){const path=feedbackPath(section);const url=new URL("https://github.com/"+feedbackRepo(section)+"/issues/new");const detail=section?.querySelector("[data-feedback-detail]")?.value.trim();const body=["Page: "+path,"URL: "+feedbackPageUrl(section),"",detail?"Missing or confusing:":"","",detail||""].filter((part,i)=>part||i<3).join("\\n");url.searchParams.set("title","Docs feedback: "+path);url.searchParams.set("body",body);return url.href} -function updateFeedbackIssueLink(section){const link=section?.querySelector("[data-feedback-issue-link]");if(link)link.href=feedbackIssueUrl(section)} -function setFeedbackState(section,value,{persist=false}={}){if(!section)return;const result=section.querySelector("[data-feedback-result]");const composer=section.querySelector("[data-feedback-composer]");section.querySelectorAll("[data-feedback-value]").forEach(button=>button.setAttribute("aria-pressed",String(button.dataset.feedbackValue===value)));if(result)result.value=value==="yes"?"Thanks.":"";if(composer)composer.hidden=value!=="no";if(value==="no")updateFeedbackIssueLink(section);if(persist)try{localStorage.setItem(feedbackStorageKey(section),value)}catch{}} -function initPageFeedback(){document.querySelectorAll(".page-feedback").forEach(section=>{if(section.dataset.feedbackReady)return;section.dataset.feedbackReady="true";const detail=section.querySelector("[data-feedback-detail]");detail?.addEventListener("input",()=>updateFeedbackIssueLink(section));updateFeedbackIssueLink(section);try{const saved=localStorage.getItem(feedbackStorageKey(section));if(saved==="yes"||saved==="no")setFeedbackState(section,saved)}catch{}})} -function handleDocsControlClick(e){if(e.target.closest("[data-mermaid-overlay].open")&&!e.target.closest("[data-mermaid-overlay-canvas]")){closeMermaidOverlay();return true}const expandMermaid=e.target.closest("[data-mermaid-expand]");if(expandMermaid){openMermaidOverlay(expandMermaid);return true}const copyCode=e.target.closest("[data-code-copy]");if(copyCode){copyText(codeTextForCopy(copyCode.closest(".oc-code")),copyCode);return true}const expandCode=e.target.closest("[data-code-expand]");if(expandCode){toggleCodeExpand(expandCode);return true}const copyPrompt=e.target.closest("[data-prompt-copy]");if(copyPrompt){const prompt=copyPrompt.closest(".oc-prompt")?.querySelector(".oc-prompt-content")?.innerText.trim()||"";copyText(prompt,copyPrompt);return true}const copyPage=e.target.closest("[data-copy-page]");if(copyPage){copyPageMarkdown(copyPage);return true}const headingAnchor=e.target.closest("[data-heading-anchor]");if(headingAnchor){const url=new URL(location.href);url.hash=headingAnchor.dataset.headingAnchor||"";copyText(url.href,headingAnchor);return true}const feedback=e.target.closest("[data-feedback-value]");if(feedback){setFeedbackState(feedback.closest(".page-feedback"),feedback.dataset.feedbackValue,{persist:true});return true}return false} +function setFeedbackState(section,value,{persist=false}={}){if(!section)return;const result=section.querySelector("[data-feedback-result]");const composer=section.querySelector("[data-feedback-composer]");section.querySelectorAll("[data-feedback-value]").forEach(button=>button.setAttribute("aria-pressed",String(button.dataset.feedbackValue===value)));if(result)result.value=value==="yes"?"Thanks.":"";if(composer)composer.hidden=value!=="no";if(persist)docsTelemetry.select("feedback_choice",value==="yes"?"helpful":"not_helpful","page_feedback");if(persist)try{localStorage.setItem(feedbackStorageKey(section),value)}catch{}} +function initPageFeedback(){document.querySelectorAll(".page-feedback").forEach(section=>{if(section.dataset.feedbackReady)return;section.dataset.feedbackReady="true";try{const saved=localStorage.getItem(feedbackStorageKey(section));if(saved==="yes"||saved==="no")setFeedbackState(section,saved)}catch{}})} +function handleDocsControlClick(e){if(e.target.closest("[data-mermaid-overlay].open")&&!e.target.closest("[data-mermaid-overlay-canvas]")){closeMermaidOverlay();return true}const expandMermaid=e.target.closest("[data-mermaid-expand]");if(expandMermaid){openMermaidOverlay(expandMermaid);return true}const copyCode=e.target.closest("[data-code-copy]");if(copyCode){copyText(codeTextForCopy(copyCode.closest(".oc-code")),copyCode);return true}const expandCode=e.target.closest("[data-code-expand]");if(expandCode){toggleCodeExpand(expandCode);return true}const copyPrompt=e.target.closest("[data-prompt-copy]");if(copyPrompt){const prompt=copyPrompt.closest(".oc-prompt")?.querySelector(".oc-prompt-content")?.innerText.trim()||"";copyText(prompt,copyPrompt);return true}const copyPage=e.target.closest("[data-copy-page]");if(copyPage){copyPageMarkdown(copyPage);return true}const headingAnchor=e.target.closest("[data-heading-anchor]");if(headingAnchor){const url=new URL(location.href);url.hash=headingAnchor.dataset.headingAnchor||"";copyText(url.href,headingAnchor);return true}const feedbackIssue=e.target.closest("[data-feedback-issue-link]");if(feedbackIssue){docsTelemetry.formAttempt("docs_feedback","page_feedback");window.open(feedbackIssueUrl(feedbackIssue.closest(".page-feedback")),"_blank","noopener,noreferrer");return true}const feedback=e.target.closest("[data-feedback-value]");if(feedback){setFeedbackState(feedback.closest(".page-feedback"),feedback.dataset.feedbackValue,{persist:true});return true}return false} const resolveDocsFragment=${resolveDocsFragment.toString()}; function fragmentId(hash){return resolveDocsFragment(hash,{has:id=>Boolean(document.getElementById(id))})??hash.slice(1)} function revealTarget(target){ @@ -248,13 +255,13 @@ current.textContent=label;current.title=label;syncTocCurrentFade(); function setActiveTocLink(id){const toc=document.querySelector(".toc");if(!toc)return;let active=null;toc.querySelectorAll('a[href^="#"]').forEach(link=>{const on=Boolean(id&&tocLinkId(link)===id);link.classList.toggle("active",on);if(on)active=link});syncTocCurrent(toc,active);if(active){const list=active.closest(".toc");if(list&&list.scrollHeight>list.clientHeight){const lr=list.getBoundingClientRect();if(!isCompactToc()&&lr.top>parseFloat(getComputedStyle(list).top)+1)return;const ar=active.getBoundingClientRect();if(ar.toplr.bottom)list.scrollTop+=ar.bottom-lr.bottom}}} function currentTocHeadingId(headings){const top=parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop)||120;const scroller=document.scrollingElement||document.documentElement;if(scroller.scrollTop+innerHeight>=scroller.scrollHeight-2)return headings.at(-1)?.id||"";let current=headings[0];for(const heading of headings){if(heading.getBoundingClientRect().top<=top+1)current=heading;else break}return current?.id||""} function initTocScrollspy(){tocObserver?.disconnect();tocObserver=null;if(tocScrollHandler){removeEventListener("scroll",tocScrollHandler);tocScrollHandler=null;}const toc=document.querySelector(".toc");if(!toc)return;const links=[...toc.querySelectorAll('a[href^="#"]')];const headings=[...document.querySelectorAll(".doc h2[id],.doc h3[id]")].filter(heading=>links.some(link=>tocLinkId(link)===heading.id));links.forEach(link=>link.classList.remove("active"));if(!headings.length)return;const sync=()=>{if(Date.now(){cancelAnimationFrame(tocScrollRaf);tocScrollRaf=requestAnimationFrame(sync)};addEventListener("scroll",tocScrollHandler,{passive:true});requestAnimationFrame(sync);if(!("IntersectionObserver" in window)){sync();return}tocObserver=new IntersectionObserver(sync,{rootMargin:"-120px 0px -70% 0px",threshold:[0,1]});headings.forEach(heading=>tocObserver.observe(heading))} -async function navigateTo(url,replace=false,animateHome=false){if(navigating)return false;navigating=true;if(!animateHome)homeGlimm.cancel();closeLanguage();closeMermaidOverlay();try{const res=await fetch(url.href,{credentials:"same-origin"});if(!res.ok||!res.headers.get("content-type")?.includes("text/html"))return false;const nextDoc=new DOMParser().parseFromString(await res.text(),"text/html");if(!nextDoc.querySelector(".main"))return false;const commit=()=>{disposeHomeHero();for(const name of ["docs-home","docs-home-layout"])document.body.classList.toggle(name,nextDoc.body.classList.contains(name));syncSidebar(nextDoc);swap(".header-left",nextDoc);swap(".main",nextDoc);initHomeHero();syncStickyHeaderOffset();syncTocDisclosure();initCodeGroups();initCodeFades();initAccordionMotion();initPageFeedback();initMermaid();document.title=nextDoc.title;history[replace?"replaceState":"pushState"]({docs:true},"",url.href);currentDocKey=url.pathname+url.search;setNavOpen(false);initTocScrollspy();scrollTarget(url.hash);syncCommunityInvite()};if(animateHome)await homeGlimm.run(commit);else commit();return true}catch{return false}finally{navigating=false}} +async function navigateTo(url,replace=false,animateHome=false){if(navigating)return false;navigating=true;if(!animateHome)homeGlimm.cancel();closeLanguage();closeMermaidOverlay();try{const res=await fetch(url.href,{credentials:"same-origin"});if(!res.ok||!res.headers.get("content-type")?.includes("text/html"))return false;const nextDoc=new DOMParser().parseFromString(await res.text(),"text/html");if(!nextDoc.querySelector(".main"))return false;const commit=()=>{disposeHomeHero();for(const name of ["docs-home","docs-home-layout"])document.body.classList.toggle(name,nextDoc.body.classList.contains(name));syncSidebar(nextDoc);swap(".header-left",nextDoc);swap(".main",nextDoc);initHomeHero();syncStickyHeaderOffset();syncTocDisclosure();initCodeGroups();initCodeFades();initAccordionMotion();initPageFeedback();initMermaid();document.title=nextDoc.title;history[replace?"replaceState":"pushState"]({docs:true},"",url.href);docsAnalytics.pageView();currentDocKey=location.pathname+location.search;setNavOpen(false);initTocScrollspy();scrollTarget(url.hash);syncCommunityInvite()};if(animateHome)await homeGlimm.run(commit);else commit();return true}catch{return false}finally{navigating=false}} function unavailableSearch(){return {unavailable:true,search:async()=>({results:[]})}} function loadPagefind(){if(pagefindReady)return pagefindReady;const src=withBase("/pagefind/pagefind.js")+(pagefindLoadAttempt?"?retry="+pagefindLoadAttempt:"");pagefindLoadAttempt+=1;pagefindReady=import(src).then(m=>m.init?.().then?.(()=>m)??m).catch(()=>{pagefindReady=null;return unavailableSearch()});return pagefindReady} let searchReturnFocus=null; -function openSearch(){if(!modal)return;closeLanguage();if(document.body.classList.contains("nav-open"))setNavOpen(false);if(!modal.classList.contains("open"))searchReturnFocus=document.activeElement;modal.classList.add("open");if(!searchQuery()&&results&&!results.innerHTML.trim())results.innerHTML=searchInitialHtml();setSearchChrome(searchQuery());setTimeout(()=>input?.focus(),0);loadPagefind()} -function clearSearch(){clearTimeout(searchTimer);seq+=1;searchActiveIndex=-1;setSearchLoading(false);if(input)input.value="";setSearchChrome("");if(results){results.classList.remove("is-loading");results.innerHTML=searchInitialHtml()}} -function closeSearch(){const wasOpen=modal?.classList.contains("open");modal?.classList.remove("open");clearSearch();if(wasOpen){let target=searchReturnFocus;if(!isUsableFocusTarget(target))target=document.querySelector("[data-search-open]");target?.focus({preventScroll:true})}searchReturnFocus=null} +function openSearch(){if(!modal)return;closeLanguage();if(document.body.classList.contains("nav-open"))setNavOpen(false);if(!modal.classList.contains("open")){searchReturnFocus=document.activeElement;docsTelemetry.popup("docs_search",true,"","search_dialog")}modal.classList.add("open");if(!searchQuery()&&results&&!results.innerHTML.trim())results.innerHTML=searchInitialHtml();setSearchChrome(searchQuery());setTimeout(()=>input?.focus(),0);loadPagefind()} +function clearSearch(){docsTelemetry.cancelSearch();clearTimeout(searchTimer);seq+=1;searchActiveIndex=-1;setSearchLoading(false);if(input)input.value="";setSearchChrome("");if(results){results.classList.remove("is-loading");results.innerHTML=searchInitialHtml()}} +function closeSearch(method="close_button"){const wasOpen=modal?.classList.contains("open");if(wasOpen){docsTelemetry.flushSearch();docsTelemetry.popup("docs_search",false,method,"search_dialog")}modal?.classList.remove("open");clearSearch();if(wasOpen){let target=searchReturnFocus;if(!isUsableFocusTarget(target))target=document.querySelector("[data-search-open]");target?.focus({preventScroll:true})}searchReturnFocus=null} function escapeHtml(text){return String(text).replace(/[&<>"']/g,ch=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[ch]))} function trimTrailingPunctuation(value){return value.replace(/[.,;!?]+$/,"")} function compactGithubLinks(text){return String(text).replace(/(^|[\\s>])https:\\/\\/github\\.com\\/openclaw\\/openclaw\\/issues\\/(\\d+)\\/?(?=[\\s).,;!?]|$)/g,(_,prefix,number)=>prefix+"[Issue #"+number+"](https://github.com/openclaw/openclaw/issues/"+number+")").replace(/(^|[\\s>])https:\\/\\/github\\.com\\/openclaw\\/openclaw\\/pull\\/(\\d+)\\/?(?=[\\s).,;!?]|$)/g,(_,prefix,number)=>prefix+"[PR #"+number+"](https://github.com/openclaw/openclaw/pull/"+number+")").replace(/(^|[\\s>])https:\\/\\/github\\.com\\/openclaw\\/openclaw\\/commit\\/([0-9a-f]{7,40})\\/?(?=[\\s).,;!?]|$)/gi,(_,prefix,sha)=>prefix+"[commit "+sha.slice(0,7)+"](https://github.com/openclaw/openclaw/commit/"+sha+")").replace(/(^|[\\s>])https:\\/\\/github\\.com\\/openclaw\\/openclaw\\/blob\\/([0-9a-f]{7,40})\\/([^\\s)]+)/gi,(_,prefix,sha,path)=>{const cleanPath=trimTrailingPunctuation(path);const suffix=path.slice(cleanPath.length);const label=decodeURIComponent(cleanPath).replace(/#L(\\d+)(?:-L(\\d+))?$/,(_line,from,to)=>to?":L"+from+"-L"+to:":L"+from);return prefix+"["+label+"](https://github.com/openclaw/openclaw/blob/"+sha+"/"+cleanPath+")"+suffix})} @@ -295,7 +302,7 @@ const setAuthState=state=>{authState=state;chat.dataset.chatAuthState=state;if(a const ensureAuth=async()=>{if(authState==="ready")return true;if(authPromise)return authPromise;setAuthState("checking");authPromise=(async()=>{const api=window.OPENCLAW_DOCS_CHAT_API;if(!api)throw new Error("AUTH_REQUIRED");try{const res=await fetch(api,{method:"GET",credentials:"same-origin",redirect:"manual",headers:{"Accept":"application/json"}});if(res.type==="opaqueredirect"||res.redirected||res.status===0||res.status===401||res.status===403)throw new Error("AUTH_REQUIRED");if(!res.ok)throw new Error("AUTH_REQUIRED");setAuthState("ready");setTimeout(()=>input.focus(),0);return true}catch(err){setAuthState("required");return false}finally{authPromise=null}})();return authPromise}; const chatBaseRight=()=>window.matchMedia("(max-width:820px)").matches?14:18;let chatPanelRight=chatBaseRight();const chatTargetWidth=expanded=>Math.min(expanded?760:420,window.innerWidth-28);const clampChatRight=(right,width=chat.getBoundingClientRect().width)=>{const pad=14;return Math.min(Math.max(pad,right),Math.max(pad,window.innerWidth-width-pad))};const applyChatRight=(right,width)=>{chatPanelRight=clampChatRight(right,width);chat.style.left="";chat.style.right=chatPanelRight+"px"};if(head){let dragStart=null;head.addEventListener("pointerdown",e=>{if(e.button!==0||e.target.closest("button,a,input,textarea"))return;const rect=chat.getBoundingClientRect();dragStart={pointerX:e.clientX,left:rect.left,width:rect.width,dragging:false};head.setPointerCapture?.(e.pointerId)});head.addEventListener("pointermove",e=>{if(!dragStart)return;const delta=e.clientX-dragStart.pointerX;if(!dragStart.dragging&&Math.abs(delta)<6)return;if(!dragStart.dragging){dragStart.dragging=true;chat.classList.add("dragging")}const left=Math.min(Math.max(14,dragStart.left+delta),Math.max(14,window.innerWidth-dragStart.width-14));applyChatRight(window.innerWidth-left-dragStart.width,dragStart.width);e.preventDefault()});const stopDrag=e=>{if(!dragStart)return;if(dragStart.dragging)applyChatRight(chatPanelRight,dragStart.width);chat.classList.remove("dragging");head.releasePointerCapture?.(e.pointerId);dragStart=null};head.addEventListener("pointerup",stopDrag);head.addEventListener("pointercancel",stopDrag);head.addEventListener("dblclick",e=>{if(e.target.closest("button,a,input,textarea"))return;setExpanded(!chat.classList.contains("expanded"))})} const maximizeIcon='';const collapseIcon='';const keepChatInViewport=()=>{if(!chat.classList.contains("open")){chatPanelRight=chatBaseRight();chat.style.left="";chat.style.right=chatPanelRight+"px";return}applyChatRight(chatPanelRight,chat.getBoundingClientRect().width)};const setExpanded=expanded=>{const targetWidth=chatTargetWidth(expanded);applyChatRight(chatPanelRight,targetWidth);chat.classList.toggle("expanded",expanded);if(maximize){maximize.setAttribute("aria-pressed",String(expanded));maximize.setAttribute("aria-label",expanded?"Shrink":"Expand");maximize.innerHTML=expanded?collapseIcon:maximizeIcon}};window.addEventListener("resize",()=>keepChatInViewport()); -let resetChatTimer=0;const resetChatLauncherPosition=()=>{chatPanelRight=chatBaseRight();chat.style.left="";chat.style.right=chatPanelRight+"px";chat.classList.remove("closing")};const setOpen=open=>{clearTimeout(resetChatTimer);chat.classList.toggle("open",open);syncCommunityInvite();if(open){chat.classList.remove("closing");applyChatRight(chatPanelRight,chat.getBoundingClientRect().width)}else{chat.classList.add("closing");resetChatTimer=setTimeout(resetChatLauncherPosition,220)}panel?.toggleAttribute("inert",!open);panel?.setAttribute("aria-hidden",String(!open));document.querySelector("[data-chat-toggle]")?.setAttribute("aria-expanded",String(open));if(open)ensureAuth().then(ok=>{if(ok)setTimeout(()=>input.focus(),0)})}; +let resetChatTimer=0;const resetChatLauncherPosition=()=>{chatPanelRight=chatBaseRight();chat.style.left="";chat.style.right=chatPanelRight+"px";chat.classList.remove("closing")};const setOpen=(open,method="toggle")=>{const changed=chat.classList.contains("open")!==open;if(open&&changed){docsTelemetry.popup("docs_assistant",true,"","docs_assistant");docsAnalytics.hold("docs_assistant",true)}clearTimeout(resetChatTimer);chat.classList.toggle("open",open);syncCommunityInvite();if(open){chat.classList.remove("closing");applyChatRight(chatPanelRight,chat.getBoundingClientRect().width)}else{chat.classList.add("closing");resetChatTimer=setTimeout(resetChatLauncherPosition,220)}panel?.toggleAttribute("inert",!open);panel?.setAttribute("aria-hidden",String(!open));document.querySelector("[data-chat-toggle]")?.setAttribute("aria-expanded",String(open));if(!open&&changed){docsAnalytics.hold("docs_assistant",false);docsTelemetry.popup("docs_assistant",false,method,"docs_assistant")}if(open)ensureAuth().then(ok=>{if(ok)setTimeout(()=>input.focus(),0)})}; const chatText=()=>[...document.querySelectorAll(".docs-chat-message")].filter(item=>!item.classList.contains("thinking")).map(item=>(item.classList.contains("user")?"You: ":"OpenClaw: ")+item.innerText.trim()).filter(Boolean).join("\\n\\n"); const chatErrorMessage=async res=>{const fallback="Docs agent returned "+res.status;if(res.status>=500)return fallback;try{const data=await res.clone().json();return typeof data?.error==="string"&&data.error.trim()?data.error.trim():fallback}catch{return fallback}}; const sendChatMessage=async(message,{echoUser=true}={})=>{const api=window.OPENCLAW_DOCS_CHAT_API;if(!api||!message)return;if(!await ensureAuth())return;lastMessage=message;setRetryEnabled();if(echoUser)addChatMessage("user",message);setChatClearVisible(true);const reply=addChatThinking();if(submit)submit.disabled=true;if(retry)retry.disabled=true;try{const res=await fetch(api,{method:"POST",headers:{"Content-Type":"application/json"},credentials:"same-origin",redirect:"manual",body:JSON.stringify({message,retrieval:"auto",confidenceThreshold:.3})});if(res.type==="opaqueredirect"||res.redirected||res.status===0||res.status===401||res.status===403)throw new Error("AUTH_REQUIRED");if(!res.ok)throw new Error(await chatErrorMessage(res));if(!res.body)throw new Error("Docs agent did not stream a response");const reader=res.body.getReader();const decoder=new TextDecoder();let raw="";while(true){const {done,value}=await reader.read();if(done)break;raw+=decoder.decode(value,{stream:true});if(reply&&raw.trim()){if(reply.classList.contains("thinking"))reply.classList.remove("thinking");reply.innerHTML=renderChatText(raw);reply.parentElement.scrollTop=reply.parentElement.scrollHeight}}if(!raw&&reply){reply.classList.remove("thinking");reply.innerHTML="

No response.

"}}catch(err){if(reply){const msg=isAuthFetchError(err)?"[Sign in with OpenClaw ID]("+chatSignInUrl()+"), then ask again.":chatFailureMessage(err?.message);reply.className="docs-chat-message error";reply.innerHTML=renderChatText(msg);if(isAuthFetchError(err))setAuthState("required")}}finally{if(submit)submit.disabled=authState!=="ready";setRetryEnabled();if(authState==="ready")input.focus()}}; @@ -303,8 +310,8 @@ const askMoltyQuestion=async message=>{const question=String(message||"").trim() window.OPENCLAW_DOCS_ASK_MOLTY=askMoltyQuestion; const resumeMolty=new URLSearchParams(location.search).get("molty_resume")==="1";const pendingMoltyQuestion=localStorage.getItem(pendingMoltyQuestionKey);if(resumeMolty&&pendingMoltyQuestion){const clean=new URL(location.href);clean.searchParams.delete("molty_resume");history.replaceState(history.state,"",clean.href);setTimeout(()=>askMoltyQuestion(pendingMoltyQuestion),0)} setAuthState("unknown"); -document.addEventListener("click",e=>{if(e.target.closest("[data-chat-maximize]")){if(!chat.classList.contains("open"))setOpen(true);setExpanded(!chat.classList.contains("expanded"));return}if(e.target.closest("[data-chat-toggle]")){setOpen(!chat.classList.contains("open"));return}if(e.target.closest("[data-chat-minimize]")){setOpen(false);return}if(e.target.closest("[data-chat-clear]")){clearChat();lastMessage="";setRetryEnabled();return}if(e.target.closest("[data-chat-copy]")){copyText(chatText(),copy);return}if(e.target.closest("[data-chat-retry]")){if(lastMessage)sendChatMessage(lastMessage,{echoUser:false});return}}); -document.addEventListener("keydown",e=>{if(e.key==="Escape"&&chat.classList.contains("open"))setOpen(false)}); +document.addEventListener("click",e=>{if(e.target.closest("[data-chat-maximize]")){if(!chat.classList.contains("open"))setOpen(true);setExpanded(!chat.classList.contains("expanded"));return}if(e.target.closest("[data-chat-toggle]")){setOpen(!chat.classList.contains("open"));return}if(e.target.closest("[data-chat-minimize]")){setOpen(false,"minimize");return}if(e.target.closest("[data-chat-clear]")){clearChat();lastMessage="";setRetryEnabled();return}if(e.target.closest("[data-chat-copy]")){copyText(chatText(),copy);return}if(e.target.closest("[data-chat-retry]")){if(lastMessage)sendChatMessage(lastMessage,{echoUser:false});return}}); +document.addEventListener("keydown",e=>{if(e.key==="Escape"&&chat.classList.contains("open"))setOpen(false,"escape")}); input.addEventListener("keydown",e=>{if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();form.requestSubmit()}}); form.addEventListener("submit",async e=>{e.preventDefault();if(authState!=="ready"){setAuthState(authState==="unknown"?"checking":authState);ensureAuth();return}const message=input.value.trim();if(!message)return;input.value="";await sendChatMessage(message)}); } @@ -320,9 +327,9 @@ scrollActiveNavLink(); initTocScrollspy(); if(location.hash)requestAnimationFrame(()=>scrollTarget(location.hash)); addEventListener("hashchange",()=>scrollTarget(location.hash)); -document.addEventListener("click",async e=>{const pageActions=e.target.closest(".page-actions");document.querySelectorAll(".page-actions-more[open]").forEach(menu=>{if(!pageActions?.contains(menu))menu.removeAttribute("open")});const toc=e.target.closest(".toc");document.querySelectorAll(".toc[open]").forEach(menu=>{if(isCompactToc()&&!toc?.contains(menu))menu.open=false});const tocLink=e.target.closest(".toc a");if(tocLink){tocSpyHoldUntil=Date.now()+900;setActiveTocLink(tocLinkId(tocLink));if(isCompactToc())tocLink.closest(".toc").open=false}if(handleDocsControlClick(e))return;const theme=e.target.closest("[data-theme-toggle]");if(theme){const next=root.dataset.theme==="dark"?"light":"dark";root.dataset.theme=next;localStorage.setItem("theme",next);initMermaid(true);return}const trigger=e.target.closest("[data-language-trigger]");if(trigger){e.stopPropagation();toggleLanguage();return}const picker=document.querySelector("[data-language-picker]");if(picker&&!picker.contains(e.target))closeLanguage();const navToggle=e.target.closest("[data-nav-toggle]");if(navToggle){setNavOpen(!document.body.classList.contains("nav-open"));return}if(e.target.closest("[data-nav-close]")){setNavOpen(false);return}if(e.target.closest("[data-community-invite-dismiss]")){dismissCommunityInvite();return}if(document.body.classList.contains("nav-open")&&!e.target.closest(".sidebar")){setNavOpen(false);return}if(e.target.closest("[data-search-open]")){openSearch();return}if(e.target.closest("[data-search-close]")){closeSearch();return}if(e.target.closest("[data-search-clear]")){clearSearch();input?.focus();return}const link=e.target.closest("a[href]");if(!link)return;if(link.closest("[data-language-picker]"))return;if(link.target||link.download||!isPlainLeftClick(e))return;const url=new URL(link.href,location.href);if(url.origin===location.origin&&url.pathname===location.pathname&&url.search===location.search&&url.hash){e.preventDefault();history.pushState({docs:true},"",url.href);scrollTarget(url.hash);return}if(!isDocsPage(url))return;e.preventDefault();closeSearch();const animateHome=e.detail>0&&link.matches(".site-header .brand")&&!document.body.classList.contains("docs-home");const ok=await navigateTo(url,false,animateHome);if(!ok)location.href=url.href}); -addEventListener("keydown",e=>{handleMermaidOverlayKeydown(e)},{capture:true});modal?.addEventListener("click",e=>{if(e.target===modal)closeSearch()});function isEditableTarget(target){return target instanceof Element&&!!target.closest("input,textarea,select,[contenteditable]")}function isSearchShortcutEvent(e){return (e.metaKey||e.ctrlKey)&&!e.altKey&&!e.shiftKey&&(e.code==="KeyK"||String(e.key).toLowerCase()==="k")}function searchFocusable(){return [...modal?.querySelectorAll("a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\\\"-1\\\"])")||[]].filter(el=>el.tabIndex>=0&&!el.hidden&&el.getAttribute("aria-hidden")!=="true"&&el.offsetParent!==null)}function trapSearchFocus(e){if(e.key!=="Tab"||!modal?.classList.contains("open"))return false;const items=searchFocusable();if(!items.length){e.preventDefault();input?.focus();return true}const first=items[0];const last=items[items.length-1];if(!modal.contains(document.activeElement)){e.preventDefault();first.focus();return true}if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();return true}if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();return true}return false}addEventListener("keydown",e=>{if(trapSearchFocus(e)||trapNavFocus(e)||handleLanguageKeydown(e))return;if(isSearchShortcutEvent(e)){if(!isEditableTarget(e.target)){e.preventDefault();e.stopPropagation();openSearch()}return}if(e.key==="Escape"){closeSearch();document.querySelectorAll(".page-actions-more[open]").forEach(menu=>menu.removeAttribute("open"));document.querySelectorAll(".toc[open]").forEach(menu=>{if(isCompactToc()){const restore=menu.contains(document.activeElement);menu.open=false;if(restore)menu.querySelector("summary")?.focus({preventScroll:true})}});closeLanguage();setNavOpen(false)}},{capture:true}); -addEventListener("popstate",()=>{closeMermaidOverlay();const key=location.pathname+location.search;if(key===currentDocKey){scrollTarget(location.hash);return}navigateTo(new URL(location.href),true)}); +document.addEventListener("click",async e=>{const pageActions=e.target.closest(".page-actions");document.querySelectorAll(".page-actions-more[open]").forEach(menu=>{if(!pageActions?.contains(menu))menu.removeAttribute("open")});const toc=e.target.closest(".toc");document.querySelectorAll(".toc[open]").forEach(menu=>{if(isCompactToc()&&!toc?.contains(menu))menu.open=false});const tocLink=e.target.closest(".toc a");if(tocLink){tocSpyHoldUntil=Date.now()+900;setActiveTocLink(tocLinkId(tocLink));if(isCompactToc())tocLink.closest(".toc").open=false}if(handleDocsControlClick(e))return;const theme=e.target.closest("[data-theme-toggle]");if(theme){const next=root.dataset.theme==="dark"?"light":"dark";root.dataset.theme=next;localStorage.setItem("theme",next);initMermaid(true);return}const trigger=e.target.closest("[data-language-trigger]");if(trigger){e.stopPropagation();toggleLanguage();return}const picker=document.querySelector("[data-language-picker]");if(picker&&!picker.contains(e.target))closeLanguage();const navToggle=e.target.closest("[data-nav-toggle]");if(navToggle){setNavOpen(!document.body.classList.contains("nav-open"));return}if(e.target.closest("[data-nav-close]")){setNavOpen(false);return}if(e.target.closest("[data-community-invite-dismiss]")){dismissCommunityInvite();return}if(document.body.classList.contains("nav-open")&&!e.target.closest(".sidebar")){setNavOpen(false);return}if(e.target.closest("[data-search-open]")){openSearch();return}if(e.target.closest("[data-search-close]")){closeSearch();return}if(e.target.closest("[data-search-clear]")){clearSearch();input?.focus();return}const link=e.target.closest("a[href]");if(!link)return;if(link.closest("[data-language-picker]"))return;if(link.target||link.download||!isPlainLeftClick(e))return;const url=new URL(link.href,location.href);if(url.origin===location.origin&&url.pathname===location.pathname&&url.search===location.search&&url.hash){e.preventDefault();history.pushState({docs:true},"",url.href);scrollTarget(url.hash);return}if(!isDocsPage(url))return;e.preventDefault();closeSearch("navigation");const animateHome=e.detail>0&&link.matches(".site-header .brand")&&!document.body.classList.contains("docs-home");const ok=await navigateTo(url,false,animateHome);if(!ok)location.href=url.href}); +addEventListener("keydown",e=>{handleMermaidOverlayKeydown(e)},{capture:true});modal?.addEventListener("click",e=>{if(e.target===modal)closeSearch("backdrop")});function isEditableTarget(target){return target instanceof Element&&!!target.closest("input,textarea,select,[contenteditable]")}function isSearchShortcutEvent(e){return (e.metaKey||e.ctrlKey)&&!e.altKey&&!e.shiftKey&&(e.code==="KeyK"||String(e.key).toLowerCase()==="k")}function searchFocusable(){return [...modal?.querySelectorAll("a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\\\"-1\\\"])")||[]].filter(el=>el.tabIndex>=0&&!el.hidden&&el.getAttribute("aria-hidden")!=="true"&&el.offsetParent!==null)}function trapSearchFocus(e){if(e.key!=="Tab"||!modal?.classList.contains("open"))return false;const items=searchFocusable();if(!items.length){e.preventDefault();input?.focus();return true}const first=items[0];const last=items[items.length-1];if(!modal.contains(document.activeElement)){e.preventDefault();first.focus();return true}if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();return true}if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();return true}return false}addEventListener("keydown",e=>{if(trapSearchFocus(e)||trapNavFocus(e)||handleLanguageKeydown(e))return;if(isSearchShortcutEvent(e)){if(!isEditableTarget(e.target)){e.preventDefault();e.stopPropagation();openSearch()}return}if(e.key==="Escape"){closeSearch("escape");document.querySelectorAll(".page-actions-more[open]").forEach(menu=>menu.removeAttribute("open"));document.querySelectorAll(".toc[open]").forEach(menu=>{if(isCompactToc()){const restore=menu.contains(document.activeElement);menu.open=false;if(restore)menu.querySelector("summary")?.focus({preventScroll:true})}});closeLanguage();setNavOpen(false)}},{capture:true}); +addEventListener("popstate",()=>{closeMermaidOverlay();const key=location.pathname+location.search;if(key===currentDocKey){docsAnalytics.pageView();scrollTarget(location.hash);return}docsAnalytics.suspend();navigateTo(new URL(location.href),true)}); document.addEventListener("toggle",e=>{const section=e.target;if(section?.matches?.(".nav-section,.nav-nested")){updateSidebarFade();return}if(!section?.matches?.(".docs-section")||section.closest(".docs-sidebar-levels"))return;updateSidebarFade();if(section.open)requestAnimationFrame(()=>{const summary=section.querySelector(":scope>summary");if(section.open&&isUsableFocusTarget(summary))summary.scrollIntoView({behavior:"instant",block:"nearest",inline:"nearest"})})},{capture:true}); document.addEventListener("scroll",e=>{if(e.target?.classList?.contains("sidebar")||e.target?.matches?.(".docs-sidebar-directory,.docs-sidebar-context > .docs-sections"))updateSidebarFade()},{capture:true,passive:true}); let tocScrollRaf=0;function scheduleTocDisclosure(){if(tocScrollRaf)return;tocScrollRaf=requestAnimationFrame(()=>{tocScrollRaf=0;syncTocDisclosure();syncCommunityInvite()})}addEventListener("scroll",scheduleTocDisclosure,{passive:true});addEventListener("scrollend",scheduleTocDisclosure,{passive:true}); @@ -356,14 +363,15 @@ function setActiveSearchResult(index){const options=searchOptions();if(!options. function searchResultHtml(item,index){const meta=item.meta||{};const metaParts=[meta.category,meta.section].filter(Boolean).map(part=>''+escapeSearchHtml(part)+'').join("");return ''+(metaParts?'
'+metaParts+'
':"")+''+escapeSearchHtml(meta.title||item.url)+'

'+(item.excerpt||"")+"

"} function searchEmptyHtml(q){const fallback=canAskMoltyFromSearch()?'

Try fewer words, or '+searchCommandIcon+searchEnterIcon+' to ask Molty.

':'

Try fewer words, a command, or the name of a channel.

';return '
No results found'+fallback+'
'} function renderSearchResults(items,q){searchActiveIndex=-1;if(!results)return;results.setAttribute("role",items.length?"listbox":"status");input?.setAttribute("aria-expanded",String(!!items.length));results.innerHTML=items.length?items.map(searchResultHtml).join(""):searchEmptyHtml(q);if(items.length)setActiveSearchResult(0)} -async function runSearch(q,id){if(!results)return;setSearchChrome(q);setSearchLoading(true);showSearchLoading();const pf=await loadPagefind();if(id!==seq)return;if(pf?.unavailable){setSearchLoading(false);searchActiveIndex=-1;results.innerHTML=searchUnavailableHtml();return}try{let search=await pf.search(q);const expanded=expandSearchQuery(q);if(!search.results.length&&expanded!==q)search=await pf.search(expanded);const items=await Promise.all(search.results.slice(0,12).map(r=>r.data()));if(id!==seq)return;setSearchLoading(false);renderSearchResults(items,q)}catch{if(id!==seq)return;setSearchLoading(false);searchActiveIndex=-1;results.innerHTML=searchUnavailableHtml()}} -function scheduleSearch(immediate=false){const q=searchQuery();const id=++seq;clearTimeout(searchTimer);searchActiveIndex=-1;setSearchChrome(q);if(!q){setSearchLoading(false);if(results)results.innerHTML=searchInitialHtml();return}setSearchLoading(true);showSearchLoading();const run=()=>runSearch(q,id);if(immediate)run();else searchTimer=setTimeout(run,240)} -async function askMoltyFromSearch(){if(!canAskMoltyFromSearch())return;const q=searchQuery();closeSearch();await window.OPENCLAW_DOCS_ASK_MOLTY(q)} +async function runSearch(q,id,analyticsCapture){if(!results)return;setSearchChrome(q);setSearchLoading(true);showSearchLoading();const pf=await loadPagefind();if(id!==seq)return;if(pf?.unavailable){if(docsAnalytics.isCurrentCapture(analyticsCapture))docsTelemetry.clientError("network","search_unavailable",analyticsCapture.page);docsTelemetry.searchRendered(q,[],undefined,"error",analyticsCapture);setSearchLoading(false);searchActiveIndex=-1;results.innerHTML=searchUnavailableHtml();return}try{let search=await pf.search(q);const expanded=expandSearchQuery(q);if(!search.results.length&&expanded!==q)search=await pf.search(expanded);const items=await Promise.all(search.results.slice(0,12).map(r=>r.data()));if(id!==seq)return;setSearchLoading(false);renderSearchResults(items,q);docsTelemetry.searchRendered(q,items,items.length,search.results.length>items.length?"partial":"complete",analyticsCapture)}catch{if(id!==seq)return;if(docsAnalytics.isCurrentCapture(analyticsCapture))docsTelemetry.clientError("network","search_failed",analyticsCapture.page);docsTelemetry.searchRendered(q,[],undefined,"error",analyticsCapture);setSearchLoading(false);searchActiveIndex=-1;results.innerHTML=searchUnavailableHtml()}} +function scheduleSearch(immediate=false){docsTelemetry.cancelSearch();const q=searchQuery();const id=++seq;clearTimeout(searchTimer);searchActiveIndex=-1;setSearchChrome(q);if(!q){setSearchLoading(false);if(results)results.innerHTML=searchInitialHtml();return}setSearchLoading(true);showSearchLoading();const analyticsCapture=docsAnalytics.capture();const run=()=>runSearch(q,id,analyticsCapture);if(immediate)run();else searchTimer=setTimeout(run,240)} +async function askMoltyFromSearch(){if(!canAskMoltyFromSearch())return;const q=searchQuery();closeSearch("switch_to_chat");await window.OPENCLAW_DOCS_ASK_MOLTY(q)} input?.addEventListener("input",()=>scheduleSearch(false)); input?.addEventListener("keydown",e=>{if(e.isComposing)return;if(e.key==="Enter"&&(e.metaKey||e.ctrlKey)){e.preventDefault();askMoltyFromSearch();return}if(e.key==="ArrowDown"){if(searchOptions().length){e.preventDefault();setActiveSearchResult(searchActiveIndex+1)}return}if(e.key==="ArrowUp"){if(searchOptions().length){e.preventDefault();setActiveSearchResult(searchActiveIndex-1)}return}if(e.key==="Enter"&&!e.shiftKey){const active=searchOptions()[searchActiveIndex];if(active){e.preventDefault();active.click()}}}); modal?.addEventListener("pointermove",e=>{const option=e.target.closest?.('[role="option"]');const index=searchOptions().indexOf(option);if(index>=0&&index!==searchActiveIndex)setActiveSearchResult(index)}); modal?.addEventListener("focusin",e=>{const option=e.target.closest?.('[role="option"]');const index=searchOptions().indexOf(option);if(index>=0)setActiveSearchResult(index)}); moltySearch?.addEventListener("click",()=>askMoltyFromSearch()); + document.addEventListener("click",e=>{const suggestion=e.target.closest("[data-search-suggestion]");if(!suggestion||!input)return;input.value=suggestion.dataset.searchSuggestion||"";input.focus();scheduleSearch(true)}); `; } diff --git a/scripts/docs-site/web-vitals-runtime.mjs b/scripts/docs-site/web-vitals-runtime.mjs new file mode 100644 index 0000000000..10b79b4c21 --- /dev/null +++ b/scripts/docs-site/web-vitals-runtime.mjs @@ -0,0 +1,7 @@ +import fs from "node:fs"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const webVitalsEntry = fileURLToPath(import.meta.resolve("web-vitals")); +export const webVitalsRuntime = fs.readFileSync(webVitalsEntry, "utf8"); +export const webVitalsAssetName = `web-vitals-${createHash("sha256").update(webVitalsRuntime).digest("hex").slice(0, 12)}.js`; diff --git a/workers/analytics-consent.test.mjs b/workers/analytics-consent.test.mjs new file mode 100644 index 0000000000..cb721b1381 --- /dev/null +++ b/workers/analytics-consent.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { analyticsRegion } from "./analytics-consent.ts"; +import router from "./docs-router.ts"; + +test("consent geography requires a validated platform ISO country", () => { + for (const country of "AT BE BG HR CY CZ DK EE FI FR DE GR HU IS IE IT LV LI LT LU MT NL NO PL PT RO SK SI ES SE GB CH AX GF GP MQ RE YT MF".split(" ")) assert.equal(analyticsRegion(country), "opt_in", country); + for (const country of ["US", "CA", "AU", "JP", "BR", "ZA", "NZ"]) assert.equal(analyticsRegion(country), "notice_opt_out", country); + for (const country of [undefined, null, "", "XX", "T1", "A1", "ZZ", "EU", "UK", "us", " US", {}, 1]) assert.equal(analyticsRegion(country), "unknown", String(country)); +}); + +test("existing Worker serves an uncached minimal consent decision without trusting spoofable headers", async () => { + const request = new Request("https://docs.openclaw.ai/api/analytics-consent", { headers: { "cf-ipcountry": "US", "x-vercel-ip-country": "US", "x-country": "US" } }); + let response = await router.fetch(request, {}, {}); + assert.deepEqual(await response.json(), { schema_version: 1, policy_version: "2026-10-02.v2", region_class: "unknown" }); + for (const country of ["DE", "US", "ZZ"]) { + const trusted = new Request(request); + Object.defineProperty(trusted, "cf", { value: { country } }); + response = await router.fetch(trusted, {}, {}); + assert.deepEqual(await response.json(), { schema_version: 1, policy_version: "2026-10-02.v2", region_class: analyticsRegion(country) }); + for (const header of ["Cache-Control", "CDN-Cache-Control", "Cloudflare-CDN-Cache-Control"]) assert.match(response.headers.get(header), /no-store/); + assert.equal(response.headers.get("Set-Cookie"), null); + } + response = await router.fetch(new Request(request, { method: "POST" }), {}, {}); + assert.equal(response.status, 405); + assert.equal(response.headers.get("Allow"), "GET"); +}); diff --git a/workers/analytics-consent.ts b/workers/analytics-consent.ts new file mode 100644 index 0000000000..3a0fa5e746 --- /dev/null +++ b/workers/analytics-consent.ts @@ -0,0 +1,33 @@ +const policyVersion = "2026-10-02.v2"; +const optInCountries = new Set("AT BE BG HR CY CZ DK EE FI FR DE GR HU IS IE IT LV LI LT LU MT NL NO PL PT RO SK SI ES SE GB CH AX GF GP MQ RE YT MF".split(" ")); +// ISO 3166-1 alpha-2. Cloudflare reserved/unknown values are deliberately absent. +const countries = new Set(`AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ +BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ +CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ +DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR +GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY +HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP +KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY +MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ +NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY +QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ +TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ +VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW`.split(/\s+/)); + +export function analyticsRegion(country: unknown): "opt_in" | "notice_opt_out" | "unknown" { + if (typeof country !== "string" || !countries.has(country)) return "unknown"; + return optInCountries.has(country) ? "opt_in" : "notice_opt_out"; +} + +export function analyticsConsentResponse(request: Request): Response { + const headers = { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "private, no-store", + "CDN-Cache-Control": "no-store", + "Cloudflare-CDN-Cache-Control": "no-store", + }; + if (request.method !== "GET") return new Response(null, { status: 405, headers: { ...headers, Allow: "GET" } }); + // cf is populated by the Worker platform. Never fall back to client headers. + const country = (request as Request & { cf?: { country?: unknown } }).cf?.country; + return new Response(JSON.stringify({ schema_version: 1, policy_version: policyVersion, region_class: analyticsRegion(country) }), { headers }); +} diff --git a/workers/docs-router.ts b/workers/docs-router.ts index 282094f51e..cafdabaef3 100644 --- a/workers/docs-router.ts +++ b/workers/docs-router.ts @@ -1,4 +1,5 @@ import { docsNotFoundHtml } from "./not-found.ts"; +import { analyticsConsentResponse } from "./analytics-consent.ts"; interface Env { DOCS_BUCKET?: R2Bucket; @@ -49,6 +50,8 @@ export default { return Response.redirect(url.toString(), 308); } + if (url.pathname === "/api/analytics-consent") return analyticsConsentResponse(request); + if (url.pathname === "/api/search") { if (request.method !== "GET" && request.method !== "HEAD") { return new Response("Method not allowed", { From 3428e455709555df4f94906df205a11e530a5ac5 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:23:11 -0600 Subject: [PATCH 2/2] test: verify analytics restoration and document rollout gates --- CLOUDFLARE.md | 21 +++ scripts/docs-site/ANALYTICS.md | 133 +++++++++++++++ scripts/docs-site/analytics-bfcache.test.mjs | 166 +++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 scripts/docs-site/ANALYTICS.md create mode 100644 scripts/docs-site/analytics-bfcache.test.mjs diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md index 8207fe4497..569691babb 100644 --- a/CLOUDFLARE.md +++ b/CLOUDFLARE.md @@ -45,6 +45,27 @@ Why a Worker still exists: - R2 object storage does not redirect non-root trailing slash docs paths to slashless paths. - R2 object storage cannot negotiate markdown from `Accept: text/markdown` without router logic. - The CLI search endpoint `/api/search` reads `docs-search.json` from R2 and needs Worker logic. +## Google Analytics + +The docs shell uses native Google tag `G-3SK7X2YLSJ` for OpenClaw Websites +property `557069374`, stream `15954198155`, on `https://docs.openclaw.ai` only. +The existing router provides uncached `GET /api/analytics-consent` from trusted +Cloudflare `request.cf.country`; browser-supplied geography headers are ignored. +No Google credentials or visitor geography belong in site artifacts. + +See [the event, privacy, consent, and verification contract](scripts/docs-site/ANALYTICS.md). +The shared regional policy governs a Basic Consent Mode gate and persistent +Google Analytics choices. Enhanced Measurement remains enabled; automatic +history pageviews are suppressed because the docs shell owns initial/PJAX views. +User-provided data and advertising features stay off. Existing server traffic +counts remain separate from these controls. + +Run `node --test scripts/docs-site/analytics*.test.mjs workers/analytics-consent.test.mjs`. +The optional `DOCS_GA4_SDK_PATH` points to a coordinator-approved public SDK file; +fixtures intercept every request without forwarding collection. Browser transport +is not provider ingestion. Production activation requires the coordinated release +GO after hosting, consent, actual-SDK, and repository checks. + ## Required Cloudflare Access Cloudflare account: diff --git a/scripts/docs-site/ANALYTICS.md b/scripts/docs-site/ANALYTICS.md new file mode 100644 index 0000000000..6ef5c4833c --- /dev/null +++ b/scripts/docs-site/ANALYTICS.md @@ -0,0 +1,133 @@ +# Docs analytics contract + +Scope: `https://docs.openclaw.ai`, public generated documentation in every locale. +Property `557069374`, stream `15954198155`, measurement ID `G-3SK7X2YLSJ`. +The site uses native `gtag.js`; existing hosting and analytics integrations remain intact. + +| Event | Owner and trigger | Additional fields / limits | +| --- | --- | --- | +| `page_view` | Shell: initial public page and completed PJAX navigation | Build-owned `page_location`, `page_title`; safe UTM values only; referrer origin on entry, prior canonical page thereafter. Hash/query-only changes do not add a view. Unsupported native-search URL parameters (`q/s/search/query/keyword`) are removed before tag startup/navigation; actual Pagefind search uses the filtered custom event. | +| `session_start`, `first_visit`, `user_engagement` | Native Google tag | No custom identity, cookie-domain override, or engagement-time inflation. | +| `scroll`, `click`, `file_download`, `video_start/progress/complete`, `view_search_results`, `form_start/submit` | Native Enhanced Measurement, when supported | Preserve the capabilities; shared automatic **history pageviews** must be off. Native form submission is an attempt, not verified success. Native video needs a supported embedded player; the current docs sources contain only a video code example, not a live supported player, so no watched-video outcome is claimed. | +| `scroll_depth` | Actual scrolling: once per 25/50/75% threshold per view | `percent_scrolled`; native 90% remains separate. | +| `section_view` | A build-allowlisted heading stays at least half visible for one second | `section_id`, once per view; first 24 TOC headings. | +| `content_engagement` | 30/60 visible seconds on public documentation, excluding open search/chat/consent panels | `engagement_seconds=30/60`, `content_type=documentation`, `content_id`. | +| `select_content` | Public docs/site links, curated references, search results, code tabs, feedback choices | `content_type`, `content_id`, `ui_location`; code tabs may add `method`, `install_platform`. Public editorial/resource handles are allowed; visitor identifiers are not. | +| `popup_view`, `popup_dismiss` | Search dialog, community invitation, diagram expansion, assistant shell | `popup_id`, `ui_location`, `dismiss_method` on dismissal. Layout occlusion is not a user dismissal. | +| `form_attempt` | Accessible feedback issue-launch button | `form_id=docs_feedback`, `ui_location=page_feedback`; no published-issue or lead claim. | +| `copy_action` | Clipboard API or fallback has resolved | `content_type`, stable `content_id`, `action_result=success/error`, `ui_location`; optional `method`, `install_platform`. Never copied text. | +| `search` | A custom Pagefind result set is visibly stable for 1.2 seconds, selected, or closed | `search_context=docs`, `search_filter=none`, `search_status=complete/partial/error`, actual rendered `result_count` on complete/partial results (top 12 of N is 12/partial); error omits count; `search_term` only when every word is in public navigation/heading/result-title vocabulary and passes sensitive-value checks. No input-event emission. | +| `web_vital` | Official `web-vitals` LCP/INP/CLS reporting | `metric_name`, exactly one of `lcp_ms`, `inp_ms`, `cls_score` (first two milliseconds; CLS unitless), `metric_rating`, `navigation_type`, `release`. One report per metric instance. Only a valid saved grant covering the document from its start permits document metrics. Any private/unclassified/denied interval permanently invalidates remaining metrics, including pending imports; a late grant does not read buffered metrics. Public-only PJAX keeps initial document context. | +| `client_error` | Runtime/resource/rejection categories and owned search failures | Fixed `error_type`, fixed `error_code`, `release`; at most five distinct categories per view. No exception strings, stacks, filenames, or input values. | + +All custom events carry safe public page context. `release` is the publishing +workflow's 12-character commit SHA, or `local` in local builds. Public static +content IDs can be high-cardinality: register them only when a report needs the +dimension. `result_count`, `lcp_ms`, `inp_ms`, and `cls_score` are metrics, not identifier dimensions. Zero results are derived only from `search_status=complete` with `result_count=0`. +Do not combine native outbound events with content selections as one interaction +total or sum distinct users across hosts. No docs event proves installation. + +## Privacy and consent + +Preview builds, other origins, the hidden component fixture, unknown/error/API +routes, and unclassified history transitions cannot emit. Back/forward navigation +suspends measurement before its asynchronous fetch. A trusted BFCache restore +refreshes the persisted choice in a capture-phase `pageshow` handler before the +native SDK, then uses the same owner/dedupe for one allowed restored view. Ordinary +initial `pageshow` adds none. An unmeasured A→B→A journey starts one current view on regrant; query-only DOM replacements rebind observers without another pageview or fresh event budgets. Document metrics stay invalid after a restore boundary. The tag keeps native +cross-domain/cookie behavior; configured Google linker behavior still requires +shared-SDK verification. + +The feedback issue launcher is a keyboard-accessible button. It constructs the +GitHub issue URL only for the actual launch, so private feedback never appears in +an analytics-observed `href`. This records an issue-launch attempt only; the docs +site cannot verify that an issue was published. + +Opening the private Ask Molty panel pauses the tag with its native measurement +opt-out flag. Closing it resumes only a classified public page. A navigation performed while the private panel was open is dropped rather than replayed at close; subsequent safe events use the current public context. Events while +paused are dropped, never replayed by a custom queue. Private conversations, +their form activity, copied transcripts, and arbitrary private outbound references +are therefore a deliberate measurement gap. Public documentation remains measured +for signed-in visitors. A session check is not a new login or signup. + +Google automatic user-provided-data collection is off. Google signals and +ad-personalization signals are also disabled locally. The approved regional +policy is `2026-10-02.v2`: EEA + GB + CH and AX/GF/GP/MQ/RE/YT/MF (39 codes) require opt-in; other validated countries +use notice/opt-out; unknown geography remains off without explicit permission. +The existing Worker classifies trusted `request.cf.country` and returns only the +region class from uncached `/api/analytics-consent`. It never uses client-supplied +country headers or exposes country/IP in the response. + +Frozen policy SHA-256: `96bb4b0abc931d856ce14aff0131320e2c8f520d0bb2632037f864fe79bd5e93`. Both client and endpoint require the v2 version. An old-version record is not absence: it stays off until a fresh explicit v2 choice, even in a notice/opt-out region. No-record visits retain regional defaults. + +The footer's **Google Analytics choices** control saves a versioned explicit +choice on this origin for 180 days in `openclaw.analytics.consent`. A region +default never creates an explicit record. DNT/GPC and explicit denial override +an allow; failed storage, failed/invalid region responses, expired records, and +unknown regions fail closed. Initial notice appears before a regional default is +enabled. Late responses cannot override an explicit choice. + +Basic Consent Mode holds the SDK until allowed. Global analytics/advertising +consent starts denied; only analytics storage is granted when allowed. Denial +closes the application gate and sets Google's measurement opt-out before the +consent update. Pre-choice/private actions are never replayed. Async copy/search outcomes and deferred visibility callbacks carry the original eligible interval; withdrawal or a private boundary invalidates them permanently. Already-consented queued/in-flight public batches may finish sending after denial; this is distinct from new denied-state collection. Persisted denial propagates to same-origin tabs. Failed persistence keeps this runtime denied and preserves drafts without claiming durable storage. Only the observed `_ga` and `_ga_3SK7X2YLSJ` cookies at the installation's host/parent root scope are removed; unrelated cookies remain. These controls +apply to Google Analytics only; existing server traffic counts remain separate. +No global legal-compliance claim is made. + +## Verification and limits + +`node --test scripts/docs-site/analytics*.test.mjs` covers generated public page +classification and isolated browser behavior. Set `DOCS_GA4_SDK_PATH` to an ignored +download of the **coordinator-approved fresh SDK** for actual SDK transport checks. +All browser requests in these fixtures are intercepted locally; collection is +never forwarded. Do not rerun against a known-bad SDK while Google settings change. + +Before activation, verify native event payloads, cross-domain links, acquisition, +private-panel suspension including delayed flush after closing, and one pageview +per intended navigation with that actual SDK. Browser transport is not provider +ingestion. Native provider reports may lag; cohort, percentile, and exploration +availability must be reported honestly. Heatmaps, replay, raw pointer/keystroke +recording, BigQuery export, advertising audiences, and User-ID are outside scope. + +## Frozen Google/lifecycle proof + +The coordinated public SDK captured at 2026-10-02 19:15:03 UTC has SHA-256 +`01593a3bbcaed6a588925395731d5ec98b3304af3c220aa2a01db00cb51119e7`. +Admin/API and that SDK verified history measurement off while useful native +Enhanced Measurement stays on. Intercepted tests verify eight settled public +navigations produce eight pageviews; this is an external activation prerequisite, +not an assumption that `send_page_view: false` disables history measurement. + +`analytics-bfcache.test.mjs` uses full managed Chromium headless (`channel: +"chromium"`) without Playwright's `--disable-back-forward-cache` default. The +headless-shell delegate cannot prove this lifecycle. The fixture traverses real +history using CDP, checks trusted `pageshow.persisted` plus retained document +state, and tests other-tab denial plus first-ever grant while cached (both no-choice and saved-deny starts). Positive activation is deferred only between trusted pagehide/pageshow; ordinary background tabs still activate. Run with +`DOCS_GA4_REQUIRE_BFCACHE=1` and the approved SDK file to require actual cache +restoration; ordinary CI also checks correct behavior if Chromium chooses reload. +No browser network request to Google is forwarded. No synthetic pageshow is used +as native lifecycle proof. + +## Two-stage deployment gate + +`DOCS_SITE_GA4_ENABLED` must equal `1` at build time to produce analytics page +metadata or choice controls. The existing R2 workflow reads this repository +variable with a default of `0`; CI explicitly builds an enabled artifact. No +Actions permission, required check, deployment queue, upload scope, or router +infrastructure changes are part of this gate. + +1. After closed-gate publishing authorization, verify/initialize the resolved + repository/environment setting to `0`, land the reviewed head through normal + checks, and let the existing R2/router workflow publish it. Verify the served + router/asset commit, `/api/analytics-consent` decision and no-store headers, + spoofed-country-header immunity, absent analytics page metadata, and no SDK + loading in a bounded browser check. +2. After separate activation authorization, set `DOCS_SITE_GA4_ENABLED=1` and + dispatch the existing R2 workflow with `artifact_scope=shell` at the reviewed + current main. Verify the actual served metadata/assets and repeat bounded + consent/SDK request checks. Coordinate provider ingestion with the Google + collector owner; transport alone is not ingestion proof. + +The browser privacy copy includes the approved withdrawal and configured +14-month retention addenda. Retention configuration is not a claim that the +provider's 24-hour change window has elapsed or that historical data was restored. diff --git a/scripts/docs-site/analytics-bfcache.test.mjs b/scripts/docs-site/analytics-bfcache.test.mjs new file mode 100644 index 0000000000..57710fdcbc --- /dev/null +++ b/scripts/docs-site/analytics-bfcache.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { before, after, test } from "node:test"; +import { chromium } from "playwright"; +import { siteJs } from "./site-js.mjs"; +import { analyticsConsentHtml } from "./analytics-consent.mjs"; +import { webVitalsAssetName, webVitalsRuntime } from "./web-vitals-runtime.mjs"; + +const origin = "https://docs.openclaw.ai"; +const sdk = process.env.DOCS_GA4_SDK_PATH ? fs.readFileSync(process.env.DOCS_GA4_SDK_PATH, "utf8") : ""; +const strictCache = process.env.DOCS_GA4_REQUIRE_BFCACHE === "1"; +let browser; +before(async () => { + // Headless-shell's delegate disables BFCache even without the command-line + // flag. Full managed Chromium's headless mode exercises the real lifecycle. + browser = await chromium.launch({ channel: "chromium", headless: true, ignoreDefaultArgs: ["--disable-back-forward-cache"] }); +}); +after(async () => { await browser?.close(); }); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function fixture(t, initialConsent = "granted") { + const context = await browser.newContext({ serviceWorkers: "block" }); + context.setDefaultTimeout(10000); + t.after(() => context.close()); + const events = [], rejected = []; + let sdkLoads = 0; + await context.addInitScript(({ initialConsent }) => { + window.__lifecycle = []; + addEventListener("pageshow", event => window.__lifecycle.push({ persisted: event.persisted, trusted: event.isTrusted })); + if (initialConsent && !localStorage.getItem("openclaw.analytics.consent")) { + const time = Date.now() - 1000; + localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: initialConsent, updated_at: new Date(time).toISOString(), expires_at: new Date(time + 180 * 86400000).toISOString() })); + } + }, { initialConsent }); + await context.route("**/*", async route => { + const request = route.request(), url = new URL(request.url()); + if (url.hostname === "www.googletagmanager.com" && url.pathname === "/gtag/js") { sdkLoads++; return route.fulfill({ contentType: "text/javascript", body: sdk }); } + if (/\/(?:g\/)?collect$/.test(url.pathname)) { + for (const line of (request.postData() || "").split(/\r?\n/)) events.push(Object.fromEntries(new URLSearchParams([url.search.slice(1), line].filter(Boolean).join("&")))); + return route.fulfill({ status: 204 }); + } + if (url.origin === "https://away.example" || url.href === origin + "/choice") return route.fulfill({ contentType: "text/html", headers: { "Cache-Control": "public, max-age=600" }, body: "Other document

Other document

" }); + if (url.origin !== origin) return route.abort(); + if (url.pathname === "/api/analytics-consent") return route.fulfill({ contentType: "application/json", headers: { "Cache-Control": "private, no-store" }, body: JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", region_class: "opt_in" }) }); + if (url.pathname === "/assets/" + webVitalsAssetName) return route.fulfill({ contentType: "text/javascript", body: webVitalsRuntime }); + if (url.pathname !== "/") return route.fulfill({ status: 404 }); + return route.fulfill({ contentType: "text/html", headers: { "Cache-Control": "public, max-age=600" }, body: `Public cache fixture

Public cache fixture

${analyticsConsentHtml()}` }); + }); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("Page.enable"); + cdp.on("Page.backForwardCacheNotUsed", event => rejected.push(event.notRestoredExplanations)); + await page.goto(origin); + if (initialConsent === "granted") await page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + else await page.waitForFunction(() => document.querySelector("[data-analytics-consent-status]")?.textContent.length > 0); + if (sdk && initialConsent === "granted") for (let i = 0; i < 70 && events.filter(event => event.en === "page_view").length < 1; i++) await delay(100); + await page.evaluate(() => { + window.__retained = true; + window.__afterSdkRestore = []; + addEventListener("pageshow", event => { + if (event.persisted) { + const disabled = window["ga-disable-G-3SK7X2YLSJ"] === true; + window.__afterSdkRestore.push({ disabled, trusted: event.isTrusted }); + if (disabled) window.gtag("event", "select_content", { content_id: "DENIED_BFCACHE_MARKER" }); + } + }); + }); + const state = async () => { + const response = await cdp.send("Runtime.evaluate", { expression: `JSON.stringify({href:location.href,lifecycle:window.__lifecycle,retained:window.__retained===true,disabled:window['ga-disable-G-3SK7X2YLSJ']===true,afterSdk:window.__afterSdkRestore||[],views:(window.dataLayer||[]).filter(entry=>entry[1]==='page_view').length})`, returnByValue: true }); + return JSON.parse(response.result.value); + }; + const restore = async () => { + await page.bringToFront(); + const history = await cdp.send("Page.getNavigationHistory"); + // goBack(waitUntil:load) is the wrong wait for a successful cache restore: + // it has no new load event. Traverse real history and read the active realm. + await cdp.send("Page.navigateToHistoryEntry", { entryId: history.entries[history.currentIndex - 1].id }); + for (let i = 0; i < 100; i++) { + try { + const current = await state(); + if (current.href === origin + "/" && current.lifecycle?.length && (!current.retained || current.lifecycle.some(event => event.persisted))) return current; + } catch {} + await delay(100); + } + throw new Error("History traversal did not produce an observable restored or reloaded document"); + }; + return { context, page, events, rejected, state, restore, sdkLoads: () => sdkLoads }; +} + +function verifyLifecycle(t, state, rejected) { + assert.ok(state.lifecycle.every(event => event.trusted), "only actual browser lifecycle events qualify"); + if (strictCache) assert.equal(state.retained, true, JSON.stringify(rejected)); + t.diagnostic(JSON.stringify({ persisted: state.lifecycle.some(event => event.persisted), retained: state.retained, rejected })); +} + +test("real restored public navigation has one pageview; ordinary pageshow adds none", { timeout: 45000 }, async t => { + const f = await fixture(t); + assert.equal((await f.state()).views, 1); + await f.page.goto("https://away.example/"); + const state = await f.restore(); + verifyLifecycle(t, state, f.rejected); + assert.equal(state.views, state.retained ? 2 : 1); + if (state.retained) assert.deepEqual(state.afterSdk, [{ disabled: false, trusted: true }]); + if (sdk) { + for (let i = 0; i < 70 && f.events.filter(event => event.en === "page_view").length < 2; i++) await delay(100); + assert.equal(f.events.filter(event => event.en === "page_view").length, 2); + if (state.retained) assert.equal(f.sdkLoads(), 1, "a restored document keeps the same installed SDK"); + } +}); + +test("a cached tab reads another tab's denial before native restore callbacks", { timeout: 45000 }, async t => { + const f = await fixture(t); + await f.page.goto("https://away.example/"); + const peer = await f.context.newPage(); + await peer.goto(origin + "/choice"); + const setChoice = choice => peer.evaluate(choice => { + const now = Date.now(); + localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: choice, updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + }, choice); + await setChoice("denied"); + const state = await f.restore(); + verifyLifecycle(t, state, f.rejected); + if (state.retained) { + assert.equal(state.disabled, true); + assert.deepEqual(state.afterSdk, [{ disabled: true, trusted: true }]); + assert.equal(state.views, 1); + } else assert.equal(state.views, 0); + await delay(1500); + if (sdk) assert.equal(f.events.filter(event => event.en === "page_view").length, 1, "the denied return is not measured"); + await setChoice("granted"); + await delay(1800); + assert.doesNotMatch(JSON.stringify(f.events), /DENIED_BFCACHE_MARKER/, "denied restore work is never replayed on a later grant"); +}); + +for (const initial of [null, "denied"]) test(`first grant while cached (${initial || "no choice"}) creates one activation view`, { timeout: 45000 }, async t => { + const f = await fixture(t, initial); + assert.equal(f.sdkLoads(), 0); assert.equal(f.events.length, 0); + await f.page.goto("https://away.example/"); + const peer = await f.context.newPage(); await peer.goto(origin + "/choice"); + await peer.evaluate(() => { + const now = Date.now(); localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + }); + assert.equal(f.sdkLoads(), 0, "a frozen document does not start its first SDK"); + const state = await f.restore(); verifyLifecycle(t, state, f.rejected); + assert.equal(state.views, 1); + if (sdk) { + for (let i = 0; i < 70 && f.events.filter(event => event.en === "page_view").length < 1; i++) await delay(100); + await delay(1200); + assert.equal(f.events.filter(event => event.en === "page_view").length, 1); + assert.equal(f.sdkLoads(), 1); + } +}); + +test("a first grant in an ordinary background tab is not deferred like a cached document", async t => { + const f = await fixture(t, null); + const peer = await f.context.newPage(); await peer.goto(origin + "/choice"); await peer.bringToFront(); + await peer.evaluate(() => { + const now = Date.now(); localStorage.setItem("openclaw.analytics.consent", JSON.stringify({ schema_version: 1, policy_version: "2026-10-02.v2", analytics: "granted", updated_at: new Date(now).toISOString(), expires_at: new Date(now + 180 * 86400000).toISOString() })); + }); + await f.page.waitForFunction(() => window.dataLayer?.some(entry => entry[1] === "page_view")); + const state = await f.state(); + assert.equal(state.views, 1); + assert.equal(state.lifecycle.some(event => event.persisted), false); + await f.page.bringToFront(); + assert.equal((await f.state()).views, 1); +});