diff --git a/apps/web/package.json b/apps/web/package.json index edb0493..f27aad7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@atproto/jwk-jose": "^0.2.4", "@atproto/lexicon": "^0.7.10", "@atproto/oauth-client-node": "^0.5.2", + "isbot": "^5.2.2", "markdown-it": "^15.0.0" }, "devDependencies": { diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index c3be3bf..75a4751 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,6 +1,11 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces -import type { D1Database } from '@cloudflare/workers-types'; +import type { + AnalyticsEngineDataset, + D1Database, + ExecutionContext, + IncomingRequestCfProperties +} from '@cloudflare/workers-types'; import type { NodeOAuthClient } from '@atproto/oauth-client-node'; declare global { @@ -14,8 +19,19 @@ declare global { // interface PageData {} // interface PageState {} interface Platform { + /** Absent outside the Workers runtime (vitest); present under wrangler/vite dev. */ + ctx?: ExecutionContext; + cf?: IncomingRequestCfProperties; env: { DB: D1Database; + /** Site-metrics store; absent = ingest no-ops and /admin/analytics 404s. */ + METRICS?: AnalyticsEngineDataset; + /** Owner DID for app-host page views; unset = app-host traffic unrecorded. */ + METRICS_APP_DID?: string; + /** Account for the Analytics Engine SQL API. */ + CLOUDFLARE_ACCOUNT_ID?: string; + /** Token (Account Analytics: Read) for dashboard queries. */ + ANALYTICS_READ_TOKEN?: string; /** The host the app itself lives on; unset disables host-based serving. */ APP_HOST?: string; /** DIDs that may claim a subdomain; `*` for all, unset for none. */ diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 054b027..ac7f651 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -9,6 +9,7 @@ import { didForSlug, isAppOnlyPath } from '$lib/server/hosting'; +import { maybeRecordPageView } from '$lib/server/metrics'; // One client per isolate. Keyed by origin so a stale cache can't survive a // hostname change within an isolate's lifetime (unlikely, but cheap to guard). @@ -60,5 +61,11 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.oauth = await cached.client; } } - return resolve(event); + const response = await resolve(event); + + // One data point per rendered HTML page view; the response never waits on it. + const recorded = maybeRecordPageView(event, response); + if (recorded) event.platform?.ctx?.waitUntil(recorded); + + return response; }; diff --git a/apps/web/src/lib/server/metrics/index.ts b/apps/web/src/lib/server/metrics/index.ts new file mode 100644 index 0000000..ce01e86 --- /dev/null +++ b/apps/web/src/lib/server/metrics/index.ts @@ -0,0 +1,11 @@ +export { + isExcludedPath, + maybeRecordPageView, + recordPageView, + referrerHost, + shouldRecord, + visitorHash +} from './ingest'; +export type { PageView } from './ingest'; +export { didLiteral, fillDays, siteMetrics } from './query'; +export type { DayViews, MetricsApi, SiteMetrics } from './query'; diff --git a/apps/web/src/lib/server/metrics/ingest.ts b/apps/web/src/lib/server/metrics/ingest.ts new file mode 100644 index 0000000..7c79b25 --- /dev/null +++ b/apps/web/src/lib/server/metrics/ingest.ts @@ -0,0 +1,141 @@ +/** + * Page-view ingestion into Analytics Engine: one data point per successfully + * rendered HTML page view. index1 = site DID, blob1 = path, blob2 = referrer + * host, blob3 = country, blob4 = daily visitor hash. Bots and excluded paths + * are never written. No cookies, no stored IPs — the hash is derived + * per-request and rotates daily. + */ + +import type { RequestEvent } from '@sveltejs/kit'; +import type { AnalyticsEngineDataset } from '@cloudflare/workers-types'; +import { isbot } from 'isbot'; +import { DEV_SECRET, hmac } from '../session'; + +const EXCLUDED_PREFIXES = ['/blob', '/admin', '/login', '/oauth']; + +export function isExcludedPath(pathname: string): boolean { + return EXCLUDED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`)); +} + +export function shouldRecord(view: { + method: string; + status: number; + contentType: string | null; + pathname: string; + userAgent: string | null; +}): boolean { + return ( + view.method === 'GET' && + view.status === 200 && + (view.contentType ?? '').includes('text/html') && + !isExcludedPath(view.pathname) && + !isbot(view.userAgent ?? '') + ); +} + +/** + * The referrer's host, or undefined when there is none worth storing: absent, + * unparseable, non-http, or the serving host itself (internal navigation). + * Hosts only — full URLs would carry query strings into the store. + */ +export function referrerHost(referrer: string | null, requestHost: string): string | undefined { + if (!referrer) return undefined; + let url: URL; + try { + url = new URL(referrer); + } catch { + return undefined; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + const host = url.host.toLowerCase(); + return host === requestHost.toLowerCase() ? undefined : host; +} + +/** + * Daily-rotating visitor hash. The salt is stateless — derived from the + * server secret and the UTC date, never stored — and the derivation string + * keeps it disjoint from session-cookie signatures. A person counts once per + * day they visit (daily-visitor semantics). + */ +export async function visitorHash( + secret: string, + utcDate: string, + did: string, + ip: string, + userAgent: string +): Promise { + const salt = await hmac(secret, `mooring-visitor:${utcDate}`); + return hmac(salt, `${did}\n${ip}\n${userAgent}`); +} + +export interface PageView { + did: string; + pathname: string; + referrer: string | null; + requestHost: string; + country: string; + ip: string; + userAgent: string; +} + +export async function recordPageView( + dataset: AnalyticsEngineDataset, + secret: string, + view: PageView +): Promise { + const day = new Date().toISOString().slice(0, 10); + const hash = await visitorHash(secret, day, view.did, view.ip, view.userAgent); + dataset.writeDataPoint({ + indexes: [view.did], + blobs: [view.pathname, referrerHost(view.referrer, view.requestHost) ?? '', view.country, hash] + }); +} + +/** + * The hook-facing entry: decides whether the response is a recordable page + * view and returns the write as a promise for waitUntil, or undefined when + * nothing is written. Tenant views record under the tenant DID; app-host + * views under METRICS_APP_DID. Ingest failures are logged, never surfaced. + */ +export function maybeRecordPageView( + event: RequestEvent, + response: Response +): Promise | undefined { + const env = event.platform?.env; + if (!env?.METRICS) return undefined; + + const did = event.locals.tenant?.did ?? env.METRICS_APP_DID; + if (!did) return undefined; + + const userAgent = event.request.headers.get('user-agent'); + if ( + !shouldRecord({ + method: event.request.method, + status: response.status, + contentType: response.headers.get('content-type'), + pathname: event.url.pathname, + userAgent + }) + ) { + return undefined; + } + + let ip = ''; + try { + ip = event.getClientAddress(); + } catch { + // Unavailable outside a real request (prerendering); hash on what we have. + } + + return recordPageView(env.METRICS, env.SESSION_SECRET ?? DEV_SECRET, { + did, + pathname: event.url.pathname, + referrer: event.request.headers.get('referer'), + requestHost: event.url.host, + country: String(event.platform?.cf?.country ?? ''), + ip, + userAgent: userAgent ?? '' + }).catch((err) => { + console.error('page-view ingest failed', err); + }); +} diff --git a/apps/web/src/lib/server/metrics/metrics.test.ts b/apps/web/src/lib/server/metrics/metrics.test.ts new file mode 100644 index 0000000..3fa6005 --- /dev/null +++ b/apps/web/src/lib/server/metrics/metrics.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; +import type { RequestEvent } from '@sveltejs/kit'; +import type { AnalyticsEngineDataset } from '@cloudflare/workers-types'; +import { + isExcludedPath, + maybeRecordPageView, + recordPageView, + referrerHost, + shouldRecord, + visitorHash +} from './ingest'; +import { didLiteral, fillDays, siteMetrics } from './query'; + +const DID = 'did:plc:o3zuar7kk2mrz7d4sqxdisy2'; + +interface Written { + indexes?: string[]; + blobs?: (string | null)[]; + doubles?: number[]; +} + +function fakeDataset(): { dataset: AnalyticsEngineDataset; written: Written[] } { + const written: Written[] = []; + return { + written, + dataset: { writeDataPoint: (point: Written) => written.push(point) } as AnalyticsEngineDataset + }; +} + +describe('isExcludedPath', () => { + it.each(['/blob', '/blob/did:plc:x/bafy', '/admin', '/admin/pages', '/login', '/oauth/callback'])( + 'excludes %s', + (path) => { + expect(isExcludedPath(path)).toBe(true); + } + ); + + it.each(['/', '/about', '/s/jzweifel.dev', '/blogpost', '/administrivia'])( + 'keeps %s', + (path) => { + expect(isExcludedPath(path)).toBe(false); + } + ); +}); + +describe('shouldRecord', () => { + const view = { + method: 'GET', + status: 200, + contentType: 'text/html; charset=utf-8', + pathname: '/', + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15' + }; + + it('records a rendered HTML page view', () => { + expect(shouldRecord(view)).toBe(true); + }); + + it('records when the user agent is absent — only known bots are dropped', () => { + expect(shouldRecord({ ...view, userAgent: null })).toBe(true); + }); + + it('skips non-GET, non-200, and non-HTML responses', () => { + expect(shouldRecord({ ...view, method: 'POST' })).toBe(false); + expect(shouldRecord({ ...view, status: 404 })).toBe(false); + expect(shouldRecord({ ...view, contentType: 'application/json' })).toBe(false); + expect(shouldRecord({ ...view, contentType: null })).toBe(false); + }); + + it('skips excluded paths and bots', () => { + expect(shouldRecord({ ...view, pathname: '/admin/analytics' })).toBe(false); + expect( + shouldRecord({ + ...view, + userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' + }) + ).toBe(false); + }); +}); + +describe('referrerHost', () => { + it('reduces the referrer to its host', () => { + expect(referrerHost('https://bsky.app/profile/x/post/y?ref=abc', 'jzweifel.dev')).toBe( + 'bsky.app' + ); + }); + + it('keeps a non-default port and lowercases', () => { + expect(referrerHost('http://Localhost:5173/somewhere', 'jzweifel.dev')).toBe('localhost:5173'); + }); + + it('drops same-host referrers regardless of case', () => { + expect(referrerHost('https://JZweifel.dev/about', 'jzweifel.dev')).toBeUndefined(); + }); + + it('drops absent, unparseable, and non-http referrers', () => { + expect(referrerHost(null, 'jzweifel.dev')).toBeUndefined(); + expect(referrerHost('not a url', 'jzweifel.dev')).toBeUndefined(); + expect(referrerHost('android-app://com.google.android.gm', 'jzweifel.dev')).toBeUndefined(); + }); +}); + +describe('visitorHash', () => { + const args = ['secret', '2026-08-27', DID, '203.0.113.7', 'Mozilla/5.0'] as const; + + it('is stable for the same visitor on the same day', async () => { + expect(await visitorHash(...args)).toBe(await visitorHash(...args)); + }); + + it('rotates when the day, site, IP, or UA changes', async () => { + const base = await visitorHash(...args); + expect(await visitorHash('secret', '2026-08-28', DID, '203.0.113.7', 'Mozilla/5.0')).not.toBe( + base + ); + expect( + await visitorHash('secret', '2026-08-27', 'did:web:other.dev', '203.0.113.7', 'Mozilla/5.0') + ).not.toBe(base); + expect(await visitorHash('secret', '2026-08-27', DID, '203.0.113.8', 'Mozilla/5.0')).not.toBe( + base + ); + expect(await visitorHash('secret', '2026-08-27', DID, '203.0.113.7', 'curl/8')).not.toBe(base); + }); + + it('carries none of its inputs', async () => { + const hash = await visitorHash(...args); + expect(hash).not.toContain('203.0.113.7'); + expect(hash).not.toContain('Mozilla'); + }); +}); + +describe('recordPageView', () => { + it('writes the schema: index = DID, blobs = path/referrer host/country/hash', async () => { + const { dataset, written } = fakeDataset(); + await recordPageView(dataset, 'secret', { + did: DID, + pathname: '/about', + referrer: 'https://news.ycombinator.com/item?id=1', + requestHost: 'jzweifel.dev', + country: 'US', + ip: '203.0.113.7', + userAgent: 'Mozilla/5.0' + }); + + expect(written).toHaveLength(1); + expect(written[0].indexes).toEqual([DID]); + const [path, referrer, country, hash] = written[0].blobs!; + expect(path).toBe('/about'); + expect(referrer).toBe('news.ycombinator.com'); + expect(country).toBe('US'); + const today = new Date().toISOString().slice(0, 10); + expect(hash).toBe(await visitorHash('secret', today, DID, '203.0.113.7', 'Mozilla/5.0')); + }); + + it('writes an empty referrer blob when navigation is internal', async () => { + const { dataset, written } = fakeDataset(); + await recordPageView(dataset, 'secret', { + did: DID, + pathname: '/', + referrer: 'https://jzweifel.dev/about', + requestHost: 'jzweifel.dev', + country: '', + ip: '', + userAgent: '' + }); + expect(written[0].blobs![1]).toBe(''); + }); +}); + +describe('maybeRecordPageView', () => { + function event(overrides: { + env?: Record; + tenant?: { did: string }; + url?: string; + }): RequestEvent { + const url = new URL(overrides.url ?? 'https://jzweifel.dev/'); + return { + platform: { env: overrides.env, cf: { country: 'US' } }, + locals: { tenant: overrides.tenant }, + url, + request: new Request(url, { + headers: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15' + } + }), + getClientAddress: () => '203.0.113.7' + } as unknown as RequestEvent; + } + + const html = new Response('', { + headers: { 'content-type': 'text/html; charset=utf-8' } + }); + + it('does nothing without the METRICS binding', () => { + expect(maybeRecordPageView(event({ env: {} }), html)).toBeUndefined(); + }); + + it('records tenant views under the tenant DID', async () => { + const { dataset, written } = fakeDataset(); + const promise = maybeRecordPageView( + event({ env: { METRICS: dataset }, tenant: { did: 'did:web:tenant.dev' } }), + html + ); + expect(promise).toBeDefined(); + await promise; + expect(written[0].indexes).toEqual(['did:web:tenant.dev']); + }); + + it('records app-host views under METRICS_APP_DID, or not at all', async () => { + const { dataset, written } = fakeDataset(); + const env = { METRICS: dataset, METRICS_APP_DID: DID }; + await maybeRecordPageView(event({ env, url: 'https://mooring.page/s/jzweifel.dev' }), html); + expect(written[0].indexes).toEqual([DID]); + + expect( + maybeRecordPageView(event({ env: { METRICS: dataset } }), html) + ).toBeUndefined(); + }); +}); + +describe('didLiteral', () => { + it('quotes well-formed DIDs', () => { + expect(didLiteral(DID)).toBe(`'${DID}'`); + expect(didLiteral('did:web:example.com')).toBe("'did:web:example.com'"); + }); + + it('rejects anything that could escape the literal', () => { + expect(() => didLiteral("did:plc:x' OR 1=1 --")).toThrow(); + expect(() => didLiteral('not-a-did')).toThrow(); + expect(() => didLiteral('')).toThrow(); + }); +}); + +describe('fillDays', () => { + it('fills gaps with zero, oldest first, ending today', () => { + const today = new Date('2026-08-27T12:00:00Z'); + const filled = fillDays([{ day: '2026-08-25', views: 4 }], 3, today); + expect(filled).toEqual([ + { day: '2026-08-25', views: 4 }, + { day: '2026-08-26', views: 0 }, + { day: '2026-08-27', views: 0 } + ]); + }); +}); + +describe('siteMetrics', () => { + function fakeApi(rows: Record[] = []) { + const queries: string[] = []; + const api = { + accountId: 'acct', + token: 'tok', + fetch: (async (_input: unknown, init?: RequestInit) => { + queries.push(String(init?.body)); + return new Response(JSON.stringify({ data: rows })); + }) as typeof fetch + }; + return { api, queries }; + } + + it('writes every count sampling-aware', async () => { + const { api, queries } = fakeApi(); + await siteMetrics(api, DID, 7); + + expect(queries).toHaveLength(5); + for (const q of queries) { + expect(q).toContain('sum(_sample_interval)'); + // A bare count() silently undercounts under sampling; only the + // documented count(DISTINCT …) form is allowed through. + expect(q.replaceAll(/count\(DISTINCT [^)]+\)/g, '')).not.toMatch(/count\(/); + } + }); + + it('scopes every query to the DID and window', async () => { + const { api, queries } = fakeApi(); + await siteMetrics(api, DID, 7); + for (const q of queries) { + expect(q).toContain(`index1 = '${DID}'`); + expect(q).toContain("INTERVAL '7' DAY"); + } + }); + + it('clamps the window to whole days within retention', async () => { + const { api, queries } = fakeApi(); + await siteMetrics(api, DID, 400.5); + expect(queries[0]).toContain("INTERVAL '90' DAY"); + }); + + it('filters empty referrer and country blobs out of their tops', async () => { + const { api, queries } = fakeApi(); + await siteMetrics(api, DID, 7); + expect(queries.filter((q) => q.includes("!= ''"))).toHaveLength(2); + }); + + it('coerces returned values, empty result sets included', async () => { + const { api } = fakeApi([{ views: '12', visitors: 3 }]); + const metrics = await siteMetrics(api, DID, 7); + expect(metrics.views).toBe(12); + expect(metrics.visitors).toBe(3); + + const empty = await siteMetrics(fakeApi().api, DID, 7); + expect(empty.views).toBe(0); + expect(empty.visitors).toBe(0); + expect(empty.byDay).toEqual([]); + }); + + it('surfaces an API failure with the status', async () => { + const api = { + accountId: 'acct', + token: 'tok', + fetch: (async () => new Response('denied', { status: 403 })) as typeof fetch + }; + await expect(siteMetrics(api, DID, 7)).rejects.toThrow('HTTP 403'); + }); +}); diff --git a/apps/web/src/lib/server/metrics/query.ts b/apps/web/src/lib/server/metrics/query.ts new file mode 100644 index 0000000..5b022ef --- /dev/null +++ b/apps/web/src/lib/server/metrics/query.ts @@ -0,0 +1,109 @@ +/** + * Reads over the Analytics Engine SQL API. Analytics Engine samples + * adaptively at volume, so every count here is written sum(_sample_interval) + * — a bare count() reads low under sampling. count(DISTINCT) has no weighted + * form; its undercount under sampling is accepted. + */ + +const DATASET = 'mooring_site_views'; +const TOP_LIMIT = 10; + +export interface MetricsApi { + accountId: string; + token: string; + fetch: typeof globalThis.fetch; +} + +export interface DayViews { + day: string; + views: number; +} + +export interface SiteMetrics { + views: number; + /** Distinct daily visitor hashes: a person counts once per day they visit. */ + visitors: number; + byDay: DayViews[]; + topPages: { path: string; views: number; visitors: number }[]; + topReferrers: { host: string; views: number }[]; + countries: { country: string; views: number }[]; +} + +/** The last `days` UTC dates oldest-first, with zero-view gaps filled in. */ +export function fillDays(byDay: DayViews[], days: number, today = new Date()): DayViews[] { + const views = new Map(byDay.map((d) => [d.day, d.views])); + const out: DayViews[] = []; + for (let i = days - 1; i >= 0; i--) { + const day = new Date(today.getTime() - i * 86_400_000).toISOString().slice(0, 10); + out.push({ day, views: views.get(day) ?? 0 }); + } + return out; +} + +/** The DID is inlined into SQL; permit only shapes that cannot escape a string literal. */ +export function didLiteral(did: string): string { + if (!/^did:[a-z0-9]+:[A-Za-z0-9._%:-]+$/.test(did)) { + throw new Error(`Unexpected DID shape: ${did}`); + } + return `'${did}'`; +} + +async function sql(api: MetricsApi, query: string): Promise[]> { + const res = await api.fetch( + `https://api.cloudflare.com/client/v4/accounts/${api.accountId}/analytics_engine/sql`, + { + method: 'POST', + headers: { authorization: `Bearer ${api.token}` }, + body: query + } + ); + if (!res.ok) { + throw new Error(`Analytics SQL API returned HTTP ${res.status}: ${await res.text()}`); + } + const body = (await res.json()) as { data?: Record[] }; + return body.data ?? []; +} + +const num = (v: unknown): number => Number(v) || 0; + +export async function siteMetrics( + api: MetricsApi, + did: string, + days: number +): Promise { + const window = Math.min(90, Math.max(1, Math.floor(days))); + const scope = `FROM ${DATASET} WHERE index1 = ${didLiteral(did)} AND timestamp > NOW() - INTERVAL '${window}' DAY`; + + const [summary, byDay, topPages, topReferrers, countries] = await Promise.all([ + sql(api, `SELECT sum(_sample_interval) AS views, count(DISTINCT blob4) AS visitors ${scope}`), + sql( + api, + `SELECT toStartOfInterval(timestamp, INTERVAL '1' DAY) AS day, sum(_sample_interval) AS views ${scope} GROUP BY day ORDER BY day ASC` + ), + sql( + api, + `SELECT blob1 AS path, sum(_sample_interval) AS views, count(DISTINCT blob4) AS visitors ${scope} GROUP BY path ORDER BY views DESC LIMIT ${TOP_LIMIT}` + ), + sql( + api, + `SELECT blob2 AS host, sum(_sample_interval) AS views ${scope} AND blob2 != '' GROUP BY host ORDER BY views DESC LIMIT ${TOP_LIMIT}` + ), + sql( + api, + `SELECT blob3 AS country, sum(_sample_interval) AS views ${scope} AND blob3 != '' GROUP BY country ORDER BY views DESC LIMIT ${TOP_LIMIT}` + ) + ]); + + return { + views: num(summary[0]?.views), + visitors: num(summary[0]?.visitors), + byDay: byDay.map((r) => ({ day: String(r.day ?? '').slice(0, 10), views: num(r.views) })), + topPages: topPages.map((r) => ({ + path: String(r.path ?? ''), + views: num(r.views), + visitors: num(r.visitors) + })), + topReferrers: topReferrers.map((r) => ({ host: String(r.host ?? ''), views: num(r.views) })), + countries: countries.map((r) => ({ country: String(r.country ?? ''), views: num(r.views) })) + }; +} diff --git a/apps/web/src/lib/server/session.ts b/apps/web/src/lib/server/session.ts index 82fd742..9fc9b15 100644 --- a/apps/web/src/lib/server/session.ts +++ b/apps/web/src/lib/server/session.ts @@ -10,9 +10,10 @@ const MAX_AGE = 60 * 60 * 24 * 30; // Fallback keeps local dev running without setup; anything deployed must set // SESSION_SECRET (wrangler secret put SESSION_SECRET). -const DEV_SECRET = 'mooring-dev-secret-do-not-deploy'; +export const DEV_SECRET = 'mooring-dev-secret-do-not-deploy'; -async function hmac(secret: string, data: string): Promise { +/** HMAC-SHA256, base64url. */ +export async function hmac(secret: string, data: string): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), diff --git a/apps/web/src/routes/admin/+layout.svelte b/apps/web/src/routes/admin/+layout.svelte index d9acfcb..35a4e50 100644 --- a/apps/web/src/routes/admin/+layout.svelte +++ b/apps/web/src/routes/admin/+layout.svelte @@ -9,7 +9,9 @@ ? 'pages' : page.url.pathname.startsWith('/admin/hosting') ? 'hosting' - : 'overview' + : page.url.pathname.startsWith('/admin/analytics') + ? 'analytics' + : 'overview' ); @@ -24,6 +26,9 @@ Hosting + + Analytics + diff --git a/apps/web/src/routes/admin/analytics/+page.server.ts b/apps/web/src/routes/admin/analytics/+page.server.ts new file mode 100644 index 0000000..0ea6118 --- /dev/null +++ b/apps/web/src/routes/admin/analytics/+page.server.ts @@ -0,0 +1,36 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { requireSession } from '$lib/server/admin'; +import { fillDays, siteMetrics, type SiteMetrics } from '$lib/server/metrics'; + +// The free window. The 30-day window unlocks with the paid tier; the store +// already holds it, so the unlock is retroactive. +const WINDOW_DAYS = 7; + +export const load: PageServerLoad = async (event) => { + const env = event.platform!.env; + // Without the metrics store the feature doesn't exist on this instance. + if (!env.METRICS) error(404, 'Not found.'); + + const { did } = await requireSession(event); + + if (!env.CLOUDFLARE_ACCOUNT_ID || !env.ANALYTICS_READ_TOKEN) { + return { windowDays: WINDOW_DAYS, configured: false, failed: false, metrics: undefined }; + } + + let metrics: SiteMetrics | undefined; + let failed = false; + try { + metrics = await siteMetrics( + { accountId: env.CLOUDFLARE_ACCOUNT_ID, token: env.ANALYTICS_READ_TOKEN, fetch }, + did, + WINDOW_DAYS + ); + metrics.byDay = fillDays(metrics.byDay, WINDOW_DAYS); + } catch (err) { + console.error('metrics query failed', err); + failed = true; + } + + return { windowDays: WINDOW_DAYS, configured: true, failed, metrics }; +}; diff --git a/apps/web/src/routes/admin/analytics/+page.svelte b/apps/web/src/routes/admin/analytics/+page.svelte new file mode 100644 index 0000000..bf9c50a --- /dev/null +++ b/apps/web/src/routes/admin/analytics/+page.svelte @@ -0,0 +1,299 @@ + + + + Analytics — Mooring + + +
+

Analytics

+

+ No cookies, no tracking — your visitors are counted, never followed. Honest crawlers aren't + counted at all. +

+ +
+ Last {data.windowDays} days + 30 days · paid tier +
+

+ Your history is already being kept, so upgrading later unlocks the last 30 days retroactively. + Metrics age out after three months. +

+ + {#if !data.configured} +

+ Recording works, but the dashboard can't query yet: set the CLOUDFLARE_ACCOUNT_ID + var and the ANALYTICS_READ_TOKEN secret (an API token with Account + Analytics · Read) to see the numbers. +

+ {:else if data.failed || !metrics} + + {:else} +
+
+ {fmt.format(metrics.views)} + Views +
+
+ {fmt.format(metrics.visitors)} + Daily visitors + a person counts once per day they visit +
+
+ +

Views by day

+
    + {#each metrics.byDay as d (d.day)} +
  1. + {#if d.views > 0 && d.views === peakViews} + {fmt.format(d.views)} + {/if} + {#if d.views > 0} + + {:else} + + {/if} + + {d.day}: {d.views} views +
  2. + {/each} +
+ + {#if metrics.views === 0} +

Nothing yet — page views land here within a few minutes of being served.

+ {/if} + +
+
+

Top pages

+ {#if metrics.topPages.length === 0} +

Nothing yet.

+ {:else} +
    + {#each metrics.topPages as p (p.path)} +
  1. + {p.path} + {fmt.format(p.views)} +
  2. + {/each} +
+ {/if} +
+
+

Referrers

+ {#if metrics.topReferrers.length === 0} +

Only direct visits so far.

+ {:else} +
    + {#each metrics.topReferrers as r (r.host)} +
  1. + {r.host} + {fmt.format(r.views)} +
  2. + {/each} +
+ {/if} +
+
+

Countries

+ {#if metrics.countries.length === 0} +

Nothing yet.

+ {:else} +
    + {#each metrics.countries as c (c.country)} +
  1. + {countryName(c.country)} + {fmt.format(c.views)} +
  2. + {/each} +
+ {/if} +
+
+ {/if} +
+ + diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 5673c8f..fdc5383 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -29,8 +29,22 @@ // The granular scope needs the published page.mooring.* lexicons and // the _lexicon.mooring.page TXT record to resolve; comment this out // to roll back if a PDS can't resolve the permission set. - "OAUTH_SCOPE": "atproto include:page.mooring.authSite blob:image/*" + "OAUTH_SCOPE": "atproto include:page.mooring.authSite blob:image/*", + // Account for the Analytics Engine SQL API; with ANALYTICS_READ_TOKEN + // unset the dashboard reports the missing config instead of numbers. + "CLOUDFLARE_ACCOUNT_ID": "f50bb5898d5ba0ee9e9d325a786d8390", + // App-host page views (apex, /s/ previews) are recorded as site views + // owned by this DID; unset, app-host traffic is not recorded. + "METRICS_APP_DID": "did:plc:o3zuar7kk2mrz7d4sqxdisy2" }, + // Site metrics store. Absent (e.g. a self-host that doesn't want metrics), + // ingest no-ops and /admin/analytics 404s. + "analytics_engine_datasets": [ + { + "binding": "METRICS", + "dataset": "mooring_site_views" + } + ], // The apex and www are Workers custom domains (exact hostnames, DNS and // certs managed by Cloudflare). Everything else binds through this route: // subdomains (via the zone's proxied wildcard DNS record) and Cloudflare @@ -69,4 +83,7 @@ // whitespace/comma separated, `*` for every // account, unset for none. A secret rather than a // var because the list names individual people. + // ANALYTICS_READ_TOKEN — API token (Account → Account Analytics: Read) + // for the /admin/analytics dashboard's SQL API + // queries; unset, the dashboard names the gap. } diff --git a/docs/NEXT.md b/docs/NEXT.md index 47865b7..7350845 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, queued as item 2 below. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, **and now built** — ADR 0016 Proposed, PR open, deploy pending; see item 2 and Done. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ ## Where things stand @@ -22,9 +22,9 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending - **Landing-page critique follow-ups, design-decision tier** (queued 2026-08-25; snapshot `.impeccable/critique/2026-08-26T01-52-10Z__apps-web-src-routes-page-svelte.md`, scored 21/40 — the three P1s and the mechanical P2s are fixed; what remains needs product calls): gloss or drop "standard.site"/"sifa" jargon for the non-technical launch audience; a pricing signal ("free subdomain" reads as an unpriced paywall); accept a pasted DID in the lookup (currently lowercased and rejected as a typo); distinguish resolver outages from typos in the error copy; and the big swing — show a real rendered site on the landing page instead of describing one ("you've built a rendering engine and put a text ad in front of it"). - **Explore extracting themeability, and a second theme — possibly nautical** (queued 2026-08-25, Jacob's hunch): "Mooring" reads as boats to plenty of people before it reads as airships, and the professional-presence theme is currently the only theme, its palette and motifs woven through `SiteLayout`/`SiteSections` rather than sitting behind a seam. Two questions to explore together: (1) what a theme contract would look like (tokens? component set? the `theme.colors` override mechanism already hints at one) and whether extracting it is worth the indirection while there is exactly one theme; (2) whether a harbor/nautical variant (same letter-and-postmark bones, different motif and palette) is a cheap second theme that meets boat-minded visitors where they land. Mock on the design canvas before building; ADR 0008 scope discipline applies — this is exploration, not a committed v1 item. -### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11, decided 2026-08-26; ship after the v1 tail, before billing) +### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11; **built 2026-08-27, PR open — see Done**; deploy + live verification remain) -The whole design is settled and recorded — `research/2026-08-26-analytics-offering.md` has the verified Analytics Engine facts (pricing/retention/SQL API/sampling), the pckt.blog competitive read, and every decision (metric set, stateless daily-hash uniques and their daily-visitor semantics, `isbot` drop-at-ingest, tenant-hosts-count / app-host traffic owned by the `mooring.page` authority DID for funnel dogfooding, `/admin/analytics` placement, dark-when-binding-absent self-host seam). Build = one `writeDataPoint()` in the serving path + a sampling-aware query helper + the dashboard page + a locked 30d control; write the ADR (AE dataset/schema specifics) when this starts. The 30d unlock itself waits on billing, but data written from day one makes it retroactive. +The build landed per the settled design (`research/2026-08-26-analytics-offering.md`) and ADR 0016 (Proposed — ratify on the PR). What remains, in order: (1) **deploy steps** — create an API token scoped Account Analytics:Read and `wrangler secret put ANALYTICS_READ_TOKEN` (the `CLOUDFLARE_ACCOUNT_ID` var is already in wrangler.jsonc; until the secret is set the dashboard names the gap and ingest records regardless), then merge + `npm run deploy -w web`; (2) **smoke-test live** — view a site, then read `/admin/analytics` signed in as the site's owner, and as the `mooring.page` authority account for the apex/`/s/` funnel; ingest-to-readable lag is expected to be minutes; (3) the **30d unlock** still waits on billing — data written from deploy day makes it retroactive. ## Standing / background @@ -36,6 +36,8 @@ The whole design is settled and recorded — `research/2026-08-26-analytics-offe ## Done +- 2026-08-27 — **Site metrics built end to end** (PD-11's build session; PR **open, not merged or deployed**). ADR 0016 (Proposed) fixes the specifics: dataset `mooring_site_views` bound as `METRICS`, one data point per rendered HTML page view (GET · 200 · `text/html`) written from `hooks.server.ts` under `waitUntil` — the one place every tenant host has already resolved to a DID — with `index1` = site DID and blobs path / referrer host / country / daily visitor hash. Two calls made in-session (both the recommended option): **referrers are stored host-only** — full URLs would carry query strings into the store against the privacy stance, and hosts are what the dashboard aggregates — and the **salt derives from `SESSION_SECRET`** (`HMAC(secret, 'mooring-visitor:' + UTC date)`, the literal prefix domain-separating it from cookie signing) so self-host stays one-secret and there is nothing to rotate or store. Bots drop at ingest via `isbot` (new dependency; learned in testing: it flags a bare `Mozilla/5.0` as headless, so test fixtures need full UA token chains). App-host traffic records under the new `METRICS_APP_DID` var (the authority DID); tenant traffic under the tenant. The query layer (`lib/server/metrics/query.ts`) is **sampling-aware from day one** — every count is `sum(_sample_interval)`, and a test literally greps the SQL to fail any bare `count()` that sneaks in; DIDs are shape-validated before inlining into SQL. `/admin/analytics` (nav link added) renders tiles, a 7-bar views-by-day chart (peak labeled, zero days as rule-colored stubs), and top pages / referrers / countries with `Intl.DisplayNames` country names — plus the locked "30 days · paid tier" chip, the retroactivity promise, and the three-month horizon stated honestly. Distinct states for unconfigured (names the missing var/secret; says recording still works), store-didn't-answer, and empty. Self-host seam: no `METRICS` binding → ingest no-ops, the route 404s. 36 new tests (260 in `web`); verified in a browser through a temporary preview route (all four states, both schemes, desktop + 375px, no console errors), deleted before commit. **Nothing is live yet**: deploy steps and live smoke-test are item 2 above. + - 2026-08-27 — **A section that stops short says so, and says where the rest is** (PR #43, **open**). Posts and writing rendered a fixed head of their collections and simply stopped: a reader with 342 posts saw five, with nothing on the page saying so and nowhere to go for the rest, while the career log rendered every record — three inconsistent volume behaviours on one page. Jacob's calls, all three the recommended option: **a stamped cue that links out** rather than an in-place disclosure or an archive route (quote hydration deliberately runs only over the *shown* posts — each quote costs a DID-document resolution plus a post read — so revealing forty more would either pay for forty more of those or render the hidden ones bare); **the career log stays whole** (a résumé's point is showing the log, its lines are compact, and a truncated work history reads as concealment rather than curation — so `positions`/`education` are untouched, and the existing `SKILL_CAP` disclosure stands); and **the default post count moves 5 → 8**, which an honest cue makes affordable. A trimmed list now closes with one line in the theme's system voice — mono, accent, above a dotted rule that replaces the list's own last border: `8 of 342 · the rest on Bluesky ↗`. **The count turned out to be free**: both adapters already page the entire collection (`listAllRecords`, 10 × 100) before slicing, so they now return it — `fetchBlueskyPosts` → `{ posts, total }`, `fetchDocuments` → `{ documents, total, publication? }` (replies count towards neither). **The line only renders when there is somewhere to send the reader** — a count with no destination is a tease, not an affordance. Posts always have one, the owner's Bluesky profile by DID, matching the two links `SiteLayout` already builds. Writing has one when every listed document belongs to a single publication that publishes an address; documents spread across publications, or `pub.leaflet.*` legacy records which name none, get no line, and the label is the publication's name falling back to its host. Resolving that publication costs a read, so it is only attempted once a listing is known to be short, the two things wanting publication records now share one read instead of two, and a failed read costs the cue rather than the listing. No lexicon change, no record migration, no new route. 7 new tests (224 in `web`); verified in a browser through a temporary preview route covering posts-trimmed, posts-whole, writing-with-a-named-publication and writing-falling-back-to-its-host in both schemes, deleted before commit — accent-on-paper measured 6.58:1 light / 5.46:1 dark, 36px tall, no horizontal overflow at 375px. Checked against live PDS data: `jzweifel.dev` has five non-reply posts, so it is trimmed by neither the old limit nor the new one and renders unchanged. **One gap:** no account in hand exercises the cue against live data — it is covered by the tests and the dev preview, not by a deployed site; look the first time a heavier poster's site is rendered. - 2026-08-27 — **Bluesky post embeds render instead of a cue** (PR #41, **merged and deployed 2026-08-27**). Posts carrying an embed showed "· view on Bluesky" next to the date; critique 4 read the inconsistency as a glitch. Three of the four embed kinds turn out to cost nothing: `app.bsky.embed.external` and `app.bsky.embed.images` carry everything in the post record, so extraction is a pure function (`postEmbeds` in `atmosphere/bluesky.ts`, defensively parsed like the facet code beside it) and `recordWithMedia` is just both. Only `app.bsky.embed.record` needs the network. Jacob's calls: **hydrate quotes tolerantly** and **give images the full text measure with a height cap**. Hydration resolves the quoted author's DID document (one fetch yields both the PDS and the handle via `handleFromDidDocument`) then reads the post, all under a 2.5s `AbortSignal.timeout`, in parallel and only for the posts actually shown; any failure leaves a bare "Quoted post ↗" link. A self-quote skips the DID resolution entirely and renders without a byline. A quote that can neither be read nor linked — a feed generator, a list — is dropped so the post falls back to the Bluesky cue, which is now shown only for embed kinds the theme doesn't render (video, today). Theme work is one `postEmbed` snippet in `SiteSections.svelte`: link cards clamp title and blurb to two lines over a sunk-paper panel with the host in mono; a single image uses the record's `aspectRatio` as `width`/`height` attributes so the browser reserves the space, then `max-height: 22rem` does the fitting; two or more become a square-celled contact sheet. Two things fell out of the work: `AtmosphereContext` gained an optional `signal` that `xrpcGet` honors, and **`postPermalink` was too permissive** — it built a `/post/` URL for any collection, which would have produced broken links for non-post quote targets, so it now matches `app.bsky.feed.post` only (one guard, all callers). The existing `/blob` widths cover both a 4.5rem card thumb and a full-measure image, so the anticipated new thumb size wasn't needed. 16 new tests (217 in `web`); the recordWithMedia test caught a real nesting bug — that embed wraps its quote one level deeper than a bare `record` embed. Verified in a browser against live PDS data (`jzweifel.dev`: two real link cards with PDS thumbs, and the `recordWithMedia` post rendering its card plus a hydrated self-quote, cue gone, no overflow at 375px) and against a temporary preview route covering all seven states in both schemes, deleted before commit. Smoke-tested against the deployed origin: `mooring.page/s/jzweifel.dev` serves two link cards with their titles and hosts, and the `recordWithMedia` post renders its card plus the hydrated self-quote — the quoted text is in the server-rendered HTML with no `` and the right permalink, so the cross-network quote read runs fine from the Worker. No `view on Bluesky` cue anywhere on the page, and the per-post `aria-label` is intact. The card thumbs serve from `/blob/…?w=256` as **7.9 KB webp** (272 KB unresized jpeg in dev), so the zone's image transform covers the new thumb size without a widening of the allowlist. **One gap:** no live record exercises `app.bsky.embed.images` or a cross-author quote yet — both are covered by tests and the dev preview route, not by a deployed site. diff --git a/docs/decisions/adr/0016-site-metrics-on-analytics-engine.md b/docs/decisions/adr/0016-site-metrics-on-analytics-engine.md new file mode 100644 index 0000000..e8e827a --- /dev/null +++ b/docs/decisions/adr/0016-site-metrics-on-analytics-engine.md @@ -0,0 +1,97 @@ +# 0016 — Site metrics on Workers Analytics Engine + +Status: Proposed (2026-08-27) + +## Context + +PD-11 commits to site metrics: the full metric set (views, unique visitors, +views over time, top pages, top referrers, countries) free for every account +over a 7-day window, with the last 30 days joining the paid tier — no +fabricated data anywhere. The privacy stance is public: no cookies, no stored +IPs, no client-side beacon. The design, competitive read, and verified +platform facts are in `../../research/2026-08-26-analytics-offering.md`. + +Workers Analytics Engine (AE) stores data points written from the serving +Worker for three months, includes 10M writes/mo and 1M read queries/mo in the +plan Mooring already pays for, and is queried over a SQL-over-HTTPS API. AE +applies weighted adaptive sampling at volume: under sampling, `count()` must +be written `sum(_sample_interval)` and sums weighted per row, or numbers +silently read low exactly when a site gets popular. + +This ADR fixes the dataset schema and the operational specifics the research +file deliberately left to the build. + +## Decision + +**Dataset and binding.** One AE dataset, `mooring_site_views`, bound as +`METRICS`. One data point per successfully rendered HTML page view +(GET, 200, `text/html`), written from the request hook — the single point +where every tenant host has already resolved to a DID. Ingest is +fire-and-forget under `waitUntil`; a failure is logged and never affects the +response. + +**Schema.** + +| Field | Contents | +| -------- | ----------------------------------------------------- | +| `index1` | site DID (the sampling/query key) | +| `blob1` | path | +| `blob2` | referrer **host** (empty when absent or same-host) | +| `blob3` | country code from `request.cf.country` (empty absent) | +| `blob4` | daily visitor hash | + +No doubles: a view's count is `sum(_sample_interval)`. Referrers are stored +host-only — full URLs would carry query strings (tokens, PII) into the store +against the stated privacy stance, and hosts are what the dashboard +aggregates anyway. + +**Visitor hash.** `HMAC(salt, did + ip + ua)` where +`salt = HMAC(SESSION_SECRET, 'mooring-visitor:' + UTC date)`. The salt is +stateless — derived, never stored, no rotation job. The derivation string +domain-separates it from session-cookie signing, so the analytics path can +never produce a valid session signature. Raw IPs exist only in the request; +the hash rotates daily, so week-window uniques carry daily-visitor semantics +(one count per person per day) and the dashboard labels them accordingly. + +**Bots** are dropped at ingest via the `isbot` library (new dependency) — +never written, preserving write quota. Honest crawlers are excluded; anything +masquerading as a browser is counted, same as umami/Plausible self-hosted. + +**Excluded paths**: `/blob`, `/admin`, `/login`, `/oauth` — never recorded +regardless of response type. + +**Attribution.** Tenant hosts (subdomain and custom domain) record under the +tenant's DID. App-host traffic — the apex and `/s/` previews — records under +the DID named by the `METRICS_APP_DID` var (on mooring.page, the lexicon +authority DID `did:plc:o3zuar7kk2mrz7d4sqxdisy2`), so the operator reads the +product funnel through the same dashboard as any site owner. Unset, +app-host traffic is not recorded. + +**Reads** go through the SQL API +(`/accounts/{id}/analytics_engine/sql`, Bearer token with Account +Analytics:Read) from a sampling-aware query helper — every count is written +`sum(_sample_interval)` from day one. Config: `CLOUDFLARE_ACCOUNT_ID` var, +`ANALYTICS_READ_TOKEN` secret. + +**Dashboard** at `/admin/analytics` behind `requireSession`, 7-day window, +with a visible locked 30-day control until billing exists. Data written from +day one makes the unlock retroactive (AE already retains 3 months). + +**Self-host seam** (ADR 0005): absent the `METRICS` binding, ingest no-ops +and the dashboard route 404s. No storage abstraction while there is exactly +one backend. + +## Consequences + +- Two operator steps on deploy: set `CLOUDFLARE_ACCOUNT_ID` (var, in + wrangler.jsonc) and `wrangler secret put ANALYTICS_READ_TOKEN` (API token + scoped Account Analytics:Read). Without them the dashboard names the + missing config instead of showing numbers; ingest works regardless. +- Analytics data is service-side operational data in Cloudflare, not user + content — ADR 0004 untouched. It ages out at three months; >90d history + would need rollups this ADR deliberately does not build. +- `count(DISTINCT)` under sampling undercounts (a sampled-out visitor is + invisible). Accepted at Mooring's scale; the query helper pins the + sampling-aware forms in tests. +- The operator's top-pages list contains other people's handles as `/s/` + paths — ordinary server-log territory, operator-private. diff --git a/package-lock.json b/package-lock.json index 35a1af2..4115a3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@atproto/jwk-jose": "^0.2.4", "@atproto/lexicon": "^0.7.10", "@atproto/oauth-client-node": "^0.5.2", + "isbot": "^5.2.2", "markdown-it": "^15.0.0" }, "devDependencies": { @@ -2458,6 +2459,15 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/iso-datestring-validator": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", -- 2.51.2 From ca4f078a3a0d79befd40328b093b9ce15fdde90c Mon Sep 17 00:00:00 2001 From: Jacob Zweifel Date: Thu, 27 Aug 2026 20:27:56 -0400 Subject: [PATCH 2/2] NEXT: the site-metrics build is PR #45 --- docs/NEXT.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/NEXT.md b/docs/NEXT.md index 7350845..c07ad3c 100644 --- a/docs/NEXT.md +++ b/docs/NEXT.md @@ -2,7 +2,7 @@ The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual. -_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, **and now built** — ADR 0016 Proposed, PR open, deploy pending; see item 2 and Done. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ +_Last updated: 2026-08-27 (**PD-11 recorded: the site-metrics offering** — free real-7d / paid-30d on Analytics Engine, design fully settled in `research/2026-08-26-analytics-offering.md`, **and now built** — ADR 0016 Proposed, PR #45 open, deploy pending; see item 2 and Done. Also: **PRs #27, #28 and #31 are all merged and deployed.** mooring.page now serves the demo-first landing page (PD-10) — verified live: the apex carries the Open Graph card, `/s/jzweifel.dev` renders with the claim banner and `noindex`, and the domain lock, sign-off band and republished `signOff` lexicon from the earlier PRs are all in place. **PR #33 is merged and deployed too** — the postmark cut-off fix and the landing footer's attribution row (both below), smoke-tested live. **PR #35 is merged and deployed** — the admin and login pages wear the site letterhead (details below). **PR #37 is merged and deployed too** — it fixes the white frame #35 shipped with (the `body { margin: 0 }` reset lived only in SiteLayout's `:global` styles, so routes that never bundle SiteLayout kept the default body margin; `letterhead.css` now resets body margin and paints `html` with background + `color-scheme` via `:has(.letterhead)`, mirroring SiteLayout's own root treatment) — verified live: the served letterhead stylesheet on mooring.page/login carries the reset in both schemes. **PR #39 is merged and deployed too** — the theme's contrast guards for user color overrides (details below); verified live in both schemes. **PR #41 is merged and deployed too** — Bluesky post embeds render instead of the cue (details below); verified live. **PR #43 is open and not yet merged** — trimmed posts/writing sections now close with a cue carrying the count and a link to where the rest lives, and the career log stays whole by decision (details below); this closes the *volume behavior* sub-item of the theme tail. **#43 is in flight; the rest of the queue below is current.** The queue below is otherwise current. Critique trends, two separate targets: the rendered **theme** ran 25 → 31 → 29 → 32, and the **landing page** has one run at 21/40 — snapshots in `.impeccable/critique/`)._ ## Where things stand @@ -22,7 +22,7 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending - **Landing-page critique follow-ups, design-decision tier** (queued 2026-08-25; snapshot `.impeccable/critique/2026-08-26T01-52-10Z__apps-web-src-routes-page-svelte.md`, scored 21/40 — the three P1s and the mechanical P2s are fixed; what remains needs product calls): gloss or drop "standard.site"/"sifa" jargon for the non-technical launch audience; a pricing signal ("free subdomain" reads as an unpriced paywall); accept a pasted DID in the lookup (currently lowercased and rejected as a typo); distinguish resolver outages from typos in the error copy; and the big swing — show a real rendered site on the landing page instead of describing one ("you've built a rendering engine and put a text ad in front of it"). - **Explore extracting themeability, and a second theme — possibly nautical** (queued 2026-08-25, Jacob's hunch): "Mooring" reads as boats to plenty of people before it reads as airships, and the professional-presence theme is currently the only theme, its palette and motifs woven through `SiteLayout`/`SiteSections` rather than sitting behind a seam. Two questions to explore together: (1) what a theme contract would look like (tokens? component set? the `theme.colors` override mechanism already hints at one) and whether extracting it is worth the indirection while there is exactly one theme; (2) whether a harbor/nautical variant (same letter-and-postmark bones, different motif and palette) is a cheap second theme that meets boat-minded visitors where they land. Mock on the design canvas before building; ADR 0008 scope discipline applies — this is exploration, not a committed v1 item. -### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11; **built 2026-08-27, PR open — see Done**; deploy + live verification remain) +### 2. Site metrics — free 7d dashboard on Analytics Engine (PD-11; **built 2026-08-27, PR #45 open — see Done**; deploy + live verification remain) The build landed per the settled design (`research/2026-08-26-analytics-offering.md`) and ADR 0016 (Proposed — ratify on the PR). What remains, in order: (1) **deploy steps** — create an API token scoped Account Analytics:Read and `wrangler secret put ANALYTICS_READ_TOKEN` (the `CLOUDFLARE_ACCOUNT_ID` var is already in wrangler.jsonc; until the secret is set the dashboard names the gap and ingest records regardless), then merge + `npm run deploy -w web`; (2) **smoke-test live** — view a site, then read `/admin/analytics` signed in as the site's owner, and as the `mooring.page` authority account for the apex/`/s/` funnel; ingest-to-readable lag is expected to be minutes; (3) the **30d unlock** still waits on billing — data written from deploy day makes it retroactive. @@ -36,7 +36,7 @@ The build landed per the settled design (`research/2026-08-26-analytics-offering ## Done -- 2026-08-27 — **Site metrics built end to end** (PD-11's build session; PR **open, not merged or deployed**). ADR 0016 (Proposed) fixes the specifics: dataset `mooring_site_views` bound as `METRICS`, one data point per rendered HTML page view (GET · 200 · `text/html`) written from `hooks.server.ts` under `waitUntil` — the one place every tenant host has already resolved to a DID — with `index1` = site DID and blobs path / referrer host / country / daily visitor hash. Two calls made in-session (both the recommended option): **referrers are stored host-only** — full URLs would carry query strings into the store against the privacy stance, and hosts are what the dashboard aggregates — and the **salt derives from `SESSION_SECRET`** (`HMAC(secret, 'mooring-visitor:' + UTC date)`, the literal prefix domain-separating it from cookie signing) so self-host stays one-secret and there is nothing to rotate or store. Bots drop at ingest via `isbot` (new dependency; learned in testing: it flags a bare `Mozilla/5.0` as headless, so test fixtures need full UA token chains). App-host traffic records under the new `METRICS_APP_DID` var (the authority DID); tenant traffic under the tenant. The query layer (`lib/server/metrics/query.ts`) is **sampling-aware from day one** — every count is `sum(_sample_interval)`, and a test literally greps the SQL to fail any bare `count()` that sneaks in; DIDs are shape-validated before inlining into SQL. `/admin/analytics` (nav link added) renders tiles, a 7-bar views-by-day chart (peak labeled, zero days as rule-colored stubs), and top pages / referrers / countries with `Intl.DisplayNames` country names — plus the locked "30 days · paid tier" chip, the retroactivity promise, and the three-month horizon stated honestly. Distinct states for unconfigured (names the missing var/secret; says recording still works), store-didn't-answer, and empty. Self-host seam: no `METRICS` binding → ingest no-ops, the route 404s. 36 new tests (260 in `web`); verified in a browser through a temporary preview route (all four states, both schemes, desktop + 375px, no console errors), deleted before commit. **Nothing is live yet**: deploy steps and live smoke-test are item 2 above. +- 2026-08-27 — **Site metrics built end to end** (PD-11's build session; PR #45, **open, not merged or deployed**). ADR 0016 (Proposed) fixes the specifics: dataset `mooring_site_views` bound as `METRICS`, one data point per rendered HTML page view (GET · 200 · `text/html`) written from `hooks.server.ts` under `waitUntil` — the one place every tenant host has already resolved to a DID — with `index1` = site DID and blobs path / referrer host / country / daily visitor hash. Two calls made in-session (both the recommended option): **referrers are stored host-only** — full URLs would carry query strings into the store against the privacy stance, and hosts are what the dashboard aggregates — and the **salt derives from `SESSION_SECRET`** (`HMAC(secret, 'mooring-visitor:' + UTC date)`, the literal prefix domain-separating it from cookie signing) so self-host stays one-secret and there is nothing to rotate or store. Bots drop at ingest via `isbot` (new dependency; learned in testing: it flags a bare `Mozilla/5.0` as headless, so test fixtures need full UA token chains). App-host traffic records under the new `METRICS_APP_DID` var (the authority DID); tenant traffic under the tenant. The query layer (`lib/server/metrics/query.ts`) is **sampling-aware from day one** — every count is `sum(_sample_interval)`, and a test literally greps the SQL to fail any bare `count()` that sneaks in; DIDs are shape-validated before inlining into SQL. `/admin/analytics` (nav link added) renders tiles, a 7-bar views-by-day chart (peak labeled, zero days as rule-colored stubs), and top pages / referrers / countries with `Intl.DisplayNames` country names — plus the locked "30 days · paid tier" chip, the retroactivity promise, and the three-month horizon stated honestly. Distinct states for unconfigured (names the missing var/secret; says recording still works), store-didn't-answer, and empty. Self-host seam: no `METRICS` binding → ingest no-ops, the route 404s. 36 new tests (260 in `web`); verified in a browser through a temporary preview route (all four states, both schemes, desktop + 375px, no console errors), deleted before commit. **Nothing is live yet**: deploy steps and live smoke-test are item 2 above. - 2026-08-27 — **A section that stops short says so, and says where the rest is** (PR #43, **open**). Posts and writing rendered a fixed head of their collections and simply stopped: a reader with 342 posts saw five, with nothing on the page saying so and nowhere to go for the rest, while the career log rendered every record — three inconsistent volume behaviours on one page. Jacob's calls, all three the recommended option: **a stamped cue that links out** rather than an in-place disclosure or an archive route (quote hydration deliberately runs only over the *shown* posts — each quote costs a DID-document resolution plus a post read — so revealing forty more would either pay for forty more of those or render the hidden ones bare); **the career log stays whole** (a résumé's point is showing the log, its lines are compact, and a truncated work history reads as concealment rather than curation — so `positions`/`education` are untouched, and the existing `SKILL_CAP` disclosure stands); and **the default post count moves 5 → 8**, which an honest cue makes affordable. A trimmed list now closes with one line in the theme's system voice — mono, accent, above a dotted rule that replaces the list's own last border: `8 of 342 · the rest on Bluesky ↗`. **The count turned out to be free**: both adapters already page the entire collection (`listAllRecords`, 10 × 100) before slicing, so they now return it — `fetchBlueskyPosts` → `{ posts, total }`, `fetchDocuments` → `{ documents, total, publication? }` (replies count towards neither). **The line only renders when there is somewhere to send the reader** — a count with no destination is a tease, not an affordance. Posts always have one, the owner's Bluesky profile by DID, matching the two links `SiteLayout` already builds. Writing has one when every listed document belongs to a single publication that publishes an address; documents spread across publications, or `pub.leaflet.*` legacy records which name none, get no line, and the label is the publication's name falling back to its host. Resolving that publication costs a read, so it is only attempted once a listing is known to be short, the two things wanting publication records now share one read instead of two, and a failed read costs the cue rather than the listing. No lexicon change, no record migration, no new route. 7 new tests (224 in `web`); verified in a browser through a temporary preview route covering posts-trimmed, posts-whole, writing-with-a-named-publication and writing-falling-back-to-its-host in both schemes, deleted before commit — accent-on-paper measured 6.58:1 light / 5.46:1 dark, 36px tall, no horizontal overflow at 375px. Checked against live PDS data: `jzweifel.dev` has five non-reply posts, so it is trimmed by neither the old limit nor the new one and renders unchanged. **One gap:** no account in hand exercises the cue against live data — it is covered by the tests and the dev preview, not by a deployed site; look the first time a heavier poster's site is rendered.