From cd75c4071e12ccaccfc2d55080d0dca7446c648e Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Mon, 7 Sep 2026 22:19:49 +0300 Subject: [PATCH 1/4] test(attribution): qualify Learn capture and SDK prerequisites Document initial-page, SPA and App handoff integration and characterize actual Root, navbar and PostHog plugin behavior. Preserve production behavior while reader deployment, concurrency, identity and retention qualification remain open. Validation: 10 Root tests and four native plugin and navbar checks pass. --- FIRST-TOUCH-ATTRIBUTION.md | 108 +++++++++++++++++++ src/theme/Root/firstTouchBaseline.test.js | 69 ++++++++++++ tests/first_touch_producer_baseline.test.mjs | 86 +++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 FIRST-TOUCH-ATTRIBUTION.md create mode 100644 src/theme/Root/firstTouchBaseline.test.js create mode 100644 tests/first_touch_producer_baseline.test.mjs diff --git a/FIRST-TOUCH-ATTRIBUTION.md b/FIRST-TOUCH-ATTRIBUTION.md new file mode 100644 index 0000000000..8fcf859a46 --- /dev/null +++ b/FIRST-TOUCH-ATTRIBUTION.md @@ -0,0 +1,108 @@ +# Learn first-touch capture preparation + +Status: research-only production design; source characterization is qualified by the tests below. +This document does not describe an implemented Learn capture or handoff producer. + +**Prerequisite: [netdata/cloud-frontend#5743](https://github.com/netdata/cloud-frontend/pull/5743).** +The reader contract is pinned to `netdata/cloud-frontend @ 3461255246041ad8c309e749973faaa53c716b55`, +`docs/first-touch-cookie.md`. Merge and deployment of prerequisite readers must be verified before +Learn capture can be enabled. Draft preparation does not authorize merge or release. + +## Purpose and observed behavior + +Preserve a visitor's first eligible Learn observation through SPA navigation and App navigation, +without changing Website field meanings, authentication, or existing analytics identities. +Source inspection targets `netdata/learn @ c3a16edd5ee4dc819976ef162c9afaff4b9b968c`. + +- `src/theme/Root/index.js:21` handles Nedi assets. Initial loads and SPA transitions do not write + `nd_first_touch`; the executable Root characterization verifies that absence. +- `docusaurus.config.js:159,172` supplies App links with `utm_source=learn` and a navigation label. + These describe the internal click, not a carried first observation. No `nd_ft` is emitted. +- Markdown content and `src/data/News.js:426,534` also contain App links. A navbar-only decorator + would miss those links; generated documentation must not be edited to add attribution. +- `docusaurus.config.js:186` uses `posthog-docusaurus@2.0.5`, pinned in `yarn.lock`, with the existing + `https://app.posthog.com` endpoint and development disabled. Its actual route module sends + `$pageview` on initial load and pathname changes; it does not capture a first-touch cookie. +- `static/api.html` is a standalone executable content page outside Docusaurus Root and its + plugins. `static/oauth2-redirect.html` is an authentication utility, not a content-entry page. +- `/` redirects before page JavaScript executes (`netlify.toml:22`). Its owner is `static.toml`; + generated redirects come from ingestion. Browser capture cannot reconstruct the original route. + +## Bounded implementation scope after qualification + +1. Add a Learn-owned adapter for the existing cookie and exact App handoff contract. Capture the + initial page's pathname, permitted referrer, supported UTMs, and canonical UTC millisecond time + together. Preserve earlier Website or other supported evidence as a whole; never put a Learn + pathname into Website-only root fields. Unknown versions and malformed stored records must not + become fresh captures or be overwritten. No additional cookie or persistent store is permitted. +2. Use the supported Docusaurus client-module hooks: initialize document evidence before the + initial React render; use `onRouteUpdate` for route context and `onRouteDidUpdate` for rendered + link refresh. Preserve the original observation across pathname, query, hash, and back navigation. + Leave Root's Nedi head ownership intact. Qualify standalone `/api` coverage separately; do not + attach capture to the OAuth redirect utility. See [Docusaurus client lifecycles](https://docusaurus.io/docs/advanced/client). +3. Decorate exact permitted HTTPS App destinations across navbar, content, news, and dynamically + inserted links. Preserve their existing query/hash bytes and internal `utm_*` labels. Cover + keyboard activation, middle-click, copied links, and SPA rerenders. Storage or SDK failure must + not block navigation. Do not change redirects, generated content, backend inputs, or SDK setup. +4. Attach only identity candidates observed for the captured visit. Retain original `ts` and + existing identities; never infer a historical ID from a later page or change SDK persistence. + ID association is diagnostic evidence, not authorization to identify or merge people. +5. Include tests, a rendered-browser check with intercepted network traffic, operator instructions, + and single-repository rollback qualification. Production code must have no SEO runtime dependency + and no dependency on another capture PR from the same delivery batch. + +## Reader constraints and unresolved qualification gates + +- Numeric version 2 uses `entry` with `surface`, `path`, `referrer`, five `utm_*` strings, and `ts`. + Optional Website roots retain their existing meaning. At most five identity candidates carry + `distinct_id`, public `project_token`, `surface`, anonymous/identified `status`, and `observed_at`. +- Encoded cookie and `nd_ft` JSON values are bounded to 3,800 bytes. Every populated observation, + including Website roots, requires canonical time, nonfuture time, original time plus 90 days + strictly after now, and pre-signup time when known. Invalid roots reject the entire handoff. +- Structured referrers exclude credentials, query/fragment, IP/local/private hostnames and + unsupported ports. Do not silently replace an unsupported referrer with an empty one. The safe + projection must preserve the observation's meaning and pass the App's existing contract. +- App independently caps the complete auth callback at 3,800 serialized URL bytes and can omit + `nd_ft` to preserve authentication. Envelope size alone does not prove callback continuity. +- **Concurrency is unresolved.** The approved requirement calls for recoverable earlier candidates + across competing writers. A shared-cookie read/compare/write is not atomic; document memory does + not prove recovery after a losing tab exits. The existing v2 reader exposes one primary entry + and optional Website roots. Do not claim complete concurrency or add storage to conceal this gap. +- **Delayed identity persistence is unresolved.** `document.cookie` does not reveal stored expiry. + Exact metadata is required for v1 migration. The approved timestamp-plus-90-day fallback belongs + to Website enrichment; it does not authorize Learn identity updates or renewal after SDK load. +- **SDK readiness is unresolved.** The installed plugin's stub has no synchronous identity reader; + the existence of `capture` or its queued opt-out method does not establish readiness. A `loaded` + function in plugin options is discarded by JSON serialization, reproduced by the tests below. + A supported readiness and anonymous/identified-state adapter must be qualified without changing + project, endpoint, SDK identity, or existing tracking controls. [PostHog documents this serialization limit](https://posthog.com/docs/libraries/docusaurus). +- **Mixed versions and rollback need browser proof.** Learn-only cookies cannot supply legacy + Website fields to a stale App reader. A stale Website existence guard can skip a later Website + capture. Qualify against the deployed Website compatibility change from + [netdata/website#1383](https://github.com/netdata/website/pull/1383) and the prerequisite App reader; + explicitly retain cached-client limitations. No same-batch Website producer is required. +- Root/legacy redirect capture requires its separate policy approval. It remains outside this + preparation scope. Missing SDK, disabled tracking, refused storage, oversize data, and absent + referrers must remain visible limitations rather than invented successful attribution. + +## Runnable characterization and later acceptance + +Install the existing lockfile with Yarn 1.22.22 and the repository's Node.js 22.23.2 runtime, then run: + +```sh +yarn vitest run src/theme/Root +node --test tests/first_touch_producer_baseline.test.mjs +``` + +The Root tests execute the actual React component. The native tests execute the installed plugin's +generated SDK stub and route module, with network insertion intercepted and only module bindings +adapted for the VM. They characterize missing capture and SDK limitations; they do not simulate an +unbuilt Learn writer or certify the desired production behavior. App owns its producer-contract +fixtures and real reader/auth/navigation checks; Learn does not vendor that source or require an App +checkout in CI. When capture is implemented, replace the absence assertions with behavior tests. + +Acceptance must additionally cover Website/Learn ordering, repeated/competing tabs, original expiry, +missing or late SDK, known IDs and overflow, unsupported versions, malformed/oversize values, +both observation timestamp boundaries, App URL safety, standalone-page coverage, and rollback. +Run the owning production build, `scripts/run-post-build-gates.mjs`, and existing redirect, +indexability, and rendered-site checks; never widen their baselines to make attribution pass. diff --git a/src/theme/Root/firstTouchBaseline.test.js b/src/theme/Root/firstTouchBaseline.test.js new file mode 100644 index 0000000000..6fb618b0ff --- /dev/null +++ b/src/theme/Root/firstTouchBaseline.test.js @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; + +import { __setMockLocation, __resetMockLocation } from '@docusaurus/router'; +import Root from './index'; + +const websiteCookie = `nd_first_touch=${encodeURIComponent(JSON.stringify({ + landing_page: '/website-first/', + referrer: 'https://search.example/', + utm_source: 'search', + ts: '2026-09-01T00:00:00.000Z', +}))}`; +let jar; +let writeCookie; + +function navigate(pathname, search = '') { + window.history.replaceState({}, '', `${pathname}${search}`); + __setMockLocation({ pathname, search, hash: '' }); +} + +beforeEach(() => { + jar = ''; + vi.spyOn(document, 'cookie', 'get').mockImplementation(() => jar); + writeCookie = vi.spyOn(document, 'cookie', 'set').mockImplementation((value) => { + jar = value.split(';')[0]; + }); + vi.spyOn(document, 'referrer', 'get').mockReturnValue('https://search.example/'); +}); + +afterEach(() => { + __resetMockLocation(); + window.history.replaceState({}, '', '/'); + vi.restoreAllMocks(); +}); + +describe('Learn Root capture baseline before a Learn producer exists', () => { + it('renders a direct campaign entry without writing a first-touch cookie', () => { + navigate('/docs/netdata-agent', '?utm_source=search&utm_medium=organic'); + const page = render(

Documentation

); + + expect(page.getByText('Documentation')).toBeInTheDocument(); + expect(document.cookie).toBe(''); + expect(writeCookie).not.toHaveBeenCalled(); + }); + + it('renders a SPA transition without recording either Learn route', () => { + navigate('/docs/netdata-agent', '?utm_source=search'); + const page = render(

First documentation page

); + + navigate('/docs/netdata-agent/installation', '?utm_source=learn'); + page.rerender(

Installation

); + + expect(page.getByText('Installation')).toBeInTheDocument(); + expect(document.cookie).toBe(''); + expect(writeCookie).not.toHaveBeenCalled(); + }); + + it('leaves an existing Website cookie byte-identical on load and SPA navigation', () => { + jar = websiteCookie; + navigate('/docs/netdata-agent'); + const page = render(

Documentation

); + + navigate('/docs/ask-nedi', '?utm_source=learn'); + page.rerender(

Ask Nedi

); + + expect(document.cookie).toBe(websiteCookie); + expect(writeCookie).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/first_touch_producer_baseline.test.mjs b/tests/first_touch_producer_baseline.test.mjs new file mode 100644 index 0000000000..85d85e4e62 --- /dev/null +++ b/tests/first_touch_producer_baseline.test.mjs @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); +const config = require('../docusaurus.config.js'); +const posthogPlugin = require('posthog-docusaurus'); + +test('Learn navbar carries internal campaign tags without a first-touch envelope', () => { + const appLinks = config.themeConfig.navbar.items.filter((item) => + (item.href || item.to || '').startsWith('https://app.netdata.cloud'), + ); + assert.equal(appLinks.length, 2); + for (const item of appLinks) { + const url = new URL(item.href || item.to); + assert.equal(url.searchParams.get('utm_source'), 'learn'); + assert.equal(url.searchParams.has('nd_ft'), false); + assert.deepEqual([...url.searchParams.keys()].sort(), ['utm_content', 'utm_source']); + } +}); + +test('the installed PostHog plugin generates an asynchronous stub without a synchronous identity reader', () => { + assert.equal(require('posthog-docusaurus/package.json').version, '2.0.5'); + const options = config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === 'posthog-docusaurus')[1]; + const plugin = posthogPlugin({}, { ...options, enableInDevelopment: true }); + const script = plugin.injectHtmlTags().headTags.find((tag) => tag.tagName === 'script').innerHTML; + const inserted = []; + const context = { + document: { + createElement: () => ({}), + getElementsByTagName: () => [{ parentNode: { insertBefore: (element) => inserted.push(element) } }], + }, + }; + context.window = context; + vm.runInNewContext(script, context); + + assert.equal(inserted.length, 1); + assert.equal(inserted[0].src, 'https://app.posthog.com/static/array.js'); + assert.equal(inserted[0].async, true); + assert.equal(context.posthog._i[0][0], options.apiKey); + assert.equal(context.posthog.get_distinct_id, undefined); + assert.equal(typeof context.posthog.capture, 'function'); + assert.equal(context.posthog.has_opted_out_capturing(), undefined); +}); + +test('the installed PostHog route module captures pageviews without producing first-touch cookies', () => { + const plugin = posthogPlugin({}, { apiKey: 'phc_synthetic', enableInDevelopment: true }); + const filename = require.resolve(plugin.getClientModules()[0]); + const module = readFileSync(filename, 'utf8') + .replace("import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';", '') + .replace('export default', 'globalThis.lifecycle ='); + const events = []; + const writes = []; + const document = {}; + Object.defineProperty(document, 'cookie', { + get: () => '', + set: (value) => writes.push(value), + }); + const context = { + ExecutionEnvironment: { canUseDOM: true }, + document, + window: { document, posthog: { capture: (event) => events.push(event) } }, + }; + vm.runInNewContext(module, context, { filename }); + context.lifecycle.onRouteUpdate({ location: { pathname: '/docs/first' }, previousLocation: null }); + context.lifecycle.onRouteUpdate({ + location: { pathname: '/docs/second' }, previousLocation: { pathname: '/docs/first' }, + }); + context.lifecycle.onRouteUpdate({ + location: { pathname: '/docs/second', hash: '#heading' }, previousLocation: { pathname: '/docs/second' }, + }); + + assert.deepEqual(events, ['$pageview', '$pageview']); + assert.deepEqual(writes, []); +}); + +test('the installed plugin removes a loaded callback from configuration when serializing it', () => { + const plugin = posthogPlugin({}, { + apiKey: 'phc_synthetic', enableInDevelopment: true, loaded: function captureOriginIdentity() {}, + }); + const script = plugin.injectHtmlTags().headTags.find((tag) => tag.tagName === 'script').innerHTML; + assert.equal(script.includes('captureOriginIdentity'), false); + assert.equal(script.includes('"loaded"'), false); +}); From f64efcd784b67e9f74838dc747e4ed03b8a582ad Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Thu, 17 Sep 2026 21:31:42 +0300 Subject: [PATCH 2/4] Capture Learn first touch and carry evidence on App links --- FIRST-TOUCH-ATTRIBUTION.md | 179 +++++++++----------- FIRST-TOUCH-IMPLEMENTATION.md | 74 ++++++++ docusaurus.config.js | 3 + package.json | 3 +- static/api.html | 2 + static/js/first-touch-handoff.js | 103 ++++++++++++ static/js/first-touch.js | 117 +++++++++++++ tests/first_touch_capture.test.mjs | 214 ++++++++++++++++++++++++ tests/fixtures/first-touch-handoff.json | 45 +++++ 9 files changed, 635 insertions(+), 105 deletions(-) create mode 100644 FIRST-TOUCH-IMPLEMENTATION.md create mode 100644 static/js/first-touch-handoff.js create mode 100644 static/js/first-touch.js create mode 100644 tests/first_touch_capture.test.mjs create mode 100644 tests/fixtures/first-touch-handoff.json diff --git a/FIRST-TOUCH-ATTRIBUTION.md b/FIRST-TOUCH-ATTRIBUTION.md index 8fcf859a46..f32d4b03d0 100644 --- a/FIRST-TOUCH-ATTRIBUTION.md +++ b/FIRST-TOUCH-ATTRIBUTION.md @@ -1,108 +1,79 @@ -# Learn first-touch capture preparation - -Status: research-only production design; source characterization is qualified by the tests below. -This document does not describe an implemented Learn capture or handoff producer. - -**Prerequisite: [netdata/cloud-frontend#5743](https://github.com/netdata/cloud-frontend/pull/5743).** -The reader contract is pinned to `netdata/cloud-frontend @ 3461255246041ad8c309e749973faaa53c716b55`, -`docs/first-touch-cookie.md`. Merge and deployment of prerequisite readers must be verified before -Learn capture can be enabled. Draft preparation does not authorize merge or release. - -## Purpose and observed behavior - -Preserve a visitor's first eligible Learn observation through SPA navigation and App navigation, -without changing Website field meanings, authentication, or existing analytics identities. -Source inspection targets `netdata/learn @ c3a16edd5ee4dc819976ef162c9afaff4b9b968c`. - -- `src/theme/Root/index.js:21` handles Nedi assets. Initial loads and SPA transitions do not write - `nd_first_touch`; the executable Root characterization verifies that absence. -- `docusaurus.config.js:159,172` supplies App links with `utm_source=learn` and a navigation label. - These describe the internal click, not a carried first observation. No `nd_ft` is emitted. -- Markdown content and `src/data/News.js:426,534` also contain App links. A navbar-only decorator - would miss those links; generated documentation must not be edited to add attribution. -- `docusaurus.config.js:186` uses `posthog-docusaurus@2.0.5`, pinned in `yarn.lock`, with the existing - `https://app.posthog.com` endpoint and development disabled. Its actual route module sends - `$pageview` on initial load and pathname changes; it does not capture a first-touch cookie. -- `static/api.html` is a standalone executable content page outside Docusaurus Root and its - plugins. `static/oauth2-redirect.html` is an authentication utility, not a content-entry page. -- `/` redirects before page JavaScript executes (`netlify.toml:22`). Its owner is `static.toml`; - generated redirects come from ingestion. Browser capture cannot reconstruct the original route. - -## Bounded implementation scope after qualification - -1. Add a Learn-owned adapter for the existing cookie and exact App handoff contract. Capture the - initial page's pathname, permitted referrer, supported UTMs, and canonical UTC millisecond time - together. Preserve earlier Website or other supported evidence as a whole; never put a Learn - pathname into Website-only root fields. Unknown versions and malformed stored records must not - become fresh captures or be overwritten. No additional cookie or persistent store is permitted. -2. Use the supported Docusaurus client-module hooks: initialize document evidence before the - initial React render; use `onRouteUpdate` for route context and `onRouteDidUpdate` for rendered - link refresh. Preserve the original observation across pathname, query, hash, and back navigation. - Leave Root's Nedi head ownership intact. Qualify standalone `/api` coverage separately; do not - attach capture to the OAuth redirect utility. See [Docusaurus client lifecycles](https://docusaurus.io/docs/advanced/client). -3. Decorate exact permitted HTTPS App destinations across navbar, content, news, and dynamically - inserted links. Preserve their existing query/hash bytes and internal `utm_*` labels. Cover - keyboard activation, middle-click, copied links, and SPA rerenders. Storage or SDK failure must - not block navigation. Do not change redirects, generated content, backend inputs, or SDK setup. -4. Attach only identity candidates observed for the captured visit. Retain original `ts` and - existing identities; never infer a historical ID from a later page or change SDK persistence. - ID association is diagnostic evidence, not authorization to identify or merge people. -5. Include tests, a rendered-browser check with intercepted network traffic, operator instructions, - and single-repository rollback qualification. Production code must have no SEO runtime dependency - and no dependency on another capture PR from the same delivery batch. - -## Reader constraints and unresolved qualification gates - -- Numeric version 2 uses `entry` with `surface`, `path`, `referrer`, five `utm_*` strings, and `ts`. - Optional Website roots retain their existing meaning. At most five identity candidates carry - `distinct_id`, public `project_token`, `surface`, anonymous/identified `status`, and `observed_at`. -- Encoded cookie and `nd_ft` JSON values are bounded to 3,800 bytes. Every populated observation, - including Website roots, requires canonical time, nonfuture time, original time plus 90 days - strictly after now, and pre-signup time when known. Invalid roots reject the entire handoff. -- Structured referrers exclude credentials, query/fragment, IP/local/private hostnames and - unsupported ports. Do not silently replace an unsupported referrer with an empty one. The safe - projection must preserve the observation's meaning and pass the App's existing contract. -- App independently caps the complete auth callback at 3,800 serialized URL bytes and can omit - `nd_ft` to preserve authentication. Envelope size alone does not prove callback continuity. -- **Concurrency is unresolved.** The approved requirement calls for recoverable earlier candidates - across competing writers. A shared-cookie read/compare/write is not atomic; document memory does - not prove recovery after a losing tab exits. The existing v2 reader exposes one primary entry - and optional Website roots. Do not claim complete concurrency or add storage to conceal this gap. -- **Delayed identity persistence is unresolved.** `document.cookie` does not reveal stored expiry. - Exact metadata is required for v1 migration. The approved timestamp-plus-90-day fallback belongs - to Website enrichment; it does not authorize Learn identity updates or renewal after SDK load. -- **SDK readiness is unresolved.** The installed plugin's stub has no synchronous identity reader; - the existence of `capture` or its queued opt-out method does not establish readiness. A `loaded` - function in plugin options is discarded by JSON serialization, reproduced by the tests below. - A supported readiness and anonymous/identified-state adapter must be qualified without changing - project, endpoint, SDK identity, or existing tracking controls. [PostHog documents this serialization limit](https://posthog.com/docs/libraries/docusaurus). -- **Mixed versions and rollback need browser proof.** Learn-only cookies cannot supply legacy - Website fields to a stale App reader. A stale Website existence guard can skip a later Website - capture. Qualify against the deployed Website compatibility change from - [netdata/website#1383](https://github.com/netdata/website/pull/1383) and the prerequisite App reader; - explicitly retain cached-client limitations. No same-batch Website producer is required. -- Root/legacy redirect capture requires its separate policy approval. It remains outside this - preparation scope. Missing SDK, disabled tracking, refused storage, oversize data, and absent - referrers must remain visible limitations rather than invented successful attribution. - -## Runnable characterization and later acceptance - -Install the existing lockfile with Yarn 1.22.22 and the repository's Node.js 22.23.2 runtime, then run: +# Learn first-touch capture and handoff + +Status: implemented locally; qualification evidence and outstanding deployment acceptance are recorded in +[FIRST-TOUCH-IMPLEMENTATION.md](FIRST-TOUCH-IMPLEMENTATION.md). + +## Behavior + +The synchronous head scripts `static/js/first-touch-handoff.js` and `static/js/first-touch.js` +capture the first executable Learn document before hydration or SPA routing. The same scripts +run on standalone `static/api.html`; the OAuth redirect utility does not capture content entry. +Root's Nedi assets and the existing PostHog initializer remain unchanged. + +When no first-touch cookie exists, capture the pathname, referrer, supported UTMs and original UTC +timestamp as one `entry` with `surface: "learn"`. Use only the existing `nd_first_touch` cookie: +URI-encoded JSON, version 2, Domain=.netdata.cloud, Path=/, Secure, SameSite=Lax, 90-day expiry. +Learn entries never populate Website-only root fields. + +Every existing cookie remains byte-for-byte unchanged, including v1, unsupported versions, +malformed records and earlier observations. Reading does not renew expiry. A supported v1 record +can be projected into a v2 URL envelope without physically upgrading the cookie. No SDK identities +are collected, reset or merged; valid already-associated candidates are retained by projection. + +## Links and authentication boundary + +Exact HTTPS App destinations receive the bounded `nd_ft` projection. Navbar, content, news, +dynamically added anchors and SPA rerenders use the same adapter. Links are decorated before +activation so copied URLs and middle-clicks carry evidence. Existing campaign labels, query bytes, +fragments and already-present envelopes are preserved. Existing `nd_ft` values are not replaced. + +The serializer shares exact source bytes with the Website-owned implementation, but this repository +contains its own copy: builds and runtime require no other repository. The contract is compatible +with the versioned App reader introduced in Cloud Frontend #5743 and retained in #5745. +An App deployment must implement that reader to consume Learn-only observations. + +Referrer projection removes query and fragment only when the remaining URL meets the App's +public-host contract. Credentials, private/local/IP hosts, unsupported ports and non-public Netdata +paths cannot be handed off. Their cookie evidence is retained unchanged, and transport is omitted; +it is never silently rewritten as direct traffic. All populated observations, including Website +roots, must have canonical, nonfuture timestamps within 90 days. Both the encoded envelope and the +complete decorated URL are limited to 3,800 characters. App additionally validates signup time and +authentication callback size. + +## Failure and compatibility boundaries + +- Capture and decoration run only on https://learn.netdata.cloud. Preview and local builds do not + create production cookies. Tests intercept requests while using the canonical origin. +- Existing SDK opt-out and persisted PostHog opt-out markers suppress both capture and handoff. + The asynchronous SDK stub is not mistaken for a loaded identity/consent reader. +- Refused writes retain evidence in document memory for outbound links. Unreadable storage fails + closed. No additional cookie, localStorage write or sessionStorage is introduced. +- SPA routes do not recapture. A competing later cookie cannot replace the retained earlier + document record during link projection; tied differing observations suppress projection. + The cookie is not an atomic cross-origin store, and document memory cannot recover after tab exit. +- Cookie expiry, unsupported/malformed evidence and oversize observations never become fresh + invented visits. No late SDK callback renews the cookie or infers historical identity. +- HTTP redirects execute before browser capture. The root redirect therefore captures the first + executable destination, not the original HTTP request. Redirect policy is unchanged. +- Historical cached clients may not read this format. The Website compatibility deployment accepts + Learn-only cookies and can add Website roots without replacing their entry; legacy Website-only + reports are not repurposed. Page delivery does not establish cross-browser SDK-history linkage. + +## Validation and rollback + +Use Node.js 22.23.2 and the existing Yarn lockfile: ```sh -yarn vitest run src/theme/Root -node --test tests/first_touch_producer_baseline.test.mjs +npm run test:first-touch +yarn vitest run +yarn build +node scripts/run-post-build-gates.mjs ``` -The Root tests execute the actual React component. The native tests execute the installed plugin's -generated SDK stub and route module, with network insertion intercepted and only module bindings -adapted for the VM. They characterize missing capture and SDK limitations; they do not simulate an -unbuilt Learn writer or certify the desired production behavior. App owns its producer-contract -fixtures and real reader/auth/navigation checks; Learn does not vendor that source or require an App -checkout in CI. When capture is implemented, replace the absence assertions with behavior tests. - -Acceptance must additionally cover Website/Learn ordering, repeated/competing tabs, original expiry, -missing or late SDK, known IDs and overflow, unsupported versions, malformed/oversize values, -both observation timestamp boundaries, App URL safety, standalone-page coverage, and rollback. -Run the owning production build, `scripts/run-post-build-gates.mjs`, and existing redirect, -indexability, and rendered-site checks; never widen their baselines to make attribution pass. +Native tests execute the actual scripts in a DOM environment; characterization tests retain checks +of unchanged PostHog plugin behavior. Complete build output is required by the redirect graph suite. +Additional browser qualification and App-reader interoperability evidence are recorded in the +implementation ledger. A local build is not proof of deployed behavior. + +Rollback removes the two script inclusions from Docusaurus and standalone API HTML. Leave the +existing cookie and analytics identities untouched. No cookie deletion or migration is needed. diff --git a/FIRST-TOUCH-IMPLEMENTATION.md b/FIRST-TOUCH-IMPLEMENTATION.md new file mode 100644 index 0000000000..dc85a6ee43 --- /dev/null +++ b/FIRST-TOUCH-IMPLEMENTATION.md @@ -0,0 +1,74 @@ +# Learn first-touch implementation ledger + +Status: implemented; local qualification passed. Deployment acceptance remains open. + +## Purpose and approved scope + +Preserve the first executable Learn page, sender, campaign and timestamp through browsing and +App navigation using the existing `nd_first_touch` version 2 cookie and `nd_ft` URL parameter. +No new storage, SDK identity operations, backend dependency, redirect policy or build-gate changes. + +## Evidence, contracts and risk + +The existing PostHog plugin records page views but neither Root nor the standalone API page captures +the shared cookie. Navbar campaign labels describe internal navigation, not first touch. A head +script captures once before SPA routing; the same script is included on the standalone API page. +An exact-host link adapter refreshes initial and dynamically rendered App links before copying or +activation. The Netdata Website IIFE and Cloud's versioned reader are the implementation patterns. + +Preserve every existing cookie byte, including unsupported or malformed records. Never renew its +expiry. Fresh Learn observations have no Website-only roots. Referrer projection must match the +Cloud reader; unsafe existing evidence suppresses transport rather than inventing an empty sender. +Tracking opt-out, unavailable storage, size limits and nonproduction hosts must fail without +blocking navigation. A shared cookie cannot provide atomic cross-tab persistence. + +New SDK identity collection is excluded pending an explicit disclosure policy; historical identity +candidates remain untouched in storage. Page attribution does not claim cross-browser history merge. + +## Validation + +Execute native VM/browser tests of the actual scripts for fresh/repeated/SPA capture, v1/v2/future +cookies, earlier evidence, expiry, denied storage, opt-out, unsafe refs, dynamic/copied/middle-click +links, destination safety and query preservation. Test output against Cloud's exact reader contract. +Run existing Learn tests and production build/gates without weakening baseline rules. Public +deployment and synthetic-account validation are separate acceptance work, not local test claims. + +## Rollback + +Remove the script inclusions to disable capture and decoration; existing cookie contents and +analytics initialization remain unchanged. Do not delete or reset cookies during rollback. + +## Local evidence + +- Node.js 22.23.2: 25 first-touch native tests passed, covering DOM capture, link mutation and the + unchanged installed PostHog plugin. No provider writes occur in these tests. +- Node.js 22.23.2: all 35 Vitest files passed, with 455 tests passed and one existing skip. The + redirect graph suite requires built output; it passed after the production build completed. +- Node.js 22.23.2: all 73 existing dependency-authority, IndexNow, Swagger-vendor and site-gate + native tests passed. Running the site-gate test with Node.js 24 correctly fails its runtime gate. +- The production Docusaurus build passed. It reported existing content anchor/minifier warnings; + attribution did not change the generated documentation or link policy. +- All post-build gates passed: rendered titles, functional H1, redirect graph, redirect-source + links, indexability, RUM and the checksum-bound site gate. No baselines were widened. +- Chromium 1243 with all traffic intercepted: actual scripts passed synchronous capture, cookie + domain/security/expiry, SPA dynamic copied links, reload without expiry renewal, and transport + to an isolated cookie-free browser context. +- Chromium loaded actual built Ask Nedi and standalone API artifacts through an intercepted local + response handler. Both captured their initial route; rendered App navbar links carried evidence. + Both rendered artifacts contain the scripts in order without async/defer attributes. +- The pure handoff helper is byte-identical to the Website copy (SHA-256 + `ede4fb9c66c4301f24a2b584ab2a8912ad922919f2113c336446a347fe6c6013`). +- `tests/fixtures/first-touch-handoff.json` defines eight shared Website/Learn projection cases: + legacy Website, Learn, Learn with Website roots, safe sender projection, unsupported versions, + private senders, future timestamps and expired timestamps. Native tests execute the actual helper. + +App-reader/auth interoperability and deployed signup reconciliation belong to the coordinated +release evidence; no local result certifies backend deployment or whole-history identity linkage. + +## PR delivery authorization — 2026-09-17 + +The user authorized publishing the scoped implementation to existing PR #3079. Immediately before +publication, its head was `cd75c4071e12ccaccfc2d55080d0dca7446c648e`; existing required checks and +the Netlify preview had completed successfully, with no running checks. Those checks qualify the +preparation head only. Review comment 4033135419 is addressed by including both first-touch native +test files in `test:first-touch`, which runs within the existing `test:run` validation command. diff --git a/docusaurus.config.js b/docusaurus.config.js index 4492991102..b4a2238a45 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -260,6 +260,9 @@ module.exports = { }, ], scripts: [ + // Synchronous head execution retains the entry before client routing or hydration. + { src: '/js/first-touch-handoff.js', async: false, defer: false }, + { src: '/js/first-touch.js', async: false, defer: false }, { src: 'https://static.cloudflareinsights.com/beacon.min.js', defer: true, diff --git a/package.json b/package.json index 5e88ec8733..b52dd065dc 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "test": "vitest", - "test:run": "vitest run && npm run test:indexnow && npm run test:swagger-ui-vendor && npm run test:dependency-authority && npm run test:site-build-gate", + "test:run": "vitest run && npm run test:first-touch && npm run test:indexnow && npm run test:swagger-ui-vendor && npm run test:dependency-authority && npm run test:site-build-gate", + "test:first-touch": "node --test tests/first_touch_capture.test.mjs tests/first_touch_producer_baseline.test.mjs", "test:dependency-authority": "node --test tests/dependency_authority.test.js", "test:indexnow": "node --test tests/indexnow_plugin.test.js", "test:swagger-ui-vendor": "node --test tests/swagger_ui_vendor.test.js", diff --git a/static/api.html b/static/api.html index 21344a2159..31ac33783c 100644 --- a/static/api.html +++ b/static/api.html @@ -22,6 +22,8 @@ + + Netdata API diff --git a/static/js/first-touch-handoff.js b/static/js/first-touch-handoff.js new file mode 100644 index 0000000000..7ee9d8e1ca --- /dev/null +++ b/static/js/first-touch-handoff.js @@ -0,0 +1,103 @@ +/* Whole-observation URL projection. Storage and SDK identity remain producer-owned. */ +(function (root) { + 'use strict'; + var LIMIT = 3800; + var LIFETIME = 7776000000; + var SURFACES = ['website', 'learn', 'community', 'app', 'agent']; + var FIELDS = ['referrer', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'ts']; + var LEGACY = ['landing_page'].concat(FIELDS); + var owns = function (object, key) { return Object.prototype.hasOwnProperty.call(object, key); }; + var object = function (value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }; + + function time(value) { + var date = new Date(value); + return typeof value === 'string' && Number.isFinite(date.getTime()) && date.toISOString() === value + ? date.getTime() : NaN; + } + + function referrer(value) { + if (!value) return ''; + try { + if (value.trim() !== value || /[\\\u0000-\u0020\u007f]/.test(value)) return null; + var url = new URL(value.split(/[?#]/, 1)[0]); + var host = url.hostname.toLowerCase().replace(/\.$/, ''); + if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.port || + !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]{1,62}$/.test(host) || + /(^|\.)(localhost|local|internal|intranet|lan|home|test|invalid|onion)$/.test(host) || + ((host === 'netdata.cloud' || host.endsWith('.netdata.cloud')) && + ['www.netdata.cloud', 'learn.netdata.cloud'].indexOf(host) === -1 && url.pathname !== '/')) return null; + return url.href; + } catch (e) { return null; } + } + + function entry(value, now) { + if (!object(value) || SURFACES.indexOf(value.surface) === -1 || + typeof value.path !== 'string' || !/^\/(?!\/)[^?#\\\u0000-\u0020\u007f]*$/.test(value.path) || + FIELDS.some(function (key) { return owns(value, key) && typeof value[key] !== 'string'; })) return null; + var stamp = time(value.ts); + if (!Number.isFinite(stamp) || stamp > now || stamp + LIFETIME <= now) return null; + var result = { surface: value.surface, path: value.path }; + FIELDS.forEach(function (key) { result[key] = value[key] || ''; }); + result.referrer = referrer(result.referrer); + if (result.referrer === null) return null; + if (owns(value, 'identities')) { + if (!Array.isArray(value.identities) || value.identities.length > 5 || !value.identities.every(function (identity) { + return object(identity) && ['distinct_id', 'project_token'].every(function (key) { + return typeof identity[key] === 'string' && identity[key].length > 0 && identity[key].trim() === identity[key]; + }) && SURFACES.indexOf(identity.surface) !== -1 && + ['anonymous', 'identified'].indexOf(identity.status) !== -1 && Number.isFinite(time(identity.observed_at)); + })) return null; + result.identities = value.identities.map(function (identity) { + var projected = {}; + ['distinct_id', 'project_token', 'surface', 'status', 'observed_at'].forEach(function (key) { projected[key] = identity[key]; }); + return projected; + }); + } + return result; + } + + function project(raw, now) { + try { + var data = raw; + if (typeof raw === 'string') { + try { data = JSON.parse(raw); } + catch (e) { data = JSON.parse(decodeURIComponent(raw)); } + } + if (!object(data) || (owns(data, 'version') && data.version !== 1 && data.version !== 2)) return null; + if (data.version === 2 && encodeURIComponent(JSON.stringify(data)).length > LIMIT) return null; + now = now === undefined ? Date.now() : Number(now); + if (!Number.isFinite(now)) return null; + var historical = {}; + if (LEGACY.some(function (key) { return owns(data, key) && typeof data[key] !== 'string'; })) return null; + LEGACY.forEach(function (key) { if (owns(data, key)) historical[key] = data[key]; }); + var website = Object.assign({}, historical, { surface: 'website', path: historical.landing_page }); + var selected = entry(data.version === 2 ? data.entry : website, now); + if (!selected) return null; + if (Object.keys(historical).some(function (key) { return Boolean(historical[key]); })) { + var validated = entry(website, now); + if (!validated) return null; + if (owns(historical, 'referrer')) historical.referrer = validated.referrer; + } + var result = Object.assign(historical, { version: 2, entry: selected }); + return encodeURIComponent(JSON.stringify(result)).length <= LIMIT ? result : null; + } catch (e) { return null; } + } + + function decorate(href, projected) { + try { + var url = new URL(href); + if (url.origin !== 'https://app.netdata.cloud' || url.username || url.password || + url.searchParams.has('nd_ft')) return href; + var validated = project(projected); + if (!validated) return href; + var hashAt = href.indexOf('#'); + var beforeHash = hashAt < 0 ? href : href.slice(0, hashAt); + var hash = hashAt < 0 ? '' : href.slice(hashAt); + var result = beforeHash + (beforeHash.indexOf('?') < 0 ? '?' : /[?&]$/.test(beforeHash) ? '' : '&') + + 'nd_ft=' + encodeURIComponent(JSON.stringify(validated)) + hash; + return new URL(result).href.length <= LIMIT ? result : href; + } catch (e) { return href; } + } + + root.ndFirstTouchHandoff = { project: project, decorate: decorate }; +})(window); diff --git a/static/js/first-touch.js b/static/js/first-touch.js new file mode 100644 index 0000000000..c6a6180317 --- /dev/null +++ b/static/js/first-touch.js @@ -0,0 +1,117 @@ +/* Learn capture and link handoff. Existing cookie roots remain Website-only. */ +(function (root) { + 'use strict'; + if (root.ndLearnFirstTouch || root.location.origin !== 'https://learn.netdata.cloud') return; + var NAME = 'nd_first_touch'; + var TOKEN = 'phc_hnhlqe6D2Q4IcQNrFItaqdXJAxQ8RcHkPAFAp74pubv'; + var LIMIT = 3800; + var retained; + var links = new WeakMap(); + + function rawCookie() { + try { + var found = document.cookie.split(';').map(function (part) { return part.trim(); }) + .filter(function (part) { return part.indexOf(NAME + '=') === 0; }); + return found.length === 0 ? null : found.length === 1 ? found[0].slice(NAME.length + 1) : undefined; + } catch (error) { return undefined; } + } + + function optedOut() { + try { + var key = '__ph_opt_in_out_' + TOKEN; + return (root.posthog && root.posthog.__loaded && + root.posthog.has_opted_out_capturing && root.posthog.has_opted_out_capturing() === true) || + root.localStorage.getItem(key) === '0' || + document.cookie.split(';').some(function (part) { return part.trim() === key + '=0'; }); + } catch (error) { return true; } + } + + function capture() { + if (optedOut()) return; + retained = rawCookie(); + if (retained !== null) return; + try { + var entry = { + surface: 'learn', path: root.location.pathname, referrer: document.referrer || '', + ts: new Date().toISOString() + }; + var params = new URLSearchParams(root.location.search); + ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'].forEach(function (key) { + entry[key] = params.get(key) || ''; + }); + if (!/^\/(?!\/)[^?#\\\u0000-\u0020\u007f]*$/.test(entry.path)) return; + var value = encodeURIComponent(JSON.stringify({ version: 2, entry: entry })); + if (value.length > LIMIT) return; + // Recheck after constructing the record; a cookie offers no atomic cross-tab transaction. + var existing = rawCookie(); + if (existing !== null) { retained = existing; return; } + retained = value; + var expires = new Date(Date.parse(entry.ts) + 90 * 86400000).toUTCString(); + document.cookie = NAME + '=' + value + '; Domain=.netdata.cloud; Path=/; Expires=' + expires + '; SameSite=Lax; Secure'; + } catch (error) { /* Document evidence can still accompany a link when persistence is refused. */ } + } + + function envelope() { + if (optedOut() || !root.ndFirstTouchHandoff) return null; + var raw = rawCookie(); + if (raw === undefined) return null; + var current = root.ndFirstTouchHandoff.project(raw === null ? retained : raw); + if (raw === null || retained === null || retained === undefined || raw === retained) return current; + var original = root.ndFirstTouchHandoff.project(retained); + // A competing writer must not silently replace earlier document evidence. + if (!current || !original) return null; + var currentTime = Date.parse(current.entry.ts); + var originalTime = Date.parse(original.entry.ts); + if (originalTime < currentTime) return original; + if (currentTime < originalTime) return current; + return JSON.stringify(current.entry) === JSON.stringify(original.entry) ? current : null; + } + + function decorate(anchor) { + try { + var href = anchor.getAttribute('href'); + var previous = links.get(anchor); + var source = previous && previous.decorated === href ? previous.source : href; + if (!source) return; + var url = new URL(source, root.location.href); + if (url.origin !== 'https://app.netdata.cloud' || url.username || url.password) return; + var value = envelope(); + var target = source.indexOf('//') === 0 ? 'https:' + source : source; + var result = value ? root.ndFirstTouchHandoff.decorate(target, value) : source; + links.set(anchor, { source: source, decorated: result }); + if (result !== href) anchor.setAttribute('href', result); + } catch (error) { /* Attribution must never interfere with navigation. */ } + } + + function refresh(node) { + if (node.matches && node.matches('a[href]')) decorate(node); + if (node.querySelectorAll) node.querySelectorAll('a[href]').forEach(decorate); + } + + capture(); + root.ndLearnFirstTouch = { refresh: function () { refresh(document); } }; + refresh(document); + if (root.MutationObserver) { + new root.MutationObserver(function (records) { + records.forEach(function (record) { + if (record.type === 'attributes') decorate(record.target); + else record.addedNodes.forEach(refresh); + }); + }).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ['href'] }); + } + ['pointerdown', 'focusin', 'contextmenu', 'click', 'auxclick'].forEach(function (name) { + document.addEventListener(name, function (event) { + var anchor = event.target && event.target.closest && event.target.closest('a[href]'); + if (anchor) decorate(anchor); + }, true); + }); + root.addEventListener('pageshow', root.ndLearnFirstTouch.refresh); + root.addEventListener('focus', root.ndLearnFirstTouch.refresh); + if (root.cookieStore && root.cookieStore.addEventListener) { + root.cookieStore.addEventListener('change', function (event) { + if (event.changed.concat(event.deleted).some(function (cookie) { return cookie.name === NAME; })) { + root.ndLearnFirstTouch.refresh(); + } + }); + } +})(window); diff --git a/tests/first_touch_capture.test.mjs b/tests/first_touch_capture.test.mjs new file mode 100644 index 0000000000..b91f31890b --- /dev/null +++ b/tests/first_touch_capture.test.mjs @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import { JSDOM } from 'jsdom'; + +const require = createRequire(import.meta.url); +const config = require('../docusaurus.config.js'); +const handoff = readFileSync(new URL('../static/js/first-touch-handoff.js', import.meta.url), 'utf8'); +const producer = readFileSync(new URL('../static/js/first-touch.js', import.meta.url), 'utf8'); +const token = config.plugins.find(item => Array.isArray(item) && item[0] === 'posthog-docusaurus')[1].apiKey; +const timestamp = () => new Date(Date.now() - 86400000).toISOString(); +const entry = (extra = {}) => ({ surface: 'website', path: '/first', referrer: 'https://www.google.com/', + utm_source: '', utm_medium: '', utm_campaign: '', utm_content: '', utm_term: '', ts: timestamp(), ...extra }); +const encoded = data => encodeURIComponent(JSON.stringify(data)); + +function browser(t, { cookie, referrer = 'https://www.google.com/search?q=netdata', + url = 'https://learn.netdata.cloud/docs/first?utm_source=google&utm_medium=organic', before } = {}) { + const dom = new JSDOM('App', + { url, referrer, runScripts: 'outside-only' }); + t.after(() => dom.window.close()); + const { window } = dom; + if (cookie !== undefined) window.document.cookie = `nd_first_touch=${cookie}; Path=/; Secure`; + before?.(window); + window.eval(handoff); + window.eval(producer); + return window; +} +const cookieValue = w => w.document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('nd_first_touch='))?.slice(15); +const cookieData = w => JSON.parse(decodeURIComponent(cookieValue(w))); +const carried = anchor => JSON.parse(new URL(anchor.href).searchParams.get('nd_ft')); +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +const fixtures = JSON.parse(readFileSync(new URL('./fixtures/first-touch-handoff.json', import.meta.url), 'utf8')); + +test('common Website/Learn projection fixtures preserve the whole-observation contract', t => { + const w = browser(t); + for (const fixture of fixtures.cases) { + const input = JSON.stringify(fixture.input); + const output = w.ndFirstTouchHandoff.project(input, Date.parse(fixtures.now)); + if (fixture.expected === null) assert.equal(output, null, fixture.name); + else { + assert.equal(output.version, 2, fixture.name); + for (const [key, value] of Object.entries(fixture.expected)) assert.equal(output.entry[key], value, fixture.name); + assert.equal(output.entry.ts, fixture.input.entry?.ts || fixture.input.ts, fixture.name); + if (fixture.input.landing_page) assert.equal(output.landing_page, fixture.input.landing_page, fixture.name); + } + assert.equal(JSON.stringify(fixture.input), input, fixture.name); + } +}); + +test('captures whole initial Learn observation without Website roots and decorates existing links', t => { + const w = browser(t); + const data = cookieData(w); + assert.equal(data.version, 2); + assert.equal(data.entry.surface, 'learn'); + assert.equal(data.entry.path, '/docs/first'); + assert.equal(data.entry.utm_source, 'google'); + assert.equal(data.entry.referrer, 'https://www.google.com/search?q=netdata'); + assert.equal(data.landing_page, undefined); + assert.equal(data.entry.identities, undefined); + const link = w.document.querySelector('#app'); + const output = carried(link); + assert.equal(output.entry.referrer, 'https://www.google.com/search'); + assert.equal(output.entry.ts, data.entry.ts); + assert.equal(new URL(link.href).searchParams.get('utm_source'), 'learn'); + assert.equal(new URL(link.href).hash, '#keep'); +}); + +test('SPA routes, script reexecution, dynamic content and middle-click preserve original entry', async t => { + const w = browser(t); + const raw = cookieValue(w); + w.history.pushState({}, '', '/docs/later?utm_source=internal'); + w.eval(producer); + const link = w.document.createElement('a'); + link.href = 'https://app.netdata.cloud/sign-in?utm_content=dynamic'; + link.textContent = 'dynamic'; + w.document.body.append(link); + await tick(); + assert.equal(carried(link).entry.path, '/docs/first'); + assert.equal(carried(link).entry.utm_source, 'google'); + link.dispatchEvent(new w.MouseEvent('auxclick', { bubbles: true, button: 1 })); + assert.equal(cookieValue(w), raw); + assert.equal(new URL(link.href).searchParams.getAll('nd_ft').length, 1); + link.setAttribute('href', 'https://app.netdata.cloud/spaces/netdata-demo?utm_content=replaced'); + await tick(); + assert.equal(carried(link).entry.path, '/docs/first'); + assert.equal(new URL(link.href).searchParams.get('utm_content'), 'replaced'); +}); + +test('preserves v1 storage bytes and transports Website roots without upgrading expiry', t => { + const first = entry(); + const raw = encoded({ landing_page: first.path, referrer: first.referrer, ts: first.ts }); + const w = browser(t, { cookie: raw }); + assert.equal(cookieValue(w), raw); + const output = carried(w.document.querySelector('#app')); + assert.equal(output.entry.surface, 'website'); + assert.equal(output.entry.path, '/first'); + assert.equal(output.landing_page, '/first'); + assert.equal(output.ts, first.ts); +}); + +test('preserves v2 observation, identities and original cookie expiry without any write', t => { + const first = entry({ surface: 'community', path: '/t/example' }); + first.identities = [{ distinct_id: 'anonymous-test-id', project_token: 'public-test-token', + surface: 'community', status: 'anonymous', observed_at: first.ts }]; + const raw = encoded({ version: 2, entry: first }); + const writes = []; + const w = browser(t, { before(window) { + Object.defineProperty(window.document, 'cookie', { get: () => `nd_first_touch=${raw}`, set: value => writes.push(value) }); + } }); + assert.deepEqual(writes, []); + assert.deepEqual(carried(w.document.querySelector('#app')).entry, first); +}); + +for (const [label, raw] of [ + ['unsupported version', encoded({ version: 99, entry: entry() })], + ['malformed value', '%broken'], + ['future entry', encoded({ version: 2, entry: entry({ ts: '2099-01-01T00:00:00.000Z' }) })], + ['expired entry', encoded({ version: 2, entry: entry({ ts: '2020-01-01T00:00:00.000Z' }) })], + ['private sender', encoded({ version: 2, entry: entry({ referrer: 'http://192.168.1.1:19999/' }) })], + ['oversized value', encoded({ version: 2, entry: entry({ utm_content: 'x'.repeat(4000) }) })], + ['invalid root', encoded({ version: 2, entry: entry(), landing_page: '/later', ts: 'invalid' })], +]) test(`${label} remains untouched and is not replaced with invented evidence`, t => { + const w = browser(t, { cookie: raw }); + assert.equal(cookieValue(w), raw); + assert.equal(carried(w.document.querySelector('#app')), null); +}); + +test('refused writes retain initial document evidence; unreadable storage fails closed', t => { + const w = browser(t, { before(window) { + Object.defineProperty(window.document, 'cookie', { get: () => '', set: () => {} }); + } }); + assert.equal(cookieValue(w), undefined); + assert.equal(carried(w.document.querySelector('#app')).entry.surface, 'learn'); + const blocked = browser(t, { before(window) { + Object.defineProperty(window.document, 'cookie', { get: () => { throw new Error('denied'); } }); + } }); + assert.equal(carried(blocked.document.querySelector('#app')), null); +}); + +for (const kind of ['localStorage', 'cookie', 'sdk']) test(`${kind} opt-out prevents capture and handoff`, t => { + const w = browser(t, { before(window) { + if (kind === 'localStorage') window.localStorage.setItem(`__ph_opt_in_out_${token}`, '0'); + if (kind === 'cookie') window.document.cookie = `__ph_opt_in_out_${token}=0; Path=/`; + if (kind === 'sdk') window.posthog = { __loaded: true, has_opted_out_capturing: () => true }; + } }); + assert.equal(cookieValue(w), undefined); + assert.equal(carried(w.document.querySelector('#app')), null); +}); + +test('opting out after decoration restores the original href without changing the cookie', t => { + const w = browser(t); + const before = cookieValue(w); + w.localStorage.setItem(`__ph_opt_in_out_${token}`, '0'); + w.ndLearnFirstTouch.refresh(); + assert.equal(carried(w.document.querySelector('#app')), null); + assert.equal(cookieValue(w), before); +}); + +test('competing cookie writes cannot replace earlier document evidence or hide a tied conflict', t => { + const first = entry({ surface: 'learn', path: '/original' }); + const w = browser(t, { cookie: encoded({ version: 2, entry: first }) }); + const later = { ...first, path: '/later', ts: new Date(Date.parse(first.ts) + 1000).toISOString() }; + w.document.cookie = `nd_first_touch=${encoded({ version: 2, entry: later })}; Path=/; Secure`; + w.ndLearnFirstTouch.refresh(); + assert.equal(carried(w.document.querySelector('#app')).entry.path, '/original'); + const conflicting = { ...first, path: '/conflict' }; + w.document.cookie = `nd_first_touch=${encoded({ version: 2, entry: conflicting })}; Path=/; Secure`; + w.ndLearnFirstTouch.refresh(); + assert.equal(carried(w.document.querySelector('#app')), null); +}); + +test('only exact App HTTPS links are eligible; preview and localhost do not capture', async t => { + const w = browser(t); + for (const href of ['http://app.netdata.cloud/', 'https://app.netdata.cloud.evil.example/', + 'https://user@app.netdata.cloud/', 'https://app.netdata.cloud:444/', '/docs/next']) { + const a = w.document.createElement('a'); a.setAttribute('href', href); w.document.body.append(a); + await tick(); + assert.equal(a.getAttribute('href'), href); + } + for (const url of ['http://localhost:3000/docs/a', 'https://deploy-preview-3079--learn.netlify.app/docs/a']) { + const other = browser(t, { url }); + assert.equal(cookieValue(other), undefined); + assert.equal(other.ndLearnFirstTouch, undefined); + } +}); + +test('Docusaurus head and standalone API use the same synchronous scripts; OAuth utility does not', () => { + const scripts = config.scripts.filter(item => item.src?.includes('first-touch')); + assert.deepEqual(scripts.map(item => item.src), ['/js/first-touch-handoff.js', '/js/first-touch.js']); + assert.ok(scripts.every(item => item.async === false && item.defer === false)); + const api = readFileSync(new URL('../static/api.html', import.meta.url), 'utf8'); + assert.ok(api.indexOf('first-touch-handoff.js') < api.indexOf('first-touch.js')); + assert.ok(api.indexOf('first-touch.js') < api.indexOf('')); + assert.ok(!readFileSync(new URL('../static/oauth2-redirect.html', import.meta.url), 'utf8').includes('first-touch')); +}); + +test('preserves query bytes, fragments and existing envelopes, with a complete URL size cap', async t => { + const w = browser(t); + for (const prefix of ['https:', '']) { + const a = w.document.createElement('a'); + const original = `${prefix}//app.netdata.cloud/sign-in?redirect_uri=a%20b&c=%2f+%2B#x%2fy`; + a.setAttribute('href', original); w.document.body.append(a); + await tick(); + assert.ok(a.href.includes('?redirect_uri=a%20b&c=%2f+%2B&nd_ft=')); + assert.ok(a.href.endsWith('#x%2fy')); + } + for (const original of ['https://app.netdata.cloud/?nd_ft=preserve-existing', + `https://app.netdata.cloud/?long=${'x'.repeat(3700)}`]) { + const a = w.document.createElement('a'); a.href = original; w.document.body.append(a); + await tick(); + assert.equal(a.href, original); + } +}); diff --git a/tests/fixtures/first-touch-handoff.json b/tests/fixtures/first-touch-handoff.json new file mode 100644 index 0000000000..c92d12418e --- /dev/null +++ b/tests/fixtures/first-touch-handoff.json @@ -0,0 +1,45 @@ +{ + "now": "2026-09-17T12:00:00.000Z", + "cases": [ + { + "name": "legacy Website projection", + "input": {"landing_page": "/guides/example/", "referrer": "https://search.example/?q=netdata", "ts": "2026-09-01T10:00:00.000Z"}, + "expected": {"surface": "website", "path": "/guides/example/", "referrer": "https://search.example/"} + }, + { + "name": "Learn first observation", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/docs/getting-started", "referrer": "https://search.example/", "ts": "2026-09-01T10:00:00.000Z"}}, + "expected": {"surface": "learn", "path": "/docs/getting-started", "referrer": "https://search.example/"} + }, + { + "name": "Learn entry with later Website roots", + "input": {"version": 2, "landing_page": "/guides/example/", "referrer": "https://learn.netdata.cloud/docs/getting-started", "ts": "2026-09-02T10:00:00.000Z", "entry": {"surface": "learn", "path": "/docs/getting-started", "referrer": "https://search.example/", "ts": "2026-09-01T10:00:00.000Z"}}, + "expected": {"surface": "learn", "path": "/docs/getting-started", "referrer": "https://search.example/"} + }, + { + "name": "public sender query projection", + "input": {"version": 2, "entry": {"surface": "website", "path": "/", "referrer": "https://search.example/search?q=netdata#result", "ts": "2026-09-01T10:00:00.000Z"}}, + "expected": {"surface": "website", "path": "/", "referrer": "https://search.example/search"} + }, + { + "name": "unsupported cookie is not upgraded", + "input": {"version": 3, "entry": {"surface": "learn", "path": "/", "ts": "2026-09-01T10:00:00.000Z"}}, + "expected": null + }, + { + "name": "private sender is not blanked", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/", "referrer": "http://192.168.1.1:19999/", "ts": "2026-09-01T10:00:00.000Z"}}, + "expected": null + }, + { + "name": "future timestamp is ineligible", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/", "ts": "2026-09-18T10:00:00.000Z"}}, + "expected": null + }, + { + "name": "expired timestamp is ineligible", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/", "ts": "2026-01-01T10:00:00.000Z"}}, + "expected": null + } + ] +} From e81bb688be80477889d5279850b3e5468afd4b51 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Thu, 17 Sep 2026 21:50:20 +0300 Subject: [PATCH 3/4] Restrict attribution decoration to navigable anchors --- FIRST-TOUCH-ATTRIBUTION.md | 2 ++ FIRST-TOUCH-IMPLEMENTATION.md | 28 ++++++++++++++++++++++--- static/js/first-touch.js | 1 + tests/first_touch_capture.test.mjs | 18 ++++++++++++++++ tests/fixtures/first-touch-handoff.json | 10 +++++++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/FIRST-TOUCH-ATTRIBUTION.md b/FIRST-TOUCH-ATTRIBUTION.md index f32d4b03d0..0ccc74880a 100644 --- a/FIRST-TOUCH-ATTRIBUTION.md +++ b/FIRST-TOUCH-ATTRIBUTION.md @@ -19,6 +19,8 @@ Every existing cookie remains byte-for-byte unchanged, including v1, unsupported malformed records and earlier observations. Reading does not renew expiry. A supported v1 record can be projected into a v2 URL envelope without physically upgrading the cookie. No SDK identities are collected, reset or merged; valid already-associated candidates are retained by projection. +Identity `observed_at` must be canonical UTC ISO syntax. It is diagnostic metadata, not the +first-touch timestamp: the App contract does not use its age to expire, order or merge observations. ## Links and authentication boundary diff --git a/FIRST-TOUCH-IMPLEMENTATION.md b/FIRST-TOUCH-IMPLEMENTATION.md index dc85a6ee43..210b79cced 100644 --- a/FIRST-TOUCH-IMPLEMENTATION.md +++ b/FIRST-TOUCH-IMPLEMENTATION.md @@ -33,7 +33,28 @@ links, destination safety and query preservation. Test output against Cloud's ex Run existing Learn tests and production build/gates without weakening baseline rules. Public deployment and synthetic-account validation are separate acceptance work, not local test claims. -## Rollback +## Review-qualified resource-link repair — 2026-09-17 + +Review comment 4040311529 reproduces: an attribute mutation on a non-anchor resource link +receives `nd_ft`. The approved repair guards the central decorator with `a[href]`, covering +every invocation without changing capture or anchor behavior. A native DOM regression fails +before the guard; resource-link and SVG-reference mutations must remain byte-identical afterward. +Website/Fleet already reject non-anchor targets in `isAppLink`. + +Comments 4040290361 and 4040311545 propose identity timestamp eligibility not present in the +App reader contract. `firstTouchCookie.js:validIdentity` requires canonical finite `observed_at`; +`firstTouchHandoff.js:readFirstTouchHandoff` applies age/signup eligibility only to entry and +Website-root `ts`. Identity candidates remain diagnostic, not ordering or merge authority. +Shared conformance fixtures retain valid diagnostic candidates independently of entry age. + +Qualification: 26 first-touch native checks pass on Node 22.23.2. The resource mutation regression +fails before the guard and passes afterward; intercepted Chromium confirms resource URLs remain +unchanged while real anchors are decorated on both Learn and Website. Both identity cases also +pass the actual App reader with metadata unchanged. The shared helper runtime remains byte-identical. +Before publishing this repair, all checks on implementation head `f64efcd784b67e9f74838dc747e4ed03b8a582ad` +were complete and passing (Netlify informational checks neutral). No build-policy changes are made. + +## Rollback procedure Remove the script inclusions to disable capture and decoration; existing cookie contents and analytics initialization remain unchanged. Do not delete or reset cookies during rollback. @@ -58,9 +79,10 @@ analytics initialization remain unchanged. Do not delete or reset cookies during Both rendered artifacts contain the scripts in order without async/defer attributes. - The pure handoff helper is byte-identical to the Website copy (SHA-256 `ede4fb9c66c4301f24a2b584ab2a8912ad922919f2113c336446a347fe6c6013`). -- `tests/fixtures/first-touch-handoff.json` defines eight shared Website/Learn projection cases: +- `tests/fixtures/first-touch-handoff.json` defines ten shared Website/Learn projection cases: legacy Website, Learn, Learn with Website roots, safe sender projection, unsupported versions, - private senders, future timestamps and expired timestamps. Native tests execute the actual helper. + private senders, future timestamps, expired timestamps and future/older diagnostic identity metadata. + Native tests execute the actual helper. App-reader/auth interoperability and deployed signup reconciliation belong to the coordinated release evidence; no local result certifies backend deployment or whole-history identity linkage. diff --git a/static/js/first-touch.js b/static/js/first-touch.js index c6a6180317..3af2f6e282 100644 --- a/static/js/first-touch.js +++ b/static/js/first-touch.js @@ -69,6 +69,7 @@ function decorate(anchor) { try { + if (!anchor || !anchor.matches || !anchor.matches('a[href]')) return; var href = anchor.getAttribute('href'); var previous = links.get(anchor); var source = previous && previous.decorated === href ? previous.source : href; diff --git a/tests/first_touch_capture.test.mjs b/tests/first_touch_capture.test.mjs index b91f31890b..da91b084bf 100644 --- a/tests/first_touch_capture.test.mjs +++ b/tests/first_touch_capture.test.mjs @@ -42,6 +42,7 @@ test('common Website/Learn projection fixtures preserve the whole-observation co assert.equal(output.version, 2, fixture.name); for (const [key, value] of Object.entries(fixture.expected)) assert.equal(output.entry[key], value, fixture.name); assert.equal(output.entry.ts, fixture.input.entry?.ts || fixture.input.ts, fixture.name); + if (fixture.input.entry?.identities) assert.deepEqual(JSON.parse(JSON.stringify(output.entry.identities)), fixture.input.entry.identities, fixture.name); if (fixture.input.landing_page) assert.equal(output.landing_page, fixture.input.landing_page, fixture.name); } assert.equal(JSON.stringify(fixture.input), input, fixture.name); @@ -99,6 +100,23 @@ test('preserves v1 storage bytes and transports Website roots without upgrading assert.equal(output.ts, first.ts); }); +test('href mutations decorate anchors but leave resource links and SVG references untouched', async t => { + const w = browser(t); + for (const element of [w.document.createElement('link'), + w.document.createElementNS('http://www.w3.org/2000/svg', 'use')]) { + w.document.body.append(element); + await tick(); + const href = 'https://app.netdata.cloud/resource?preserve=%20#asset'; + element.setAttribute('href', href); + await tick(); + assert.equal(element.getAttribute('href'), href, element.tagName); + } + const anchor = w.document.querySelector('#app'); + anchor.setAttribute('href', 'https://app.netdata.cloud/sign-up?changed=1'); + await tick(); + assert.equal(carried(anchor).entry.path, '/docs/first'); +}); + test('preserves v2 observation, identities and original cookie expiry without any write', t => { const first = entry({ surface: 'community', path: '/t/example' }); first.identities = [{ distinct_id: 'anonymous-test-id', project_token: 'public-test-token', diff --git a/tests/fixtures/first-touch-handoff.json b/tests/fixtures/first-touch-handoff.json index c92d12418e..a2cde9ee01 100644 --- a/tests/fixtures/first-touch-handoff.json +++ b/tests/fixtures/first-touch-handoff.json @@ -1,6 +1,16 @@ { "now": "2026-09-17T12:00:00.000Z", "cases": [ + { + "name": "future diagnostic identity does not replace entry chronology", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/", "ts": "2026-09-01T10:00:00.000Z", "identities": [{"distinct_id": "synthetic-id", "project_token": "synthetic-token", "surface": "learn", "status": "anonymous", "observed_at": "2099-01-01T00:00:00.000Z"}]}}, + "expected": {"surface": "learn", "path": "/"} + }, + { + "name": "older diagnostic identity does not expire valid entry", + "input": {"version": 2, "entry": {"surface": "learn", "path": "/", "ts": "2026-09-01T10:00:00.000Z", "identities": [{"distinct_id": "synthetic-id", "project_token": "synthetic-token", "surface": "learn", "status": "anonymous", "observed_at": "2020-01-01T00:00:00.000Z"}]}}, + "expected": {"surface": "learn", "path": "/"} + }, { "name": "legacy Website projection", "input": {"landing_page": "/guides/example/", "referrer": "https://search.example/?q=netdata", "ts": "2026-09-01T10:00:00.000Z"}, From 6d0c8491bcf7f7a9d05e90c63a2a1fa445212374 Mon Sep 17 00:00:00 2001 From: Costa Tsaousis Date: Fri, 18 Sep 2026 11:10:00 +0300 Subject: [PATCH 4/4] Make attribution tests detect missed activation and cookie renewal --- FIRST-TOUCH-IMPLEMENTATION.md | 19 ++++++++++++++++++- tests/first_touch_capture.test.mjs | 24 ++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/FIRST-TOUCH-IMPLEMENTATION.md b/FIRST-TOUCH-IMPLEMENTATION.md index 210b79cced..ef59188a5d 100644 --- a/FIRST-TOUCH-IMPLEMENTATION.md +++ b/FIRST-TOUCH-IMPLEMENTATION.md @@ -54,7 +54,24 @@ pass the actual App reader with metadata unchanged. The shared helper runtime re Before publishing this repair, all checks on implementation head `f64efcd784b67e9f74838dc747e4ed03b8a582ad` were complete and passing (Netlify informational checks neutral). No build-policy changes are made. -## Rollback procedure +## Test sensitivity repair — 2026-09-18 + +Review comments 4040420748 and 4040420754 identify verified coverage gaps: removing the auxclick +handler or injecting an existing-cookie renewal still passes the respective tests. Strengthen the +middle-click test to assert synchronous decoration before observer delivery. Seed a real expiring +cookie in the DOM cookie jar, intercept producer writes, and compare expiry after handoff/refresh. +Apply the same inherited test corrections to the offline Community tests; runtime bytes are unchanged. + +Qualification on Node 22.23.2: all 26 Learn native checks and all 30 offline Community checks pass. +Four in-memory mutation controls fail for the intended reason: disabling the auxclick listener +leaves the synchronous link undecorated; injecting a same-value 90-day cookie renewal triggers the +write assertion, on both surfaces. Neither mutation writes runtime files. Both original Learn tests +pass those deliberately broken variants, establishing their prior insensitivity. Community desktop +and mobile Chromium cases also assert decoration in the same JavaScript turn as insertion/auxclick. +All PR checks on `e81bb688be80477889d5279850b3e5468afd4b51` were complete and passing before this +tests-only publication; no production rebuild is needed to qualify unchanged runtime bytes. + +## Runtime rollback Remove the script inclusions to disable capture and decoration; existing cookie contents and analytics initialization remain unchanged. Do not delete or reset cookies during rollback. diff --git a/tests/first_touch_capture.test.mjs b/tests/first_touch_capture.test.mjs index da91b084bf..9011977481 100644 --- a/tests/first_touch_capture.test.mjs +++ b/tests/first_touch_capture.test.mjs @@ -21,7 +21,7 @@ function browser(t, { cookie, referrer = 'https://www.google.com/search?q=netdat t.after(() => dom.window.close()); const { window } = dom; if (cookie !== undefined) window.document.cookie = `nd_first_touch=${cookie}; Path=/; Secure`; - before?.(window); + before?.(window, dom.cookieJar); window.eval(handoff); window.eval(producer); return window; @@ -76,10 +76,11 @@ test('SPA routes, script reexecution, dynamic content and middle-click preserve link.href = 'https://app.netdata.cloud/sign-in?utm_content=dynamic'; link.textContent = 'dynamic'; w.document.body.append(link); - await tick(); + assert.equal(carried(link), null); + // Dispatch before the mutation observer can decorate the inserted anchor. + link.dispatchEvent(new w.MouseEvent('auxclick', { bubbles: true, button: 1 })); assert.equal(carried(link).entry.path, '/docs/first'); assert.equal(carried(link).entry.utm_source, 'google'); - link.dispatchEvent(new w.MouseEvent('auxclick', { bubbles: true, button: 1 })); assert.equal(cookieValue(w), raw); assert.equal(new URL(link.href).searchParams.getAll('nd_ft').length, 1); link.setAttribute('href', 'https://app.netdata.cloud/spaces/netdata-demo?utm_content=replaced'); @@ -91,13 +92,28 @@ test('SPA routes, script reexecution, dynamic content and middle-click preserve test('preserves v1 storage bytes and transports Website roots without upgrading expiry', t => { const first = entry(); const raw = encoded({ landing_page: first.path, referrer: first.referrer, ts: first.ts }); - const w = browser(t, { cookie: raw }); + const expires = new Date(Date.now() + 3600000).toUTCString(); + const writes = []; + let jar; + const w = browser(t, { before(window, cookieJar) { + jar = cookieJar; + window.document.cookie = `nd_first_touch=${raw}; Path=/; Secure; Expires=${expires}`; + const access = Object.getOwnPropertyDescriptor(window.Document.prototype, 'cookie'); + Object.defineProperty(window.document, 'cookie', { + get: () => access.get.call(window.document), + set: value => { writes.push(value); access.set.call(window.document, value); } + }); + } }); assert.equal(cookieValue(w), raw); const output = carried(w.document.querySelector('#app')); assert.equal(output.entry.surface, 'website'); assert.equal(output.entry.path, '/first'); assert.equal(output.landing_page, '/first'); assert.equal(output.ts, first.ts); + w.dispatchEvent(new w.Event('focus')); + assert.deepEqual(writes, [], 'reading and refreshing existing attribution must not write cookies'); + const stored = jar.getCookiesSync(w.location.href).find(cookie => cookie.key === 'nd_first_touch'); + assert.equal(stored.expires.toUTCString(), expires); }); test('href mutations decorate anchors but leave resource links and SVG references untouched', async t => {