wiki-formant 0.27.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -551,27 +551,55 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
551
551
 
552
552
  ## Analytics
553
553
 
554
- Two lanes, one events helper. `mcpCallProps` reads a tool name out of a JSON-RPC
555
- envelope that is untrusted and may be a batch; `searchQueryProps` normalises a
556
- search box's free text so the same question aggregates as one row. Sending stays
557
- with the caller — `plausibleEvent` builds the request, and the framework's own
558
- deferral (`after()` in a route or server action, `event.waitUntil` in a proxy)
559
- decides when it goes, so this package keeps its zero-dependency guarantee.
554
+ Page views and events, counted in the site's own Postgres instead of a hosted
555
+ service. No cookie is set and no address is kept: a visitor is a hash of the
556
+ address and browser under a salt that is replaced every UTC day, and the old
557
+ salt is deleted, so a visitor is told apart within a day and never followed
558
+ into the next. Rows are kept 400 days. The package owns the SQL and the app
559
+ owns the connection: `Sql` is one function that runs a statement with `$1, $2…`
560
+ placeholders, and the three tables it needs are stated as Prisma models at the
561
+ top of `src/analytics.ts`.
562
+
563
+ ```ts
564
+ // src/lib/track.ts
565
+ export const sql: Sql = (query, ...values) => prisma.$queryRawUnsafe(query, ...values);
566
+
567
+ // src/app/api/view/route.ts – the whole beacon route
568
+ export const POST = (request: Request) => collect(request, { sql, defer: after });
569
+
570
+ // the root layout, once
571
+ <Beacon />
572
+ ```
573
+
574
+ `<Beacon>` (`wiki-formant/react`) sends a view each time the path changes,
575
+ client-side moves included, and a click on a link to another host as an
576
+ "Outbound Link: Click" event; `track(name, props)` (`wiki-formant/dom`) sends
577
+ any other browser event. Server code calls `recordEvent` and leaves the
578
+ deferral to the framework, so this package keeps its zero-dependency
579
+ guarantee:
560
580
 
561
581
  ```ts
562
582
  const props = searchQueryProps({ query, results: rows.length, surface: 'wiki' });
563
583
  // null for an empty field, so a blank search cannot fire an event
564
- if (props) after(() => plausibleEvent({ domain }, 'Search Query', url, props, headers, {
565
- // a person triggered this, so it joins their session rather than landing
566
- // as the fixed bot-tracker pseudo-visitor
567
- userAgent: headers.get('user-agent') ?? undefined,
568
- }));
584
+ if (props) after(() => recordEvent(sql, { name: 'Search Query', props, url, headers }));
569
585
  ```
570
586
 
587
+ `mcpCallProps` reads a tool name out of a JSON-RPC envelope that is untrusted
588
+ and may be a batch; `searchQueryProps` normalises a search box's free text so
589
+ the same question aggregates as one row. Pass the request's headers and the
590
+ event joins whoever sent it, person or agent.
591
+
592
+ `digest(sql, { days })` reads a site back as one JSON object: visitors, page
593
+ views, bounce rate, visit length, sources (a link's `utm_source`, else the
594
+ referring host), top and entry pages, countries, devices, visitors by day, MCP
595
+ tool calls, and the searches that found nothing. A visit is a run of one
596
+ visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
597
+ key to every call and a `site` resolver to `collect`.
598
+
571
599
  Instrumenting the agent lane and not the human one is the easy mistake: it
572
600
  leaves a wiki able to say what every crawler asked for and nothing about what
573
- its readers asked for. The queries that return zero rows are the valuable ones —
574
- they name a gap in the corpus in the reader's own words.
601
+ its readers asked for. The queries that return zero rows are the valuable ones
602
+ – they name a gap in the corpus in the reader's own words.
575
603
 
576
604
  ## Base stylesheet
577
605
 
@@ -605,6 +633,14 @@ npx check-classes --warn # report and exit 0
605
633
  npx check-classes --compositions # also fail on the inline compositions
606
634
  ```
607
635
 
636
+ `analytics-digest` prints a site's `digest` as JSON for a script outside the app. Run it from the app's root, where `pg` is installed and `.env` holds `DATABASE_URL`:
637
+
638
+ ```sh
639
+ npx analytics-digest --days 7
640
+ npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
641
+ npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds an event to the search gaps
642
+ ```
643
+
608
644
  ## Licence
609
645
 
610
646
  MIT.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // bin/analytics-digest.mjs – print one site's `digest` (`wiki-formant/analytics`)
3
+ // as JSON, for a script that reads traffic from outside the app.
4
+ //
5
+ // Run from the app's root, where `pg` is installed and `.env` holds
6
+ // DATABASE_URL (read from the environment first). The SQL is the package's, so
7
+ // a report reads the figures the app's own pages read.
8
+ //
9
+ // npx analytics-digest --days 7
10
+ // npx analytics-digest --days 30 --handle radixwiki --siblings caper.network,acuiq.com
11
+ // npx analytics-digest --days 7 --site <id> # a multi-tenant app's site
12
+ // npx analytics-digest --days 7 --gap "Symptom Miss:term" # adds to the search gaps
13
+ import pg from 'pg';
14
+ import { digest, SEARCH_GAPS } from '../dist/analytics.js';
15
+
16
+ const args = process.argv.slice(2);
17
+ const arg = name => {
18
+ const i = args.indexOf(name);
19
+ return i >= 0 ? args[i + 1] : undefined;
20
+ };
21
+ const all = name => args.flatMap((a, i) => (a === name && args[i + 1] ? [args[i + 1]] : []));
22
+
23
+ if (!process.env.DATABASE_URL) {
24
+ try {
25
+ process.loadEnvFile('.env');
26
+ } catch {
27
+ // reported below
28
+ }
29
+ }
30
+ if (!process.env.DATABASE_URL) {
31
+ console.log(JSON.stringify({ error: 'no DATABASE_URL in the environment or ./.env' }));
32
+ process.exit(0);
33
+ }
34
+
35
+ const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
36
+ try {
37
+ await client.connect();
38
+ const sql = (query, ...values) => client.query(query, values).then(r => r.rows);
39
+ const result = await digest(sql, {
40
+ site: arg('--site') ?? '',
41
+ days: Number(arg('--days') ?? 7),
42
+ handle: arg('--handle'),
43
+ siblings: arg('--siblings')?.split(',').filter(Boolean) ?? [],
44
+ gaps: [
45
+ SEARCH_GAPS,
46
+ ...all('--gap').map(g => {
47
+ const [event, prop] = g.split(':');
48
+ return { event, prop };
49
+ }),
50
+ ],
51
+ });
52
+ console.log(JSON.stringify(result));
53
+ } catch (e) {
54
+ console.log(JSON.stringify({ error: e.message }));
55
+ } finally {
56
+ await client.end().catch(() => {});
57
+ }
@@ -1,36 +1,127 @@
1
- import type { HeaderReader } from './rate-limit.js';
2
- export interface PlausibleConfig {
3
- /** The hostname the Plausible property is registered under. */
4
- domain: string;
5
- /** Override for self-hosted Plausible. */
6
- endpoint?: string;
1
+ import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
2
+ /**
3
+ * Runs one statement with `$1, $2…` placeholders and resolves to its rows.
4
+ * Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
5
+ */
6
+ export type Sql = (query: string, ...values: unknown[]) => Promise<unknown>;
7
+ /** Long enough to compare a month with the same month a year before. */
8
+ export declare const KEEP_DAYS = 400;
9
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
10
+ export declare const deviceOf: (ua: string) => "phone" | "tablet" | "computer";
11
+ /**
12
+ * Where a visit came from: its `utm_source` when the link carried one, else the
13
+ * referring host, and null for a referrer that is this site or none.
14
+ */
15
+ export declare function sourceOf(referrer: unknown, utm: unknown, host: string): string | null;
16
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
17
+ export declare function normalisePath(path: string): string | null;
18
+ export interface ViewInput {
19
+ /** The beacon request's headers: the address, browser, host and country come from them. */
20
+ headers: HeaderReader;
21
+ path: string;
22
+ referrer?: unknown;
23
+ /** The page URL's `utm_source`. */
24
+ utm?: unknown;
25
+ site?: string;
26
+ }
27
+ /** Count one page view, unless a bot sent it. Never rejects. */
28
+ export declare function recordView(sql: Sql, { headers, path, referrer, utm, site }: ViewInput): Promise<void>;
29
+ export interface EventInput {
30
+ name: string;
31
+ props?: Record<string, string | number>;
32
+ /** The page it happened on, as a URL or a path. */
33
+ url?: string;
34
+ /** The request of whoever caused it, so the event joins their visitor. Omit for an event no visitor caused. */
35
+ headers?: HeaderReader;
36
+ site?: string;
37
+ }
38
+ /** Record one named event. Never rejects. */
39
+ export declare function recordEvent(sql: Sql, { name, props, url, headers, site }: EventInput): Promise<void>;
40
+ /** What a page sends: a view, or with `name` an event. */
41
+ export interface Beacon {
42
+ path: string;
43
+ referrer?: string;
44
+ utm?: string;
45
+ name?: string;
46
+ props?: Record<string, string | number>;
7
47
  }
8
- export interface PlausibleExtra {
9
- /** Revenue goal payload; accepted and ignored on plans without revenue goals. */
10
- revenue?: {
11
- currency: string;
12
- amount: number;
13
- };
48
+ export interface CollectOptions {
49
+ sql: Sql;
50
+ /** `after` in a Next route: the write runs once the 204 has gone. */
51
+ defer: (task: () => Promise<unknown>) => void;
52
+ /** The site a beacon counts toward, or null to drop it. A single-site app omits it. */
53
+ site?: (request: Request, path: string) => string | null | Promise<string | null>;
54
+ rateLimit?: RateLimitOptions;
55
+ }
56
+ /**
57
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
58
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
59
+ */
60
+ export declare function collect(request: Request, { sql, defer, site, rateLimit: limit }: CollectOptions): Promise<Response>;
61
+ export interface Gap {
62
+ event: string;
63
+ /** The prop holding the reader's words. */
64
+ prop: string;
65
+ /** Props the event must carry, e.g. `{ results: '0' }`. */
66
+ where?: Record<string, string>;
67
+ }
68
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
69
+ export declare const SEARCH_GAPS: Gap;
70
+ export interface DigestOptions {
71
+ site?: string;
72
+ /** How far back, in whole days from now. */
73
+ days: number;
74
+ /** The site's own X handle: `follow_clicks` counts clicks on links to it. */
75
+ handle?: string;
76
+ /** Hosts of sibling sites: `sibling_referrals` counts the visits they sent. */
77
+ siblings?: string[];
78
+ gaps?: Gap[];
14
79
  /**
15
- * Send the visitor's own user agent so the event joins their session rather
16
- * than landing as the fixed bot-tracker pseudo-visitor. Right for an event
17
- * a person triggered; wrong for one a crawler did.
80
+ * MCP callers left out of `agent_tool_calls`, matched inside the user agent.
81
+ * The default is the conformance suites (`<repo>-mcp-test`) and the studio's own tools.
18
82
  */
19
- userAgent?: string;
83
+ ownAgents?: string[];
84
+ }
85
+ type Ranked<K extends string, M extends string> = Array<Record<K, string> & Record<M, number>>;
86
+ export interface Digest {
87
+ visitors: number;
88
+ pageviews: number;
89
+ /** Percent of visits that saw one page; null with no visits. */
90
+ bounce_rate: number | null;
91
+ /** Mean seconds from a visit's first view to its last; null with no visits. */
92
+ visit_duration: number | null;
93
+ /** Visitors plus every agent that sent an event, such as an MCP call. */
94
+ visitors_incl_agents: number;
95
+ /** Visitors in the same number of days before. */
96
+ previous_visitors: number;
97
+ top_sources: Ranked<'source', 'visitors'>;
98
+ top_pages: Ranked<'page', 'visitors'>;
99
+ entry_pages: Ranked<'page', 'visitors'>;
100
+ /** Entry pages of visits from links tagged `utm_source=x`. */
101
+ from_posts: Ranked<'page', 'visitors'>;
102
+ sibling_referrals: Ranked<'source', 'visitors'>;
103
+ countries: Ranked<'country', 'visitors'>;
104
+ devices: Ranked<'device', 'visitors'>;
105
+ by_day: Ranked<'date', 'visitors'>;
106
+ follow_clicks: {
107
+ events: number;
108
+ visitors: number;
109
+ } | null;
110
+ agent_tool_calls: Ranked<'tool', 'events'>;
111
+ gaps: Ranked<'query', 'events'>;
20
112
  }
21
113
  /**
22
- * The exact request shape Plausible's events API expects.
23
- *
24
- * Never rejects: analytics must not be able to fail a response the reader is
25
- * waiting on.
114
+ * Everything a report needs about one site's last `days` days, in one query.
115
+ * Visitor figures count distinct visitors, so a person who comes back on
116
+ * another day counts once for each day.
26
117
  */
27
- export declare function plausibleEvent(config: PlausibleConfig, name: string, url: string, props: Record<string, string>, headers: HeaderReader, extra?: PlausibleExtra): Promise<unknown>;
118
+ export declare function digest(sql: Sql, { site, days, handle, siblings, gaps, ownAgents }: DigestOptions): Promise<Digest>;
28
119
  /**
29
120
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
30
121
  *
31
122
  * The body is untrusted and may be a batch, a notification, or malformed
32
123
  * entirely, so every extraction is defensive and the result is always a
33
- * complete props object. A batch is attributed to its first member — the
124
+ * complete props object. A batch is attributed to its first member – the
34
125
  * alternative is one event per member, which would make a fan-out look like
35
126
  * traffic it is not.
36
127
  */
@@ -47,7 +138,7 @@ export interface SearchQueryInput {
47
138
  surface?: string;
48
139
  }
49
140
  /**
50
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
141
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
51
142
  *
52
143
  * A wiki instruments its agent surface and then counts every question an agent
53
144
  * asks while recording none of the questions a person asks. The queries that
@@ -56,9 +147,8 @@ export interface SearchQueryInput {
56
147
  *
57
148
  * Returns `null` for a query with no content, so an empty field cannot fire an
58
149
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
59
- * and truncated — the same 64-character bound search itself applies, which
150
+ * and truncated – the same 64-character bound search itself applies, which
60
151
  * keeps the prop and the query that produced it the same string.
61
152
  */
62
153
  export declare function searchQueryProps({ query, results, surface, }: SearchQueryInput): Record<string, string> | null;
63
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
64
- export declare function plausibleDomain(siteUrl: string | undefined, fallback: string): string;
154
+ export {};
package/dist/analytics.js CHANGED
@@ -1,49 +1,235 @@
1
- // analytics.ts — server-side Plausible events for a wiki's agent and human lanes.
1
+ // analytics.ts – page views and events, counted in the site's own Postgres.
2
2
  //
3
- // The proxy counts an "AI Bot Visit" by matching user agents, which cannot see
4
- // inside a JSON-RPC envelope. So an MCP call is only countable by tool name at
5
- // the route, and that extraction — from a body that is untrusted and may be a
6
- // batch — is the part worth sharing. Every wiki in the workspace had its own
7
- // copy of it, differing only in the default hostname.
3
+ // It replaces a hosted analytics service. A page reports its own view to the
4
+ // site's own address (`startBeacon` in `dom.ts`, `<Beacon>` in `react.tsx`), a
5
+ // route hands the beacon to `collect`, and server code records events with
6
+ // `recordEvent`. `digest` reads it all back as one JSON object, and the
7
+ // `analytics-digest` bin prints that object for scripts outside the app.
8
8
  //
9
- // `searchQueryProps` is the human-side twin: the same shape problem, from the
10
- // other direction. A search box is untrusted free text on every wiki, and the
11
- // normalisation that makes it aggregatable — and keeps an empty field from
12
- // firing an event — is the part worth sharing rather than re-deriving.
9
+ // No cookie is set and no address is kept. A visitor is a hash of the address
10
+ // and the browser under a salt that changes every UTC day, and the old salt is
11
+ // deleted when the new one is made, so a visitor can be told apart within a day
12
+ // and never followed into the next. A visit is a run of one visitor's views
13
+ // with no gap over 30 minutes.
13
14
  //
14
- // Deferral stays with the caller: `after()` in a route handler,
15
- // `event.waitUntil` in a proxy. Both are framework calls, and importing one
16
- // here would cost this package its zero-dependency guarantee.
17
- const DEFAULT_ENDPOINT = 'https://plausible.io/api/event';
15
+ // The package owns the SQL; the app owns the connection. `Sql` is one function
16
+ // that runs a parameterised statement, which Prisma, `pg` and postgres.js can
17
+ // all supply in a line. The app declares the three tables in its own schema,
18
+ // so `prisma db push` keeps them:
19
+ //
20
+ // model View {
21
+ // id BigInt @id @default(autoincrement())
22
+ // siteId String @default("")
23
+ // path String
24
+ // source String?
25
+ // country String?
26
+ // device String
27
+ // visitor String
28
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
29
+ // @@index([siteId, createdAt])
30
+ // }
31
+ //
32
+ // model Event {
33
+ // id BigInt @id @default(autoincrement())
34
+ // siteId String @default("")
35
+ // name String
36
+ // path String?
37
+ // props Json @default("{}")
38
+ // visitor String?
39
+ // createdAt DateTime @default(now()) @db.Timestamptz(3)
40
+ // @@index([siteId, name, createdAt])
41
+ // }
42
+ //
43
+ // model Salt {
44
+ // day String @id
45
+ // value String
46
+ // }
47
+ //
48
+ // `siteId` is "" in an app that serves one site. A multi-tenant app passes its
49
+ // own site key to every call.
50
+ //
51
+ // Deferral stays with the caller: `after()` in a route, `event.waitUntil` in a
52
+ // proxy. Both are framework calls, and importing one here would cost this
53
+ // package its zero-dependency guarantee. Hashing is Web Crypto, so the module
54
+ // runs anywhere Node 20, a browser or an edge runtime does.
55
+ import { clientIp, clientKey, rateLimit } from './rate-limit.js';
56
+ /** Long enough to compare a month with the same month a year before. */
57
+ export const KEEP_DAYS = 400;
58
+ const DAY_MS = 86_400_000;
59
+ /** A script that runs a page's JavaScript and says what it is. */
60
+ const BOT = /bot|crawl|spider|slurp|headless|lighthouse|prerender|preview/i;
61
+ /** iPadOS reports itself as a Mac, so its views count as a computer's. */
62
+ export const deviceOf = (ua) => /iPad|Tablet|Android(?!.*Mobile)/i.test(ua) ? 'tablet' : /Mobi|iPhone|Android/i.test(ua) ? 'phone' : 'computer';
63
+ const bareHost = (host) => host.toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
18
64
  /**
19
- * The exact request shape Plausible's events API expects.
20
- *
21
- * Never rejects: analytics must not be able to fail a response the reader is
22
- * waiting on.
65
+ * Where a visit came from: its `utm_source` when the link carried one, else the
66
+ * referring host, and null for a referrer that is this site or none.
23
67
  */
24
- export function plausibleEvent(config, name, url, props, headers, extra = {}) {
25
- return fetch(config.endpoint ?? DEFAULT_ENDPOINT, {
26
- method: 'POST',
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- 'User-Agent': extra.userAgent || 'Mozilla/5.0 (compatible; BotTracker/1.0)',
30
- 'X-Forwarded-For': headers.get('x-forwarded-for') || headers.get('x-real-ip') || '127.0.0.1',
31
- },
32
- body: JSON.stringify({
33
- name,
34
- url,
35
- domain: config.domain,
36
- props: JSON.stringify(props),
37
- ...(extra.revenue ? { revenue: extra.revenue } : {}),
38
- }),
39
- }).catch(() => undefined);
68
+ export function sourceOf(referrer, utm, host) {
69
+ if (typeof utm === 'string' && utm.trim())
70
+ return utm.trim().toLowerCase().slice(0, 100);
71
+ if (typeof referrer !== 'string' || !referrer)
72
+ return null;
73
+ try {
74
+ const from = bareHost(new URL(referrer).hostname);
75
+ return from && from !== bareHost(host) ? from.slice(0, 200) : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ /** A browser path as stored: decoded, one leading slash, no trailing one. Null when it will not decode. */
82
+ export function normalisePath(path) {
83
+ try {
84
+ const p = decodeURIComponent(path.split(/[?#]/)[0].slice(0, 500));
85
+ return `/${p.replace(/^\/+|\/+$/g, '')}`;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ const hex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
92
+ const salts = new WeakMap();
93
+ /** Today's salt. Whoever makes it also deletes yesterday's, and rows past keeping. */
94
+ async function todaysSalt(sql) {
95
+ const day = new Date().toISOString().slice(0, 10);
96
+ const cached = salts.get(sql);
97
+ if (cached?.day === day)
98
+ return cached.value;
99
+ const made = (await sql('INSERT INTO "Salt" (day, value) VALUES ($1, $2) ON CONFLICT (day) DO NOTHING RETURNING day', day, hex(crypto.getRandomValues(new Uint8Array(32)))));
100
+ if (made.length) {
101
+ const cutoff = new Date(Date.now() - KEEP_DAYS * DAY_MS);
102
+ await sql('DELETE FROM "Salt" WHERE day < $1', day);
103
+ await sql('DELETE FROM "View" WHERE "createdAt" < $1', cutoff);
104
+ await sql('DELETE FROM "Event" WHERE "createdAt" < $1', cutoff);
105
+ }
106
+ const [row] = (await sql('SELECT value FROM "Salt" WHERE day = $1', day));
107
+ salts.set(sql, { day, value: row.value });
108
+ return row.value;
109
+ }
110
+ async function visitorOf(sql, site, headers) {
111
+ const text = [await todaysSalt(sql), site, clientIp(headers), headers.get('user-agent') ?? ''].join('\n');
112
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
113
+ return hex(new Uint8Array(digest)).slice(0, 32);
114
+ }
115
+ /** Analytics must not be able to fail a response, so a failed write is logged and dropped. */
116
+ const quietly = (task) => task().then(() => undefined, (e) => console.error(`analytics: ${e.message}`));
117
+ /** Count one page view, unless a bot sent it. Never rejects. */
118
+ export function recordView(sql, { headers, path, referrer, utm, site = '' }) {
119
+ const ua = headers.get('user-agent') ?? '';
120
+ const page = normalisePath(path);
121
+ if (!ua || BOT.test(ua) || page === null)
122
+ return Promise.resolve();
123
+ return quietly(async () => sql('INSERT INTO "View" ("siteId", path, source, country, device, visitor) VALUES ($1, $2, $3, $4, $5, $6)', site, page, sourceOf(referrer, utm, headers.get('host') ?? ''), headers.get('x-vercel-ip-country')?.slice(0, 2) || null, deviceOf(ua), await visitorOf(sql, site, headers)));
124
+ }
125
+ /** Record one named event. Never rejects. */
126
+ export function recordEvent(sql, { name, props = {}, url, headers, site = '' }) {
127
+ return quietly(async () => {
128
+ let path = null;
129
+ if (url)
130
+ path = normalisePath(new URL(url, 'http://x').pathname);
131
+ return sql('INSERT INTO "Event" ("siteId", name, path, props, visitor) VALUES ($1, $2, $3, $4::jsonb, $5)', site, name.slice(0, 64), path, JSON.stringify(props), headers?.get('user-agent') ? await visitorOf(sql, site, headers) : null);
132
+ });
133
+ }
134
+ /** A browser-sent event's props: eight at most, each value a short string or a number. */
135
+ function beaconProps(props) {
136
+ if (!props || typeof props !== 'object' || Array.isArray(props))
137
+ return {};
138
+ return Object.fromEntries(Object.entries(props)
139
+ .filter(([, v]) => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v)))
140
+ .slice(0, 8)
141
+ .map(([k, v]) => [k.slice(0, 40), typeof v === 'string' ? v.slice(0, 300) : v]));
142
+ }
143
+ // A person reads a few pages a minute. This leaves room for a fast reader and
144
+ // stops one address filling a site's figures.
145
+ const BEACONS = { capacity: 30, refillPerSec: 0.5 };
146
+ /**
147
+ * The whole beacon route: `export const POST = (r: Request) => collect(r, { sql, defer: after })`.
148
+ * Answers 204 whatever happens, before anything is written, so a reader never waits on it.
149
+ */
150
+ export async function collect(request, { sql, defer, site, rateLimit: limit = BEACONS }) {
151
+ const done = new Response(null, { status: 204 });
152
+ const { headers } = request;
153
+ if (BOT.test(headers.get('user-agent') ?? '') || !rateLimit(clientKey('beacon', headers), limit).ok)
154
+ return done;
155
+ const body = (await request.json().catch(() => null));
156
+ const path = typeof body?.path === 'string' ? normalisePath(body.path) : null;
157
+ if (!body || !path)
158
+ return done;
159
+ const key = site ? await site(request, path) : '';
160
+ if (key === null)
161
+ return done;
162
+ defer(() => typeof body.name === 'string' && body.name
163
+ ? recordEvent(sql, { name: body.name, props: beaconProps(body.props), url: path, headers, site: key })
164
+ : recordView(sql, { headers, path, referrer: body.referrer, utm: body.utm, site: key }));
165
+ return done;
166
+ }
167
+ /** Searches in a wiki's own search box that found nothing (`searchQueryProps` events). */
168
+ export const SEARCH_GAPS = { event: 'Search Query', prop: 'q', where: { results: '0', surface: 'wiki' } };
169
+ /**
170
+ * Everything a report needs about one site's last `days` days, in one query.
171
+ * Visitor figures count distinct visitors, so a person who comes back on
172
+ * another day counts once for each day.
173
+ */
174
+ export async function digest(sql, { site = '', days, handle, siblings = [], gaps = [SEARCH_GAPS], ownAgents = ['-mcp-test', 'radix-studio'] }) {
175
+ // The window is the database's clock, which stamped the rows; the app's can run behind it.
176
+ const values = [site, Math.max(1, Math.round(days))];
177
+ const $ = (v) => `$${values.push(v)}`;
178
+ const list = (select, limit, order = '2 DESC, 1') => `(SELECT coalesce(json_agg(t), '[]') FROM (${select} ORDER BY ${order}${limit ? ` LIMIT ${limit}` : ''}) t)`;
179
+ const gapSelects = gaps.map(g => {
180
+ const prop = $(g.prop);
181
+ return `(SELECT props->>${prop} AS query, count(*) AS events FROM e
182
+ WHERE name = ${$(g.event)} AND props @> ${$(JSON.stringify(g.where ?? {}))}::jsonb AND props->>${prop} IS NOT NULL
183
+ GROUP BY 1 ORDER BY 2 DESC, 1 LIMIT 15)`;
184
+ });
185
+ const [row] = (await sql(`WITH v AS (
186
+ SELECT id, path, source, country, device, visitor, "createdAt" AS at FROM "View"
187
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
188
+ ), marked AS (
189
+ SELECT *, CASE WHEN at - lag(at) OVER (PARTITION BY visitor ORDER BY at, id) <= interval '30 minutes' THEN 0 ELSE 1 END AS starts FROM v
190
+ ), numbered AS (
191
+ SELECT *, sum(starts) OVER (PARTITION BY visitor ORDER BY at, id) AS visit FROM marked
192
+ ), visits AS (
193
+ SELECT visitor, count(*) AS views, extract(epoch FROM max(at) - min(at)) AS seconds,
194
+ (array_agg(path ORDER BY at, id))[1] AS entry, (array_agg(source ORDER BY at, id))[1] AS source
195
+ FROM numbered GROUP BY visitor, visit
196
+ ), e AS (
197
+ SELECT name, props, visitor FROM "Event" WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => $2::int)
198
+ )
199
+ SELECT json_build_object(
200
+ 'visitors', (SELECT count(DISTINCT visitor) FROM v),
201
+ 'pageviews', (SELECT count(*) FROM v),
202
+ 'bounce_rate', (SELECT round(100.0 * count(*) FILTER (WHERE views = 1) / nullif(count(*), 0)) FROM visits),
203
+ 'visit_duration', (SELECT round(avg(seconds)) FROM visits),
204
+ 'visitors_incl_agents', (SELECT count(DISTINCT visitor) FROM (SELECT visitor FROM v UNION SELECT visitor FROM e) a),
205
+ 'previous_visitors', (SELECT count(DISTINCT visitor) FROM "View"
206
+ WHERE "siteId" = $1 AND "createdAt" >= now() - make_interval(days => 2 * $2::int) AND "createdAt" < now() - make_interval(days => $2::int)),
207
+ 'top_sources', ${list(`SELECT coalesce(source, '(none)') AS source, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1`, 6)},
208
+ 'top_pages', ${list('SELECT path AS page, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1', 6)},
209
+ 'entry_pages', ${list('SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits GROUP BY 1', 6)},
210
+ 'from_posts', ${list(`SELECT entry AS page, count(DISTINCT visitor) AS visitors FROM visits WHERE source = 'x' GROUP BY 1`, 15)},
211
+ 'sibling_referrals', ${list(`SELECT source, count(DISTINCT visitor) AS visitors FROM visits WHERE source = ANY(${$(siblings)}::text[]) GROUP BY 1`, 6)},
212
+ 'countries', ${list('SELECT country, count(DISTINCT visitor) AS visitors FROM v WHERE country IS NOT NULL GROUP BY 1', 10)},
213
+ 'devices', ${list('SELECT device, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1')},
214
+ 'by_day', ${list(`SELECT to_char(at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS date, count(DISTINCT visitor) AS visitors FROM v GROUP BY 1`, undefined, '1')},
215
+ 'follow_clicks', ${handle
216
+ ? `(SELECT json_build_object('events', count(*), 'visitors', count(DISTINCT visitor)) FROM e
217
+ WHERE name = 'Outbound Link: Click' AND props->>'url' ILIKE ANY(${$([`%x.com/${handle}%`, `%twitter.com/${handle}%`])}::text[]))`
218
+ : 'NULL'},
219
+ 'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
220
+ WHERE name = 'MCP Call' AND props->>'method' = 'tools/call'
221
+ AND NOT coalesce(props->>'ua', '') ILIKE ANY(${$(ownAgents.map(a => `%${a}%`))}::text[])
222
+ GROUP BY 1`, 10)},
223
+ 'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
224
+ ) AS digest`, ...values));
225
+ return row.digest;
40
226
  }
41
227
  /**
42
228
  * Props for an "MCP Call" event, read out of the JSON-RPC envelope.
43
229
  *
44
230
  * The body is untrusted and may be a batch, a notification, or malformed
45
231
  * entirely, so every extraction is defensive and the result is always a
46
- * complete props object. A batch is attributed to its first member — the
232
+ * complete props object. A batch is attributed to its first member – the
47
233
  * alternative is one event per member, which would make a fan-out look like
48
234
  * traffic it is not.
49
235
  */
@@ -55,7 +241,7 @@ export function mcpCallProps(request, body, server) {
55
241
  return { ...(server ? { server } : {}), method, ...(tool ? { tool } : {}), ua };
56
242
  }
57
243
  /**
58
- * Props for a "Search Query" event — the human-side twin of `mcpCallProps`.
244
+ * Props for a "Search Query" event – the human-side twin of `mcpCallProps`.
59
245
  *
60
246
  * A wiki instruments its agent surface and then counts every question an agent
61
247
  * asks while recording none of the questions a person asks. The queries that
@@ -64,7 +250,7 @@ export function mcpCallProps(request, body, server) {
64
250
  *
65
251
  * Returns `null` for a query with no content, so an empty field cannot fire an
66
252
  * event. The text is untrusted, so it is collapsed, lowercased for aggregation
67
- * and truncated — the same 64-character bound search itself applies, which
253
+ * and truncated – the same 64-character bound search itself applies, which
68
254
  * keeps the prop and the query that produced it the same string.
69
255
  */
70
256
  export function searchQueryProps({ query, results, surface, }) {
@@ -73,18 +259,8 @@ export function searchQueryProps({ query, results, surface, }) {
73
259
  return null;
74
260
  return {
75
261
  q,
76
- // A string because Plausible props are strings; `results == "0"` is the
77
- // filter that yields the gap list.
262
+ // A string, so `{ results: '0' }` is the filter that yields the gap list.
78
263
  results: String(Math.max(0, Math.trunc(results) || 0)),
79
264
  ...(surface ? { surface } : {}),
80
265
  };
81
266
  }
82
- /** The hostname of a site URL, for the `domain` a Plausible property is filed under. */
83
- export function plausibleDomain(siteUrl, fallback) {
84
- try {
85
- return new URL(siteUrl || fallback).hostname;
86
- }
87
- catch {
88
- return fallback.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
89
- }
90
- }
package/dist/dom.d.ts CHANGED
@@ -103,3 +103,18 @@ export declare function sortTables(root: ParentNode, options?: SortTablesOptions
103
103
  * A table already in a box keeps it, so a second pass changes nothing.
104
104
  */
105
105
  export declare function scrollTables(html: string): string;
106
+ /**
107
+ * Report each page view to the site's own `endpoint`, whose route hands it to
108
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
109
+ * runs and no blocklist names the request.
110
+ *
111
+ * A client-side router moves between pages with `history.pushState`, which no
112
+ * load event marks, so both history writes are wrapped and a view is sent
113
+ * whenever the path changes; a change to the query or hash alone is not a page.
114
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
115
+ * carrying its `url`. Starts once per page load however often it is called, so
116
+ * React's doubled effects in development send one view, not two.
117
+ */
118
+ export declare function startBeacon(endpoint?: string): void;
119
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
120
+ export declare function track(name: string, props?: Record<string, string | number>): void;
package/dist/dom.js CHANGED
@@ -322,3 +322,60 @@ export function scrollTables(html) {
322
322
  return hadBox ? tag : SCROLL_BOX + tag;
323
323
  });
324
324
  }
325
+ // ---- analytics beacon -------------------------------------------------------
326
+ let beaconEndpoint = '/api/view';
327
+ let beaconStarted = false;
328
+ const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
329
+ /**
330
+ * Report each page view to the site's own `endpoint`, whose route hands it to
331
+ * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
332
+ * runs and no blocklist names the request.
333
+ *
334
+ * A client-side router moves between pages with `history.pushState`, which no
335
+ * load event marks, so both history writes are wrapped and a view is sent
336
+ * whenever the path changes; a change to the query or hash alone is not a page.
337
+ * A click on a link to another host is sent as an "Outbound Link: Click" event
338
+ * carrying its `url`. Starts once per page load however often it is called, so
339
+ * React's doubled effects in development send one view, not two.
340
+ */
341
+ export function startBeacon(endpoint = '/api/view') {
342
+ if (beaconStarted || typeof window === 'undefined')
343
+ return;
344
+ beaconStarted = true;
345
+ beaconEndpoint = endpoint;
346
+ let last = '';
347
+ let first = true;
348
+ const view = () => {
349
+ if (location.pathname === last)
350
+ return;
351
+ last = location.pathname;
352
+ // document.referrer belongs to the page load, so only its first view carries it.
353
+ sendBeacon({
354
+ path: last,
355
+ referrer: first ? document.referrer : undefined,
356
+ utm: new URLSearchParams(location.search).get('utm_source') ?? undefined,
357
+ });
358
+ first = false;
359
+ };
360
+ for (const method of ['pushState', 'replaceState']) {
361
+ const write = history[method];
362
+ history[method] = function (...args) {
363
+ write.apply(this, args);
364
+ view();
365
+ };
366
+ }
367
+ addEventListener('popstate', view);
368
+ document.addEventListener('click', e => {
369
+ const a = e.target?.closest?.('a[href]');
370
+ if (a instanceof HTMLAnchorElement && /^https?:$/.test(a.protocol) && a.host !== location.host) {
371
+ track('Outbound Link: Click', { url: a.href });
372
+ }
373
+ }, { capture: true });
374
+ view();
375
+ }
376
+ /** Record a named event from the browser, on the endpoint `startBeacon` was given. */
377
+ export function track(name, props) {
378
+ if (typeof window === 'undefined')
379
+ return;
380
+ sendBeacon({ name, path: location.pathname, props });
381
+ }
package/dist/react.d.ts CHANGED
@@ -444,3 +444,10 @@ export type { WikiLinkComponent, WikiLinkProps } from './react-server.js';
444
444
  export declare function useArticlePasses(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
445
445
  /** Tweet placeholders hydrated, and their iframes kept sized to the posted height. */
446
446
  export declare function useTweetEmbeds(ref: RefObject<HTMLElement | null>, deps: DependencyList): void;
447
+ /**
448
+ * Counts the page views of the app it is mounted in: place it once, in the root
449
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
450
+ */
451
+ export declare function Beacon({ endpoint }: {
452
+ endpoint?: string;
453
+ }): null;
package/dist/react.js CHANGED
@@ -27,7 +27,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
27
27
  // state on <html> before first paint. The hook keeps that attribute in
28
28
  // sync afterwards, so CSS has one source of truth either side of hydration.
29
29
  import { railBreakpointMismatch, readRailFloating, resolveSidebarOpen, SIDEBAR_ATTRIBUTE, } from './sidebar.js';
30
- import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables } from './dom.js';
30
+ import { activateTabGroups, addCopyButtons, hydrateTweetEmbeds, onTweetResize, sizeTweetEmbeds, sortTables, startBeacon } from './dom.js';
31
31
  import { comboboxAria } from './combobox.js';
32
32
  import { Component, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
33
33
  const readStored = (key) => {
@@ -820,3 +820,11 @@ export function useTweetEmbeds(ref, deps) {
820
820
  // eslint-disable-next-line react-hooks/exhaustive-deps
821
821
  }, deps);
822
822
  }
823
+ /**
824
+ * Counts the page views of the app it is mounted in: place it once, in the root
825
+ * layout. `endpoint` is the route that hands the beacon to `collect`.
826
+ */
827
+ export function Beacon({ endpoint = '/api/view' }) {
828
+ useEffect(() => startBeacon(endpoint), [endpoint]);
829
+ return null;
830
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, and conditional-GET plumbing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -182,7 +182,8 @@
182
182
  },
183
183
  "bin": {
184
184
  "check-classes": "bin/check-classes.mjs",
185
- "check-pages": "bin/check-pages.mjs"
185
+ "check-pages": "bin/check-pages.mjs",
186
+ "analytics-digest": "bin/analytics-digest.mjs"
186
187
  },
187
188
  "files": [
188
189
  "dist",
@@ -239,7 +240,8 @@
239
240
  "fast-diff": ">=1.3",
240
241
  "jose": ">=5",
241
242
  "react": ">=18",
242
- "sanitize-html": ">=2"
243
+ "sanitize-html": ">=2",
244
+ "pg": ">=8"
243
245
  },
244
246
  "peerDependenciesMeta": {
245
247
  "react": {
@@ -295,6 +297,9 @@
295
297
  },
296
298
  "fast-diff": {
297
299
  "optional": true
300
+ },
301
+ "pg": {
302
+ "optional": true
298
303
  }
299
304
  }
300
305
  }