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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/development/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ const analytics = init({
while preserving allowlisted conversion events. `spa` defaults to `true`; `clicks` defaults to
`true`.

Configuring `exclude` or `noPageviews` disables Cloudflare's automatic soft-navigation tracking.
Cloudflare Web Analytics does not expose a per-route SPA filter, so enabling it would send pageviews
for excluded paths. Full page loads on allowed paths remain measured, and custom events recheck
`exclude` against the current path before every send.
Cloudflare's automatic soft-navigation tracking remains enabled when `exclude` or `noPageviews` is
configured. The client filters Cloudflare Web Analytics requests against those rules, preserving
pageviews for allowed routes while suppressing pageviews for matching initial and soft-navigation
paths. Custom events recheck `exclude` against the current path before every send.

The returned handle contains `enabled`, an optional gate `reason`, and `track(name, props)`. Calling
`init` again returns the first handle and does not install another beacon or click listener.
Expand Down
12 changes: 7 additions & 5 deletions docs/development/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ pnpm exec lvbt-analytics verify https://labs.lasvegasfortransit.org \
--expect present
```

The verifier opens the page in Chromium and observes requests to the Cloudflare beacon and LVBT
collector. This exercises the browser gate instead of looking for dormant strings in a bundle.
`--expect absent` proves a preview or archive sends neither kind of request. A navigation failure or
mismatched expectation exits nonzero. The consuming repository supplies `@playwright/test` and its
Chromium browser.
The verifier opens the page in Chromium and independently observes the Cloudflare script download,
an actual Cloudflare Web Analytics request, and any LVBT collector requests. It also verifies the
site declared by the deployed client and rejects collector requests attributed to another site. This
exercises the browser gate instead of looking for dormant strings in a bundle. `--expect absent`
proves a preview or archive sends none of those requests. A navigation failure or mismatched
expectation exits nonzero. The consuming repository supplies `@playwright/test` and its Chromium
browser.

## CSP checks

Expand Down
5 changes: 3 additions & 2 deletions docs/operations/how-to/verify-a-deployment.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Verify a deployment

Verification checks the page, bundled client, security policy, collector, privacy gate, and one real
event. A successful page response alone does not prove analytics works.
Verification checks the page, bundled client, Cloudflare Web Analytics request, configured site,
security policy, collector, privacy gate, and one real event. A successful page response alone does
not prove analytics works.

For production:

Expand Down
3 changes: 2 additions & 1 deletion packages/analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
},
"files": [
"astro",
"dist"
"dist",
"LICENSE"
],
"scripts": {
"build": "tsdown && tsdown --config tsdown.client.config.ts",
Expand Down
53 changes: 45 additions & 8 deletions packages/analytics/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export async function writeClient(path: string) {
}

interface VerifyRoute {
continue(): Promise<unknown>;
fulfill(options: { body?: string; contentType?: string; status: number }): Promise<unknown>;
request(): { url(): string };
request(): { postData(): string | null; url(): string };
}

export interface VerifyPage {
goto(url: string, options?: { waitUntil?: 'domcontentloaded' }): Promise<unknown>;
getAttribute(selector: string, name: string): Promise<string | null>;
goto(url: string, options?: { waitUntil?: 'load' }): Promise<unknown>;
route(pattern: string, handler: (route: VerifyRoute) => Promise<unknown>): Promise<unknown>;
waitForTimeout(milliseconds: number): Promise<unknown>;
}
Expand Down Expand Up @@ -79,26 +81,61 @@ export async function verifyDeployment(
const pageUrl = new URL(url);
if (expect === 'present' && pageUrl.hostname !== site && pageUrl.hostname !== `www.${site}`)
throw new Error(`${pageUrl.hostname} does not match expected site ${site}.`);
const scriptRequests: string[] = [];
const beaconRequests: string[] = [];
const eventRequests: string[] = [];
const eventSites: string[] = [];
await withPage(async (page) => {
await page.route('https://static.cloudflareinsights.com/**', async (route) => {
scriptRequests.push(route.request().url());
return route.continue();
});
await page.route('https://cloudflareinsights.com/**', async (route) => {
beaconRequests.push(route.request().url());
return route.fulfill({ body: 'export {};', contentType: 'text/javascript', status: 200 });
return route.fulfill({ status: 204 });
});
await page.route('https://events.lasvegasfortransit.org/**', async (route) => {
eventRequests.push(route.request().url());
const request = route.request();
eventRequests.push(request.url());
const body = request.postData();
if (body) {
try {
const payload = JSON.parse(body) as { site?: unknown };
if (typeof payload.site === 'string') eventSites.push(payload.site);
} catch {
// The collector owns full payload validation; deployment verification only checks site.
}
}
return route.fulfill({ status: 204 });
});
await page.goto(pageUrl.href, { waitUntil: 'domcontentloaded' });
await page.goto(pageUrl.href, { waitUntil: 'load' });
await page.waitForTimeout(500);
if (expect === 'present') {
const configuredSite = await page.getAttribute('[data-lvbt-analytics]', 'data-lvbt-site');
if (configuredSite !== site)
throw new Error(
`Expected analytics site ${site} at ${pageUrl.href}; the deployed client declared ${configuredSite ?? 'no site'}.`,
);
}
});
const wrongSite = eventSites.find((eventSite) => eventSite !== site);
if (wrongSite)
throw new Error(
`Expected collector requests for ${site} at ${pageUrl.href}; observed a collector request for ${wrongSite}.`,
);
if (expect === 'present' && scriptRequests.length !== 1)
throw new Error(
`Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${scriptRequests.length} Cloudflare script requests.`,
);
if (expect === 'present' && beaconRequests.length !== 1)
throw new Error(
`Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare beacon requests.`,
`Expected analytics to be present for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare Web Analytics beacon requests.`,
);
if (expect === 'absent' && beaconRequests.length + eventRequests.length !== 0)
if (
expect === 'absent' &&
scriptRequests.length + beaconRequests.length + eventRequests.length !== 0
)
throw new Error(
`Expected analytics to be absent for ${site} at ${pageUrl.href}; the browser made ${beaconRequests.length} Cloudflare beacon requests and ${eventRequests.length} collector requests.`,
`Expected analytics to be absent for ${site} at ${pageUrl.href}; the browser made ${scriptRequests.length} Cloudflare script requests, ${beaconRequests.length} Cloudflare Web Analytics beacon requests, and ${eventRequests.length} collector requests.`,
);
}
83 changes: 75 additions & 8 deletions packages/analytics/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,82 @@ interface AnalyticsWindow extends Window {

let current: AnalyticsHandle | undefined;
let removeClickListener: (() => void) | undefined;
let removePageviewFilter: (() => void) | undefined;

function installBeacon(token: string, spa: boolean) {
function installBeacon(site: string, token: string, spa: boolean) {
if (document.querySelector('[data-lvbt-analytics]')) return;
const script = document.createElement('script');
script.type = 'module';
script.defer = true;
script.src = 'https://static.cloudflareinsights.com/beacon.min.js';
script.dataset.cfBeacon = JSON.stringify({ token, spa });
script.dataset.lvbtAnalytics = '';
script.dataset.lvbtSite = site;
document.head.append(script);
}

function isCloudflareBeacon(url: string | URL) {
const endpoint = new URL(String(url), location.href);
return endpoint.hostname === 'cloudflareinsights.com' && endpoint.pathname === '/cdn-cgi/rum';
}

function payloadPath(body: Document | XMLHttpRequestBodyInit | null | undefined) {
if (typeof body !== 'string') return location.pathname;
try {
const payload = JSON.parse(body) as { location?: unknown };
return typeof payload.location === 'string'
? new URL(payload.location, location.href).pathname
: location.pathname;
} catch {
return location.pathname;
}
}

function installPageviewFilter(patterns: RegExp[]) {
const blocks = (pathname: string) => patterns.some((pattern) => matches(pattern, pathname));
const destinations = new WeakMap<XMLHttpRequest, string | URL>();
const originalOpen = Reflect.get(XMLHttpRequest.prototype, 'open');
const originalSend = Reflect.get(XMLHttpRequest.prototype, 'send');
const sendBeaconDescriptor = Object.getOwnPropertyDescriptor(navigator, 'sendBeacon');
const originalSendBeacon =
typeof navigator.sendBeacon === 'function' ? navigator.sendBeacon.bind(navigator) : undefined;

XMLHttpRequest.prototype.open = function (
this: XMLHttpRequest,
...args: [
method: string,
url: string | URL,
async?: boolean,
username?: string | null,
password?: string | null,
]
) {
const url = args[1];
destinations.set(this, url);
Reflect.apply(originalOpen, this, args);
};
XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
const destination = destinations.get(this);
if (destination && isCloudflareBeacon(destination) && blocks(payloadPath(body))) return;
originalSend.call(this, body);
};
if (typeof originalSendBeacon === 'function')
Object.defineProperty(navigator, 'sendBeacon', {
configurable: true,
value(url: string | URL, data?: BodyInit | null) {
if (isCloudflareBeacon(url) && blocks(location.pathname)) return true;
return originalSendBeacon.call(navigator, url, data);
},
});

return () => {
XMLHttpRequest.prototype.open = originalOpen;
XMLHttpRequest.prototype.send = originalSend;
if (sendBeaconDescriptor) Object.defineProperty(navigator, 'sendBeacon', sendBeaconDescriptor);
else Reflect.deleteProperty(navigator, 'sendBeacon');
};
}

function eventSender(site: string, collector: string, exclude: RegExp[] = []) {
const sent = new Set<string>();
return <N extends EventName>(name: N, props: PropsFor<N>) => {
Expand Down Expand Up @@ -74,21 +138,24 @@ export function init(options: InitOptions): AnalyticsHandle {
}
const track = eventSender(options.site, options.collector ?? DEFAULT_COLLECTOR, options.exclude);
current = { enabled: true, track };
if (!options.noPageviews?.some((pattern) => matches(pattern, location.pathname)))
installBeacon(
options.token?.trim() ?? '',
options.spa === false || options.exclude?.length || options.noPageviews?.length
? false
: true,
);
const pageviewRules = [...(options.exclude ?? []), ...(options.noPageviews ?? [])];
if (options.spa !== false && pageviewRules.length > 0)
removePageviewFilter = installPageviewFilter(pageviewRules);
const initialPageviewBlocked = pageviewRules.some((pattern) =>
matches(pattern, location.pathname),
);
if (options.spa !== false || !initialPageviewBlocked)
installBeacon(options.site, options.token?.trim() ?? '', options.spa !== false);
if (options.clicks !== false) removeClickListener = installClickTracking(track);
(window as AnalyticsWindow).lvbt = { track };
return current;
}

export function resetForTesting() {
removeClickListener?.();
removePageviewFilter?.();
removeClickListener = undefined;
removePageviewFilter = undefined;
current = undefined;
delete (window as AnalyticsWindow).lvbt;
}
1 change: 1 addition & 0 deletions packages/analytics/src/standalone-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function startStandalone(
beacon.src = 'https://static.cloudflareinsights.com/beacon.min.js';
beacon.dataset.cfBeacon = JSON.stringify({ token, spa: script.dataset.lvbtSpa !== 'false' });
beacon.dataset.lvbtAnalytics = '';
beacon.dataset.lvbtSite = site;
document.head.append(beacon);
}
if (script.dataset.lvbtClicks !== 'false')
Expand Down
66 changes: 59 additions & 7 deletions packages/analytics/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,25 @@ test('keeps the committed event reference synchronized with the runtime allowlis
expect(normalizeTable(committed ?? '')).toBe(normalizeTable(eventsMarkdown()));
});

function pageThatRequests(...requestedUrls: string[]): VerifyPage {
function pageThatRequests(...requests: Array<string | { body: string; url: string }>): VerifyPage {
const routes: Array<{
pattern: string;
handler: (route: {
continue(): Promise<void>;
fulfill(options: { body?: string; contentType?: string; status: number }): Promise<void>;
request(): { url(): string };
request(): { postData(): string | null; url(): string };
}) => Promise<unknown>;
}> = [];
const goto: VerifyPage['goto'] = async () => {
for (const url of requestedUrls) {
for (const request of requests) {
const { body, url } = typeof request === 'string' ? { body: null, url: request } : request;
const route = routes.find(({ pattern }) =>
pattern.endsWith('/**') ? url.startsWith(pattern.slice(0, -2)) : url === pattern,
);
await route?.handler({
continue: () => Promise.resolve(),
fulfill: () => Promise.resolve(),
request: () => ({ url: () => url }),
request: () => ({ postData: () => body, url: () => url }),
});
}
};
Expand All @@ -57,7 +60,10 @@ function pageThatRequests(...requestedUrls: string[]): VerifyPage {
return Promise.resolve();
};
const waitForTimeout: VerifyPage['waitForTimeout'] = () => Promise.resolve();
const getAttribute: VerifyPage['getAttribute'] = (_selector, name) =>
Promise.resolve(name === 'data-lvbt-site' ? 'labs.lasvegasfortransit.org' : null);
return {
getAttribute: vi.fn(getAttribute),
goto: vi.fn(goto),
route: vi.fn(route),
waitForTimeout: vi.fn(waitForTimeout),
Expand All @@ -66,7 +72,12 @@ function pageThatRequests(...requestedUrls: string[]): VerifyPage {

test('verifies analytics from browser-observed requests instead of bundle text', async () => {
const withPage = (run: (page: VerifyPage) => Promise<void>) =>
run(pageThatRequests('https://static.cloudflareinsights.com/beacon.min.js'));
run(
pageThatRequests(
'https://static.cloudflareinsights.com/beacon.min.js',
'https://cloudflareinsights.com/cdn-cgi/rum',
),
);

await expect(
verifyDeployment(
Expand All @@ -88,7 +99,21 @@ test('fails when the built client exists but the runtime gate sends no request',
'present',
withPage,
),
).rejects.toThrow('browser made 0 Cloudflare beacon requests');
).rejects.toThrow('browser made 0 Cloudflare script requests');
});

test('does not accept the script download as proof that Web Analytics sent a beacon', async () => {
const withPage = (run: (page: VerifyPage) => Promise<void>) =>
run(pageThatRequests('https://static.cloudflareinsights.com/beacon.min.js'));

await expect(
verifyDeployment(
'https://labs.lasvegasfortransit.org',
'labs.lasvegasfortransit.org',
'present',
withPage,
),
).rejects.toThrow('browser made 0 Cloudflare Web Analytics beacon requests');
});

test('does not accept a collector event as proof that Web Analytics loaded', async () => {
Expand All @@ -102,7 +127,34 @@ test('does not accept a collector event as proof that Web Analytics loaded', asy
'present',
withPage,
),
).rejects.toThrow('browser made 0 Cloudflare beacon requests');
).rejects.toThrow('browser made 0 Cloudflare script requests');
});

test('rejects collector events attributed to a different production site', async () => {
const withPage = (run: (page: VerifyPage) => Promise<void>) =>
run(
pageThatRequests(
'https://static.cloudflareinsights.com/beacon.min.js',
'https://cloudflareinsights.com/cdn-cgi/rum',
{
body: JSON.stringify({
name: 'join_click',
props: { placement: 'header' },
site: 'map.lasvegasfortransit.org',
}),
url: 'https://events.lasvegasfortransit.org/e',
},
),
);

await expect(
verifyDeployment(
'https://labs.lasvegasfortransit.org',
'labs.lasvegasfortransit.org',
'present',
withPage,
),
).rejects.toThrow('collector request for map.lasvegasfortransit.org');
});

test('requires the expected site to match a production deployment hostname', async () => {
Expand Down
Loading
Loading