Skip to content

Commit 46aff9b

Browse files
committed
feat(docs): connect optional tracking with website
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent dd95cc2 commit 46aff9b

12 files changed

Lines changed: 1063 additions & 12 deletions

‎docs/site/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ them into the Pages artifact. Earlier Pages-only fallback bundles age out with t
9292
machine-readable corpora are sealed into the canonical-root release tree, so the canonical site does
9393
not depend on unreleased `/dev/` content.
9494

95+
## Optional visit tracking
96+
97+
Canonical released pages offer optional tracking shared with the main website.
98+
The hidden main-origin bridge owns the version-2 consent choice and native Mautic
99+
identity. Documentation sends minimized public page views and cannot request an
100+
identifier. Rejection, expiry, privacy signals, or a blocked bridge keep optional
101+
tracking off. Development pages, versioned archives, and error pages do not take
102+
part. Tracking acceptance does not subscribe anyone to marketing email.
103+
104+
The website and docs copies of `src/lib/shared-tracking-client.mjs` and
105+
`src/lib/tracking-protocol.mjs` must stay identical. Coordinate changes with the
106+
website's bridge before publishing a new docs-bearing release. Merging docs
107+
source changes alone cannot change the sealed canonical-root release asset.
108+
109+
Run the docs tests and build checks, then the website repository's
110+
`scripts/check-marketing-tracking.mjs` with `DOCS_TRACKING_DIST` set to the
111+
canonical docs build directory. Set `TRACKING_BROWSERS=chromium,firefox,webkit`
112+
for the complete browser matrix. That check intercepts network requests and
113+
verifies the built website to docs to starter request journey. Native Mautic
114+
acceptance is a separate isolated fixture check in the website repository.
115+
95116
## Content Sources
96117

97118
Data-backed reference tables are generated from:

‎docs/site/scripts/analytics.test.mjs‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,18 +45,23 @@ test('canonical release analytics uses source-controlled defaults', () => {
4545
);
4646
});
4747

48-
async function builtRoot(t, body) {
48+
const trackingBridge = 'https://registrystack.org/tracking-bridge/';
49+
const trackingPreferences =
50+
`<aside data-registry-shared-tracking data-shared-tracking-bridge-url="${trackingBridge}"></aside>`;
51+
52+
async function builtRoot(t, head, body = '') {
4953
const root = await mkdtemp(resolve(tmpdir(), 'registry-docs-analytics-'));
5054
t.after(() => rm(root, { recursive: true, force: true }));
5155
await mkdir(root, { recursive: true });
52-
await writeFile(resolve(root, 'index.html'), `<html><head>${body}</head></html>`);
56+
await writeFile(resolve(root, 'index.html'), `<html><head>${head}</head><body>${body}</body></html>`);
5357
return root;
5458
}
5559

5660
test('built canonical root contains the exact Registry Docs tracker', async (t) => {
5761
const root = await builtRoot(
5862
t,
5963
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
64+
trackingPreferences,
6065
);
6166
await checkBuiltAnalytics(root, { enabled: true });
6267
});
@@ -65,13 +70,38 @@ test('built canonical root rejects a different website identity', async (t) => {
6570
const root = await builtRoot(
6671
t,
6772
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="wrong-id" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
73+
trackingPreferences,
6874
);
6975
await assert.rejects(
7076
checkBuiltAnalytics(root, { enabled: true }),
7177
/source-controlled Registry Docs tracker/,
7278
);
7379
});
7480

81+
test('built canonical root requires the pinned shared tracking bridge', async (t) => {
82+
const root = await builtRoot(
83+
t,
84+
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}" data-domains="${DOCS_UMAMI_DOMAINS}"></script>`,
85+
'<aside data-registry-shared-tracking data-shared-tracking-bridge-url="https://other.example.org/tracking-bridge/"></aside>',
86+
);
87+
await assert.rejects(
88+
checkBuiltAnalytics(root, { enabled: true }),
89+
/source-controlled shared tracking bridge/,
90+
);
91+
});
92+
93+
test('built canonical root rejects a native Mautic tracker outside the bridge', async (t) => {
94+
const root = await builtRoot(
95+
t,
96+
`<script defer src="${DOCS_UMAMI_SCRIPT_SRC}" data-website-id="${DOCS_UMAMI_WEBSITE_ID}" data-domains="${DOCS_UMAMI_DOMAINS}"></script><script src="https://mautic.example.org/mtc.js"></script>`,
97+
trackingPreferences,
98+
);
99+
await assert.rejects(
100+
checkBuiltAnalytics(root, { enabled: true }),
101+
/must not load Mautic directly/,
102+
);
103+
});
104+
75105
test('noncanonical builds reject analytics', async (t) => {
76106
const root = await builtRoot(
77107
t,
@@ -82,3 +112,11 @@ test('noncanonical builds reject analytics', async (t) => {
82112
/must not contain analytics outside the canonical released tree/,
83113
);
84114
});
115+
116+
test('noncanonical builds reject shared tracking controls', async (t) => {
117+
const root = await builtRoot(t, '', trackingPreferences);
118+
await assert.rejects(
119+
checkBuiltAnalytics(root, { enabled: false }),
120+
/must not contain shared tracking outside the canonical released tree/,
121+
);
122+
});

‎docs/site/scripts/check-built-analytics.mjs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
DOCS_UMAMI_SCRIPT_SRC,
1010
DOCS_UMAMI_WEBSITE_ID,
1111
} from '../src/lib/analytics.mjs';
12+
import { SHARED_TRACKING_BRIDGE_URL } from '../src/lib/tracking-protocol.mjs';
1213

1314
function attributes(node) {
1415
return Object.fromEntries((node.attrs ?? []).map(({ name, value }) => [name, value]));
@@ -20,19 +21,44 @@ function scriptAttributes(node, found = []) {
2021
return found;
2122
}
2223

24+
function elementAttributes(node, attribute, found = []) {
25+
const attrs = attributes(node);
26+
if (attribute in attrs) found.push(attrs);
27+
for (const child of node.childNodes ?? []) elementAttributes(child, attribute, found);
28+
return found;
29+
}
30+
2331
export async function checkBuiltAnalytics(root, { enabled }) {
2432
const indexPath = resolve(root, 'index.html');
2533
const document = parse(await readFile(indexPath, 'utf8'));
2634
const analyticsScripts = scriptAttributes(document).filter(
2735
(attrs) => attrs.src === DOCS_UMAMI_SCRIPT_SRC || attrs['data-website-id'],
2836
);
37+
const directMauticScripts = scriptAttributes(document).filter((attrs) =>
38+
/\/mtc\.js(?:[?#]|$)/.test(attrs.src ?? ''),
39+
);
40+
const sharedTrackingControls = elementAttributes(
41+
document,
42+
'data-registry-shared-tracking',
43+
);
44+
45+
assert.deepEqual(
46+
directMauticScripts,
47+
[],
48+
`${indexPath} must not load Mautic directly`,
49+
);
2950

3051
if (!enabled) {
3152
assert.deepEqual(
3253
analyticsScripts,
3354
[],
3455
`${indexPath} must not contain analytics outside the canonical released tree`,
3556
);
57+
assert.deepEqual(
58+
sharedTrackingControls,
59+
[],
60+
`${indexPath} must not contain shared tracking outside the canonical released tree`,
61+
);
3662
return;
3763
}
3864

@@ -51,4 +77,14 @@ export async function checkBuiltAnalytics(root, { enabled }) {
5177
},
5278
`${indexPath} must contain the source-controlled Registry Docs tracker`,
5379
);
80+
assert.equal(
81+
sharedTrackingControls.length,
82+
1,
83+
`${indexPath} must contain exactly one shared tracking control`,
84+
);
85+
assert.equal(
86+
sharedTrackingControls[0]['data-shared-tracking-bridge-url'],
87+
SHARED_TRACKING_BRIDGE_URL,
88+
`${indexPath} must contain the source-controlled shared tracking bridge`,
89+
);
5490
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import assert from 'node:assert/strict';
2+
import { readFile } from 'node:fs/promises';
3+
import { test } from 'node:test';
4+
5+
import { buildMauticPageview } from '../src/lib/shared-tracking-client.mjs';
6+
import { docsSharedTrackingEnabled } from '../src/lib/docs-shared-tracking.mjs';
7+
8+
const root = new URL('../', import.meta.url);
9+
const componentUrl = new URL('src/components/RegistryTrackingPreferences.astro', root);
10+
const footerUrl = new URL('src/components/RegistryFooter.astro', root);
11+
const headUrl = new URL('src/components/RegistryHead.astro', root);
12+
const privacyUrl = new URL('src/content/docs/privacy.mdx', root);
13+
14+
test('shared tracking is enabled only for canonical released documentation pages', () => {
15+
assert.equal(
16+
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/' }),
17+
true,
18+
);
19+
assert.equal(
20+
docsSharedTrackingEnabled({
21+
isReleasedArchiveBuild: true,
22+
base: '/',
23+
path: '/products/base-registry/',
24+
}),
25+
true,
26+
);
27+
assert.equal(
28+
docsSharedTrackingEnabled({ isReleasedArchiveBuild: false, base: '/dev/', path: '/' }),
29+
false,
30+
);
31+
assert.equal(
32+
docsSharedTrackingEnabled({
33+
isReleasedArchiveBuild: true,
34+
base: '/v/0.43.0/',
35+
path: '/',
36+
}),
37+
false,
38+
);
39+
assert.equal(
40+
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/404/' }),
41+
false,
42+
);
43+
assert.equal(
44+
docsSharedTrackingEnabled({ isReleasedArchiveBuild: true, base: '/', path: '/404' }),
45+
false,
46+
);
47+
});
48+
49+
test('page views use public canonical data and only reviewed external campaign labels', () => {
50+
assert.deepEqual(
51+
buildMauticPageview({
52+
canonicalUrl: 'https://docs.registrystack.org/start/concepts/?private=yes#record',
53+
currentUrl:
54+
'https://docs.registrystack.org/start/concepts/?utm_source=partner&utm_medium=referral&utm_campaign=registry.review&utm_content=guide&utm_term=records&search=person%40example.org',
55+
pageTitle: ' How the products fit together ',
56+
referrer: 'https://partner.example.org/report/?person=someone#private',
57+
}),
58+
{
59+
page_url: 'https://docs.registrystack.org/start/concepts/',
60+
page_title: 'How the products fit together',
61+
page_referrer: 'https://partner.example.org',
62+
utm_source: 'partner',
63+
utm_medium: 'referral',
64+
utm_campaign: 'registry.review',
65+
utm_content: 'guide',
66+
utm_term: 'records',
67+
},
68+
);
69+
70+
assert.deepEqual(
71+
buildMauticPageview({
72+
canonicalUrl: 'https://docs.registrystack.org/start/concepts/',
73+
currentUrl:
74+
'https://docs.registrystack.org/start/concepts/?utm_source=registrystack&utm_medium=site&utm_campaign=cross_site&utm_content=%2Fpricing%2F',
75+
pageTitle: 'How the products fit together',
76+
referrer: 'https://registrystack.org/pricing/?email=person%40example.org',
77+
}),
78+
{
79+
page_url: 'https://docs.registrystack.org/start/concepts/',
80+
page_title: 'How the products fit together',
81+
page_referrer: 'https://registrystack.org',
82+
},
83+
);
84+
});
85+
86+
test('tracking controls use the shared bridge and remain available after failure', async () => {
87+
const [component, footer, head, privacy] = await Promise.all([
88+
readFile(componentUrl, 'utf8'),
89+
readFile(footerUrl, 'utf8'),
90+
readFile(headUrl, 'utf8'),
91+
readFile(privacyUrl, 'utf8'),
92+
]);
93+
94+
assert.match(component, /SHARED_TRACKING_BRIDGE_URL/);
95+
assert.match(component, /createSharedTrackingClient/);
96+
assert.match(component, /buildMauticPageview/);
97+
assert.match(component, /trackingDeniedByBrowser/);
98+
assert.match(component, /new URL\(window\.location\.pathname, canonicalOrigin\)/);
99+
assert.match(component, /Accept optional cookies/);
100+
assert.match(component, /Reject optional cookies/);
101+
assert.match(component, /main Registry Stack website and this\s+documentation/i);
102+
assert.match(component, /data-tracking-error[^>]*role="alert"/);
103+
assert.match(component, /client\.pageview\(pageview\)/);
104+
assert.match(component, /let sentGeneration: string \| null = null/);
105+
assert.match(component, /let pendingGeneration: string \| null = null/);
106+
assert.match(component, /state\.generation === sentGeneration/);
107+
assert.match(component, /state\.generation === pendingGeneration/);
108+
assert.match(component, /sentGeneration = state\.generation/);
109+
assert.match(component, /currentState\.generation === state\.generation/);
110+
assert.match(component, /addEventListener\('pagehide', \(event\) => \{/);
111+
assert.match(component, /if \(!event\.persisted\) trackingClient\.close\(\)/);
112+
assert.doesNotMatch(component, /location\.reload/);
113+
assert.doesNotMatch(component, /mautic_device_id|mtc_id|\/mtc\.js/);
114+
115+
assert.match(footer, /<RegistryTrackingPreferences/);
116+
assert.match(footer, /canonicalOrigin=\{canonicalOrigin\}/);
117+
assert.match(footer, /route\.entry\.id === '404'/);
118+
assert.match(footer, /route\.entry\.id\.endsWith\('\/404'\)/);
119+
assert.match(footer, /path: '\/'/);
120+
assert.match(footer, /data-tracking-preferences-open/);
121+
assert.match(footer, /data-tracking-preferences-open hidden/);
122+
assert.match(footer, />\s*Tracking preferences\s*</);
123+
assert.match(footer, /docsSharedTrackingEnabled/);
124+
125+
assert.doesNotMatch(
126+
head,
127+
/targetSite === 'marketing' \|\| targetSite === 'lab'/,
128+
);
129+
assert.match(head, /targetSite === 'lab'/);
130+
assert.match(head, /window\.umami\?\.track/);
131+
132+
assert.match(privacy, /main Registry Stack website and Registry\s+Docs/i);
133+
assert.match(privacy, /tracking bridge/i);
134+
assert.match(privacy, /six months/i);
135+
assert.match(privacy, /Do Not Track and Global Privacy Control/i);
136+
assert.match(privacy, /\/dev\/[\s\S]*\/v\/<version>\/[\s\S]*do not load/i);
137+
});

‎docs/site/src/components/RegistryFooter.astro‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
22
import docsetsManifest from '../data/generated/docsets.json';
3+
import RegistryTrackingPreferences from './RegistryTrackingPreferences.astro';
4+
import { docsSharedTrackingEnabled } from '../lib/docs-shared-tracking.mjs';
35
import { reviewStatusLabel } from '../lib/review-status.mjs';
46
57
const base = import.meta.env.BASE_URL;
@@ -8,6 +10,8 @@ const activeDocset =
810
docsetsManifest.docsets.find((docset) => docset.id === activeDocsetId) ??
911
docsetsManifest.docsets.find((docset) => docset.id === docsetsManifest.current);
1012
const isArchived = activeDocset?.status === 'archived';
13+
const isReleasedArchiveBuild =
14+
isArchived && process.env.DOCS_RELEASED_ARCHIVE === 'true';
1115
1216
// Per-page affordances driven by the route. On the built-in 404 page `entry`
1317
// (and therefore its frontmatter) may be undefined, so every field is read
@@ -27,6 +31,15 @@ const hasPagination = Boolean(prev || next);
2731
2832
const pageTitle = entryData?.title ?? 'this page';
2933
const pagePath = Astro.url.pathname;
34+
const isNotFound = route.entry.id === '404' || route.entry.id.endsWith('/404');
35+
const showSharedTracking =
36+
!isNotFound &&
37+
docsSharedTrackingEnabled({
38+
isReleasedArchiveBuild,
39+
base,
40+
path: '/',
41+
});
42+
const canonicalOrigin = new URL('/', Astro.site ?? 'https://docs.registrystack.org').toString();
3043
const issueUrl =
3144
'https://github.com/registrystack/registry-stack/issues/new?title=' +
3245
encodeURIComponent(`Docs issue: ${pageTitle}`) +
@@ -104,9 +117,18 @@ const showPageFooter = hasPagination || hasMeta || showFeedback;
104117
<a href={`${base}privacy/`}>Privacy</a>
105118
<a href={`${base}accessibility/`}>Accessibility</a>
106119
{!isArchived && <a href={`${base}llms.txt`}>llms.txt</a>}
120+
{
121+
showSharedTracking && (
122+
<button type="button" data-tracking-preferences-open hidden>
123+
Tracking preferences
124+
</button>
125+
)
126+
}
107127
</nav>
108128
</footer>
109129

130+
{showSharedTracking && <RegistryTrackingPreferences canonicalOrigin={canonicalOrigin} />}
131+
110132
<script>
111133
// Wire the per-page "Was this helpful?" controls on each full page load.
112134
// Starlight does not use client-side view transitions by default, so a

‎docs/site/src/components/RegistryHead.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ const analytics = docsAnalyticsConfig({
6565
6666
const docsJourneyUrl = (anchor, targetSite) => {
6767
const url = new URL(anchor.href);
68-
if (targetSite === 'marketing' || targetSite === 'lab') {
68+
if (targetSite === 'lab') {
6969
url.searchParams.set('utm_source', 'registry_docs');
7070
url.searchParams.set('utm_medium', 'docs');
7171
url.searchParams.set('utm_campaign', 'cross_site');

0 commit comments

Comments
 (0)