diff --git a/CLOUDFLARE.md b/CLOUDFLARE.md index 569691babb..93a6ba3700 100644 --- a/CLOUDFLARE.md +++ b/CLOUDFLARE.md @@ -164,6 +164,19 @@ enabled here: retaining an old output directory without an exact current-file inventory would also retain obsolete search fragments. Publication order, search coverage and old-object deletion remain unchanged. +Content-only commits do not stamp their Git SHA into every HTML page. Analytics +`release` is `js-<12 hex>` from the complete emitted JavaScript before its identity +is prepended, including serialized analytics/config and hashed lazy-module paths. +It identifies the executing runtime even when PJAX loads newer page content. +During the first rollout, a tab still running the previous attribute-based +runtime falls back to `local` after PJAX loads HTML without that attribute; this +means unknown provenance, not the new runtime's identity. No reload is forced. +The final `.openclaw-docs-r2-manifest.json` records the full incoming `buildCommit` +(or null outside a GitHub build) and each object's hashes. +A partial publication may retain older objects; the incoming build commit does +not relabel those bytes. The search fallback JSON is also content-stable: it has +no unused generation timestamp. Pagefind still rebuilds as described above. + ### Router deployment 1. On a main push that changes `workers/**` or `wrangler.toml`, `r2-pages.yml` deploys the matching Worker after any required R2 upload, provided that snapshot passed admission before the build. diff --git a/scripts/docs-site/analytics-behavior.test.mjs b/scripts/docs-site/analytics-behavior.test.mjs index 57c430a85e..23cfa0b513 100644 --- a/scripts/docs-site/analytics-behavior.test.mjs +++ b/scripts/docs-site/analytics-behavior.test.mjs @@ -69,7 +69,9 @@ async function openSite(t, { privacy, clock = false, diagram = false, showConsen 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 }; + const runtimeRelease = /const docsRuntimeRelease="(js-[a-f0-9]{12})";/.exec(fs.readFileSync(path.join(site, "assets/docs-site.js"), "utf8"))?.[1]; + assert.ok(runtimeRelease); + return { page, context, events, collected, googleRequests, runtimeRelease }; } async function setSavedChoice(page, analytics) { @@ -82,11 +84,11 @@ async function setSavedChoice(page, analytics) { } 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 { page, context, events, collected, runtimeRelease } = 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"); + assert.equal(view.release, runtimeRelease); await page.locator("[data-code-copy]").click(); await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "copy_action")); await page.evaluate(() => { window.__copyFails = true; }); @@ -262,9 +264,25 @@ test("regrant after an unmeasured return to the same route starts one current vi await page.waitForFunction(() => window.dataLayer.some(entry => entry[1] === "section_view" && entry[2].section_id === "install")); await setSavedChoice(page, "denied"); 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 setSavedChoice(page, "granted"); + await page.locator('.main[data-analytics-path="/"]').waitFor(); + // A history URL change precedes the asynchronous PJAX DOM commit. Hold that + // response to exercise the boundary instead of racing it on a fast runner. + let releaseReturn; + const heldReturn = new Promise(resolve => { releaseReturn = resolve; }); + let returnRequested; + const requestStarted = new Promise(resolve => { returnRequested = resolve; }); + await page.route(`${origin}/guide**`, async route => { + returnRequested(); await heldReturn; await route.fallback(); + }); + try { + await page.goBack(); await page.waitForURL("**/guide**"); await requestStarted; + assert.equal(await page.locator(".main").getAttribute("data-analytics-path"), "/"); + assert.equal((await events("page_view")).length, 1); + await setSavedChoice(page, "granted"); + assert.equal((await events("page_view")).length, 1, "an uncommitted URL cannot start a public view"); + } finally { releaseReturn(); } + await page.locator('.main[data-analytics-path="/guide"]').waitFor(); + await page.waitForFunction(() => window.dataLayer.filter(entry => entry[1] === "page_view").length === 2); 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); diff --git a/scripts/docs-site/analytics-vitals.test.mjs b/scripts/docs-site/analytics-vitals.test.mjs index fb51d3f730..5a8bb59ae1 100644 --- a/scripts/docs-site/analytics-vitals.test.mjs +++ b/scripts/docs-site/analytics-vitals.test.mjs @@ -22,7 +22,7 @@ function harness({ delayed = false, withEvents = false } = {}) { loadVitals: () => { loads++; return delayed ? pending : Promise.resolve(library); }, }; sandbox.window = sandbox; - vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals);`, sandbox); + vm.runInNewContext(`globalThis.analytics=(${createDocsAnalytics.toString()})(loadVitals, "js-0123456789ab");`, 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 }] }); @@ -35,14 +35,19 @@ test("allowed document vitals keep typed values and their original public docume await turn(); h.report("LCP", "first"); h.report("LCP", "first"); h.sandbox.location.pathname = "/next"; h.main.dataset.analyticsPath = "/next"; + h.main.dataset.analyticsRelease = "a-newer-html-deployment"; 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(metric.release, "js-0123456789ab", "PJAX content cannot relabel the executing runtime"); assert.equal(["lcp_ms", "inp_ms", "cls_score"].filter(key => Object.hasOwn(metric, key)).length, 1); assert.equal(metric.metric_value, undefined); } + const pageViews = h.sandbox.dataLayer.filter(entry => entry[0] === "event" && entry[1] === "page_view"); + assert.equal(pageViews.length, 2); + assert.ok(pageViews.every(entry => entry[2].release === "js-0123456789ab")); }); for (const boundary of ["private", "history", "denial"]) test(`${boundary} permanently invalidates document metrics after public recovery`, async () => { diff --git a/scripts/docs-site/analytics.mjs b/scripts/docs-site/analytics.mjs index 8e597a71d6..9e3d845db2 100644 --- a/scripts/docs-site/analytics.mjs +++ b/scripts/docs-site/analytics.mjs @@ -1,5 +1,5 @@ // Serialized into the browser shell. All page identity comes from the renderer. -export function createDocsAnalytics(loadVitals) { +export function createDocsAnalytics(loadVitals, runtimeRelease = "local") { const origin = "https://docs.openclaw.ai"; const measurementId = "G-3SK7X2YLSJ"; const listeners = []; @@ -26,7 +26,7 @@ export function createDocsAnalytics(loadVitals) { 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" }; + return { main, path, title: main.dataset.analyticsTitle, release: runtimeRelease }; } function suspend() { collectionEpoch++; diff --git a/scripts/docs-site/build.mjs b/scripts/docs-site/build.mjs index 83f38f2cea..1ebe9ca342 100644 --- a/scripts/docs-site/build.mjs +++ b/scripts/docs-site/build.mjs @@ -361,7 +361,7 @@ ${canonicalUrl ? ` ${siteHeader(page)}