Skip to content

Analytics

An analytics provider supplies the /admin/analytics dashboard and, optionally, the beacon script the public site loads. Two ship with the engine, Cloudflare Web Analytics and Fathom, and both are ordinary plugins.

interface AnalyticsProvider {
name: string;
label: string;
capabilities: AnalyticsCapabilities;
ranges: AnalyticsRange[];
defaultRange: string;
hosts?: BeaconHosts;
setup?: AnalyticsSetup;
isConfigured(config: AnalyticsConfig): boolean;
loadDashboard(range: string, config: AnalyticsConfig): Promise<AnalyticsDashboard>;
beacon(config: AnalyticsConfig): AnalyticsBeacon | null;
}

config is the plugin’s own settings value, the same object the plugin’s config form writes. The provider never reads the database itself.

setup is what the Plugins screen shows an editor who has not finished connecting the provider. The engine renders it and knows nothing about what it says, so a provider that needs a secret has to name it here and nowhere else:

interface AnalyticsSetup {
url: string; // where the editor goes to get the credentials
link?: string; // what to call that link. Defaults to the bare URL
steps: string[]; // one sentence each, in the order they are done
secrets?: string[]; // the env var names, shown verbatim
}

steps is required. Copy describing one provider does not belong in the engine, so a provider with nothing to say still has to say so with an empty array.

src/plugins/plausible/index.ts
import { env } from 'cloudflare:workers';
import { z } from 'zod';
import type { AnalyticsConfig, AnalyticsDashboard, AnalyticsProvider, Plugin } from '@ouncepage/core';
import { loadDashboard } from './api';
const BEACON = 'https://plausible.io';
const schema = z.object({
domain: z.string().trim().default(''),
});
interface Settings {
domain: string;
}
function settings(config: AnalyticsConfig): Settings {
const value = config as Partial<Settings>;
return { domain: value.domain || env.PLAUSIBLE_DOMAIN || '' };
}
export const plausibleProvider: AnalyticsProvider = {
name: 'plausible',
label: 'Plausible',
capabilities: { live: true, bounceRate: true, avgDuration: true, events: true },
defaultRange: '30d',
ranges: [
{ key: '7d', label: 'Last 7 days', grouping: 'day' },
{ key: '30d', label: 'Last 30 days', grouping: 'day' },
{ key: '12m', label: 'Last 12 months', grouping: 'month' },
],
hosts: { script: [BEACON], connect: [BEACON] },
setup: {
url: 'https://plausible.io/settings/api-keys',
link: 'Plausible API keys',
steps: [
'Fill in Domain on the Plugins screen. It is the site name as Plausible has it.',
'Create an API key in Plausible.',
'Put that key in .dev.vars locally, and run wrangler secret put for production.',
],
secrets: ['PLAUSIBLE_API_KEY'],
},
isConfigured(config) {
return Boolean(env.PLAUSIBLE_API_KEY && settings(config).domain);
},
loadDashboard(range, config): Promise<AnalyticsDashboard> {
return loadDashboard(range, settings(config).domain);
},
beacon(config) {
const { domain } = settings(config);
if (!domain) return null;
return { src: `${BEACON}/js/script.js`, attributes: { 'data-domain': domain } };
},
};
export function plausible(defaults: Partial<Settings> = {}): Plugin {
return {
name: 'plausible',
title: 'Plausible',
domain: 'plausible.io',
blurb: 'Privacy friendly analytics with live visitors and goals.',
secrets: ['PLAUSIBLE_API_KEY'],
analytics: plausibleProvider,
config: {
fields: [{ name: 'domain', label: 'Site domain', type: 'text' }],
schema,
defaults: { domain: '', ...defaults },
},
};
}
interface AnalyticsCapabilities {
live: boolean; // a current visitor count
bounceRate: boolean;
avgDuration: boolean;
events: boolean; // goals or custom events
}

The dashboard omits a tile whose capability is false. It does not render it as zero. Cloudflare Web Analytics has none of the four, so its dashboard is visibly smaller, which is honest. Declaring a capability you cannot fill means the dashboard shows a confident zero, which is a lie.

loadDashboard returns one object. Fill what your capabilities promise; leave the rest at zero or empty.

interface AnalyticsDashboard {
range: string;
live: number;
current: AnalyticsTotals; // visits, uniques, pageviews, avg_duration, bounce_rate
previous: AnalyticsTotals; // the comparable earlier period, for the deltas
series: { date: string; visits: number; pageviews: number }[];
pages: { label: string; value: number }[];
referrers: { label: string; value: number }[];
countries: { label: string; value: number }[];
devices: { label: string; value: number }[];
browsers: { label: string; value: number }[];
events: { name: string; conversions: number; uniques: number }[];
}

previous drives the percentage deltas. Return the same window shifted back by its own length. Returning the same numbers as current shows every delta as 0%.

series dates must match the range’s grouping: YYYY-MM-DD for day, YYYY-MM for month.

beacon() returns the script the public site should load, or null when the plugin is not configured enough to be useful.

interface AnalyticsBeacon {
src: string;
attributes?: Record<string, string>;
init?: BeaconInit;
}

hosts declares the origins your beacon touches, so a site can build its Content Security Policy from the active providers instead of hardcoding them.

Some trackers will not report anything from their loader alone. Google’s gtag.js is the example: the queue and the config call have to exist before the loader runs. init is that snippet.

interface BeaconInit {
code: string;
attributes?: Record<string, string>;
}

It renders as an inline <script> immediately before the deferred loader, so it runs first.

The public policy is script-src 'self' plus the hosts the providers declare, and csp appends hosts, never keywords. There is no 'unsafe-inline' to be had, and adding one would undo the policy for every other script on the page. A hash is the way through, and hosts.script already accepts one:

export const INIT =
"window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}" +
"gtag('js',new Date());gtag('config',document.currentScript.dataset.ga4);";
export const INIT_HASH = "'sha256-ah84unwvglGfpyjopOZ/9UIW5V24c+xV8tOctyWqwuY='";
hosts: { script: [TAG, INIT_HASH], /* ... */ },
beacon(config) {
const { measurementId } = settings(config);
if (!measurementId) return null;
return {
src: `${TAG}/gtag/js?id=${encodeURIComponent(measurementId)}`,
init: { code: INIT, attributes: { 'data-ga4': measurementId } },
};
}

Every enabled analytics plugin renders its beacon, and the dashboard shows a provider switch. The one it opens on is, in order: the one named in the query string, the first that reports isConfigured(), the first enabled.

That is what makes a migration between providers survivable. Keep the old plugin configured and its history stays reachable while the new one accumulates its own.

  • Read env in the factory. The factory runs at build time. Read it inside the methods.
  • Store an API key in config. That is plain JSON in D1 and visible in the admin. Use secrets and the Worker environment.
  • Throw from loadDashboard for an ordinary empty result. Return zeros; the dashboard renders an empty state.