From 18f3c32cce0ecae66a2e469487bdb9de6aa193b1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph <49103247+hannesrudolph@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:32:51 -0600 Subject: [PATCH] fix: preserve docs outbound link placement in analytics --- scripts/docs-site/ANALYTICS.md | 35 +++++- scripts/docs-site/analytics-behavior.test.mjs | 114 +++++++++++++++++- scripts/docs-site/analytics-events.mjs | 15 ++- 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/scripts/docs-site/ANALYTICS.md b/scripts/docs-site/ANALYTICS.md index d99288123a..0d02f7507c 100644 --- a/scripts/docs-site/ANALYTICS.md +++ b/scripts/docs-site/ANALYTICS.md @@ -12,7 +12,7 @@ The site uses native `gtag.js`; existing hosting and analytics integrations rema | `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 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. | +| `select_content` | Public docs/site links, curated references, search results, code tabs, feedback choices | `content_type`, `content_id`, `ui_location`; external-origin HTTP(S) anchors also add `link_url` without query/fragment and hostname `link_domain`. Same-origin and non-anchor selections omit destination fields; credential-bearing targets are excluded. 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. | @@ -28,6 +28,39 @@ dimension. `result_count`, `lcp_ms`, `inp_ms`, and `cls_score` are metrics, not Do not combine native outbound events with content selections as one interaction total or sum distinct users across hosts. No docs event proves installation. +### Selection placement + +Placement and destination share the existing `select_content` event. Native +Enhanced Measurement `click` remains unchanged and does not receive a placement +parameter. Cross-canonical OpenClaw links also receive selection destination +fields; native linker decoration and original DOM destinations remain intact. + +| Fixed `ui_location` | Surface | +| --- | --- | +| `community_invite` | Docked community invitation links | +| `footer` | Footer social, resource and project links | +| `header` | Network navigation and header social links | +| `article` | Documentation body links and code tabs | +| `sidebar` | Sidebar links | +| `page_actions` | Page action links | +| `page_feedback` | Feedback choices and public Edit source / Raise issue links | +| `search_results` | Search result links | +| `table_of_contents` | In-page table of contents links | +| `pager` | Reserved existing `.pager` selector; current `.page-nav` is not covered | +| `docs_assistant` | Private assistant references are excluded while the panel is open | + +Existing content semantics stay intact: for example, all Discord placements use +`social_link` / `discord`, while generic curated links use `public_reference` and +a public URL. Public Edit source / Raise issue anchors use `public_reference`, +with query/fragment removed from both content ID and destination. The separate +feedback composer still launches through its existing button without exposing +private text in an observed anchor. + +Destination fields and feedback-anchor coverage are prospective. Historical +selection placements may exist without destination fields; historical native +clicks have no placement. Missing values remain unknown/not applicable and cannot +be reconstructed by joining selections, native clicks or popup events. + ## Privacy and consent Preview builds, other origins, the hidden component fixture, unknown/error/API diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs index df4a97ed5b..ea3865ea84 100644 --- a/scripts/docs-site/analytics-behavior.test.mjs +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -11,10 +11,11 @@ let browser; before(async () => { browser = await chromium.launch({ headless: true }); }); after(async () => { await browser?.close(); }); -async function openSite(t, { privacy, clock = false, diagram = false, theme = "dark", viewport = { width: 1440, height: 900 } } = {}) { +async function openSite(t, { privacy, clock = false, diagram = false, articleLinks = "", 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 (articleLinks) fs.appendFileSync(path.join(f.root, "docs/guide.md"), articleLinks); 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"); @@ -81,6 +82,117 @@ async function setBrowserPrivacy(page, blocked) { }, blocked); } +function wireEvents(collected) { + return collected.flatMap(request => (request.body || "").split(/\r?\n/).map(line => + Object.fromEntries(new URLSearchParams([new URL(request.url).search.slice(1), line].filter(Boolean).join("&"))))); +} + +test("selection placement and sanitized destination share one event without changing native clicks", async t => { + const destination = "https://discord.com/invite/clawd"; + const { page, context, events, collected } = await openSite(t, { + articleLinks: `\n[Community](${destination})\n\n[External reference](https://example.org/guide?ref=docs#intro)\n\n[Product](https://openclaw.ai/install?ref=docs#intro)\n`, + }); + page.setDefaultTimeout(5000); + // A 204 keeps same-tab external navigation on the fixture. No destination or + // collection request is forwarded and the actual anchor href is unchanged. + await context.route("**/*", route => route.request().isNavigationRequest() && new URL(route.request().url()).origin !== origin + ? route.fulfill({ status: 204 }) : route.fallback()); + context.on("page", popup => popup.close().catch(() => {})); + const count = (name, url) => wireEvents(collected).filter(event => event.en === name && (!url || event["ep.link_url"] === url)); + async function clickAndObserve(selector, placement, contentType, contentId, url, native = false) { + const link = page.locator(selector).first(); + const href = await link.getAttribute("href"); + const before = (await events("select_content")).length; + const nativeBefore = count("click", url).length; + const selectionBefore = count("select_content", url).length; + await link.click(); + const selected = await events("select_content"); + assert.equal(selected.length, before + 1); + assert.equal(selected.at(-1).ui_location, placement); + assert.equal(selected.at(-1).content_type, contentType); + assert.equal(selected.at(-1).content_id, contentId); + assert.equal(selected.at(-1).link_url, url); + assert.equal(selected.at(-1).link_domain, new URL(url).hostname); + const observedHref = new URL(await link.getAttribute("href"), origin); + const expectedHref = new URL(href, origin); + // Native Google linker decoration remains allowed; the application must + // preserve the destination's original query and fragment. + observedHref.searchParams.delete("_gl"); + expectedHref.searchParams.delete("_gl"); + assert.equal(observedHref.href, expectedHref.href); + if (sdk) { + const deadline = Date.now() + 8000; + while ((count("select_content", url).length === selectionBefore || (native && count("click", url).length === nativeBefore)) && Date.now() < deadline) await page.waitForTimeout(100); + assert.equal(count("select_content", url).length, selectionBefore + 1); + const selection = count("select_content", url).at(-1); + assert.equal(selection["ep.ui_location"], placement); + assert.equal(selection["ep.link_domain"], new URL(url).hostname); + if (native) { + assert.equal(count("click", url).length, nativeBefore + 1); + assert.equal(count("click", url).at(-1)["ep.ui_location"], undefined); + } + } + } + for (const [selector, placement] of [[".community-invite", "community_invite"], [".site-footer", "footer"], [".doc", "article"]]) { + await clickAndObserve(`${selector} a[href="${destination}"]`, placement, "social_link", "discord", destination, true); + } + await clickAndObserve('.doc a[href^="https://example.org/"]', "article", "public_reference", "https://example.org/guide", "https://example.org/guide"); + await clickAndObserve('.doc a[href^="https://openclaw.ai/"]', "article", "site_link", "openclaw.ai/install", "https://openclaw.ai/install"); + for (const label of ["Edit source", "Raise issue"]) { + const link = page.getByRole("link", { name: label, exact: true }); + const url = new URL(await link.getAttribute("href")); + const clean = url.origin + url.pathname; + await clickAndObserve(`.page-feedback a[href="${await link.getAttribute("href")}"]`, "page_feedback", "public_reference", clean, clean); + } + + const beforePrivate = (await events("select_content")).length; + const nativeBeforePrivate = count("click", destination).length; + await page.locator("[data-chat-toggle]").click(); + await page.locator(`.site-footer a[href="${destination}"]`).click(); + await page.waitForTimeout(1200); + await page.locator("[data-chat-minimize]").click(); + await page.waitForTimeout(1200); + assert.equal((await events("select_content")).length, beforePrivate, "private selection is not replayed on close"); + assert.equal(count("click", destination).length, nativeBeforePrivate, "private native click is not replayed on close"); + await clickAndObserve(`.site-footer a[href="${destination}"]`, "footer", "social_link", "discord", destination, true); +}); + +test("selection destination stays absent for same-origin, non-anchor, unknown and blocked contexts", async t => { + const { page, events, googleRequests } = await openSite(t); + page.setDefaultTimeout(5000); + await page.locator('[data-feedback-value="yes"]').click(); + let selected = (await events("select_content")).at(-1); + assert.equal(selected.content_type, "feedback_choice"); + assert.equal(selected.link_url, undefined); + assert.equal(selected.link_domain, undefined); + await page.locator('.doc a[href="/"]').click(); + await page.locator('.main[data-analytics-path="/"]').waitFor(); + selected = (await events("select_content")).at(-1); + assert.equal(selected.content_type, "documentation"); + assert.equal(selected.link_url, undefined); + assert.equal(selected.link_domain, undefined); + await page.evaluate(() => { + for (const [id, href, parent] of [["unknown-link", "https://example.org/unknown", document.body], ["credential-link", "https://example.org/guide", document.querySelector(".doc")], ["non-http-link", "mailto:fixture@example.org", document.querySelector(".doc")]]) { + const link = document.createElement("a"); link.id = id; link.href = href; link.textContent = id; + if (id === "credential-link") { link.username = "fixture"; link.password = "fixture"; } + link.addEventListener("click", event => event.preventDefault()); + parent.append(link); + } + }); + const before = (await events("select_content")).length; + // Exercise rejected owner inputs without navigating to credentials or + // launching a system mail handler from these deliberately invalid fixtures. + for (const id of ["unknown-link", "credential-link", "non-http-link"]) await page.locator("#" + id).dispatchEvent("click"); + assert.equal((await events("select_content")).length, before); + await setBrowserPrivacy(page, true); + const requestsBefore = googleRequests.length; + await page.locator('.site-footer a[href="https://discord.com/invite/clawd"]').click(); + await page.waitForTimeout(1200); + await setBrowserPrivacy(page, false); + assert.equal((await events("select_content")).length, before); + assert.equal(googleRequests.length, requestsBefore); +}); + test("acquisition, actual copy outcomes, public identifiers and feedback launch stay useful and safe", async t => { const { page, context, events, collected, runtimeRelease } = await openSite(t); const view = (await events("page_view"))[0]; diff --git a/scripts/docs-site/analytics-events.mjs b/scripts/docs-site/analytics-events.mjs index 56e1191afc..1c4f5cca72 100644 --- a/scripts/docs-site/analytics-events.mjs +++ b/scripts/docs-site/analytics-events.mjs @@ -211,17 +211,20 @@ export function createDocsAnalyticsEvents(analytics) { 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; + if (!link.closest(".doc,.sidebar,.site-header,.site-footer,.page-actions,.page-feedback,.pager,.toc,.community-invite,.search-modal")) return; let url; try { url = new URL(link.href); } catch { return; } - if (!/^https?:$/.test(url.protocol)) return; + if (!/^https?:$/.test(url.protocol) || url.username || url.password) return; + // Keep placement and destination on the same selection, without changing + // native outbound clicks or retaining query/fragment data from the href. + const destination = url.origin === window.location.origin ? {} : { link_url: url.origin + url.pathname, link_domain: url.hostname }; const artifact = artifactChoice(url); if (link.matches(".search-result")) flushSearch(); - if (socialHosts[url.hostname]) select("social_link", socialHosts[url.hostname], location); + if (socialHosts[url.hostname]) select("social_link", socialHosts[url.hostname], location, destination); 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 || {}); + 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, ...destination }); + } else if (link.closest(".page-actions")) select("page_action", /chatgpt/.test(url.hostname) ? "ask_chatgpt" : /claude/.test(url.hostname) ? "ask_claude" : "view_markdown", location, destination); + else if (link.closest(".doc,.site-footer,.site-header,.sidebar,.community-invite,.page-feedback")) select(artifact ? "download" : "public_reference", url.origin + url.pathname, location, { ...artifact, ...destination }); }); addEventListener("error", event => { const tag = event.target?.tagName;