Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion scripts/docs-site/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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
Expand Down
114 changes: 113 additions & 1 deletion scripts/docs-site/analytics-behavior.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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];
Expand Down
15 changes: 9 additions & 6 deletions scripts/docs-site/analytics-events.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading