Skip to content
Draft
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
108 changes: 108 additions & 0 deletions FIRST-TOUCH-ATTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 69 additions & 0 deletions src/theme/Root/firstTouchBaseline.test.js
Original file line number Diff line number Diff line change
@@ -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(<Root><p>Documentation</p></Root>);

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(<Root><p>First documentation page</p></Root>);

navigate('/docs/netdata-agent/installation', '?utm_source=learn');
page.rerender(<Root><p>Installation</p></Root>);

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(<Root><p>Documentation</p></Root>);

navigate('/docs/ask-nedi', '?utm_source=learn');
page.rerender(<Root><p>Ask Nedi</p></Root>);

expect(document.cookie).toBe(websiteCookie);
expect(writeCookie).not.toHaveBeenCalled();
});
});
86 changes: 86 additions & 0 deletions tests/first_touch_producer_baseline.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});