@wtfalch/reporting 0.1.2 → 0.2.1

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
@@ -134,6 +134,62 @@ registerShutdownFlush(reporting); // one bounded flush on SIGTERM
134
134
  Best effort. The queue is per process and bounded at 1,000; the logger has
135
135
  every row regardless.
136
136
 
137
+ ## Analytics (0.2.0)
138
+
139
+ Three tables and two writers. `reporting_analytics` holds raw rows for a
140
+ window (`analytics.retention_days`, default 90); `reporting_analytics_daily`
141
+ and `reporting_analytics_weekly` hold counts with no identifier in any column
142
+ and are kept indefinitely. The rollups store distinct visitor and people
143
+ counts at every grain a reader asks for (site, tenant, name, path, device,
144
+ country, referrer) rather than summing finer groups, keep a contiguous
145
+ completed-day watermark in `reporting_tasks`, and pruning never passes what
146
+ they still need.
147
+
148
+ ```ts
149
+ // The collector, a public route: POST and OPTIONS, always 204.
150
+ import { collectHandler } from '@wtfalch/reporting/next';
151
+ const handle = collectHandler({
152
+ reporting, db,
153
+ sites: JSON.parse(process.env.REPORTING_SITES ?? '{}'),
154
+ getUserId: async () => (await getSession())?.user.id ?? null,
155
+ tenantFor: async (userId, pathname) => tenantIfMember(userId, pathname),
156
+ normalisePath: routePattern,
157
+ });
158
+ export const POST = handle;
159
+ export const OPTIONS = handle;
160
+
161
+ // The beacon, in the root layout.
162
+ import { ReportingProvider, ConsentControl } from '@wtfalch/reporting/react';
163
+ <ReportingProvider collector="/api/reporting/collect" site="app" consent="consented">
164
+ {children}
165
+ <ConsentControl>We count visits. Allow a cookie so we can tell returning visitors apart?</ConsentControl>
166
+ </ReportingProvider>
167
+
168
+ // Server-side truth, from a Server Action.
169
+ await reporting.analytics.track({ name: 'invoice.approved', userId: access.actor.id, tenantId, path });
170
+
171
+ // Housekeeping.
172
+ housekeeping.register(rollupAnalytics);
173
+ housekeeping.register(rollupAnalyticsWeekly);
174
+ housekeeping.register(pruneAnalytics);
175
+ ```
176
+
177
+ What the beacon never sends: a user id. Identity is joined server-side from
178
+ the session, for same-origin beacons only; a beacon from an allowed foreign
179
+ origin (`sites`) is recorded with no user and no tenant, whatever cookies it
180
+ carries. Paths are normalised to route patterns before storage
181
+ (`normalisePath`: uuids, numbers, tokens and addresses become placeholders),
182
+ the query string never arrives, the user agent becomes a device class and is
183
+ discarded, and `cf-ipcountry` becomes two letters or nothing.
184
+
185
+ Consent: `none` stores nothing about the browser; `consented` (the default)
186
+ sets the first-party cookie `_rp` only after `accept()`; `always` sets it
187
+ without asking. `analytics.identify_signed_in` is the one line that turns
188
+ server-side identification off.
189
+
190
+ Erasure: `reporting_erase_person(subject)` deletes the subject's raw rows;
191
+ the rollups hold nothing to erase. Call it in the host's erasure transaction.
192
+
137
193
  ## Tests
138
194
 
139
195
  ```ts
@@ -0,0 +1,40 @@
1
+ import type { Db, Logger } from '../types.js';
2
+ /** Which origins may post beacons for which site (D17). The collector's own site is implied by same-origin. */
3
+ export type Sites = Readonly<Record<string, {
4
+ readonly origins: readonly string[];
5
+ }>>;
6
+ export interface CollectorOptions {
7
+ readonly db: Db;
8
+ readonly log: Logger;
9
+ /** The collector's own site: same-origin beacons claiming it are this app's. */
10
+ readonly site: string;
11
+ /** Other sites and the origins allowed to post for them. A same-origin beacon for another site is dropped. */
12
+ readonly sites?: Sites;
13
+ /**
14
+ * The signed-in person, from the host's session, for same-origin beacons
15
+ * only (addendum A5): a beacon from an allowed foreign origin never reads
16
+ * the session, whatever cookies arrive. Return null for nobody.
17
+ */
18
+ readonly getUserId?: (request: Request) => Promise<string | null>;
19
+ /**
20
+ * The organisation a row belongs to, from the resolved person and the
21
+ * pathname as the browser sent it (the query string already gone); attach
22
+ * one only when that person is a member of it. Anonymous or refused claims
23
+ * store null. The normalised route is the third argument, for a host that
24
+ * decides by route rather than by id.
25
+ */
26
+ readonly tenantFor?: (userId: string, pathname: string, route: string) => Promise<string | null>;
27
+ /** The host's route normalisation; the package's default handles what it does not. */
28
+ readonly normalisePath?: (pathname: string) => string;
29
+ readonly identifySignedIn?: () => Promise<boolean>;
30
+ readonly now?: () => Date;
31
+ /** Events per minute per visitor and per client ip. Default 600. */
32
+ readonly ratePerMinute?: number;
33
+ }
34
+ /**
35
+ * The collector: a public route handler over the Web `Request`. Always 204,
36
+ * so a bad beacon is never a probe; every refusal is a drop and a log line.
37
+ */
38
+ export declare function createCollector(options: CollectorOptions): {
39
+ handle(request: Request): Promise<Response>;
40
+ };
@@ -0,0 +1,170 @@
1
+ import { ANALYTICS_LIMITS, beaconBatchSchema, clampOccurredAt, countryOf, deviceOf, normalisePath, referrerHostOf, } from './schema.js';
2
+ import { insertAnalytics } from './write.js';
3
+ /** A per-process token bucket, enough to bound abuse, never to meter. */
4
+ class Buckets {
5
+ perMinute;
6
+ now;
7
+ buckets = new Map();
8
+ constructor(perMinute, now) {
9
+ this.perMinute = perMinute;
10
+ this.now = now;
11
+ }
12
+ take(key, n) {
13
+ const t = this.now().getTime();
14
+ const b = this.buckets.get(key) ?? { tokens: this.perMinute, at: t };
15
+ b.tokens = Math.min(this.perMinute, b.tokens + ((t - b.at) / 60_000) * this.perMinute);
16
+ b.at = t;
17
+ if (b.tokens < n) {
18
+ this.buckets.set(key, b);
19
+ return false;
20
+ }
21
+ b.tokens -= n;
22
+ this.buckets.set(key, b);
23
+ if (this.buckets.size > 10_000)
24
+ this.buckets.clear();
25
+ return true;
26
+ }
27
+ }
28
+ const NO_CONTENT = () => new Response(null, { status: 204 });
29
+ /**
30
+ * The collector: a public route handler over the Web `Request`. Always 204,
31
+ * so a bad beacon is never a probe; every refusal is a drop and a log line.
32
+ */
33
+ export function createCollector(options) {
34
+ const now = options.now ?? (() => new Date());
35
+ const buckets = new Buckets(options.ratePerMinute ?? 600, now);
36
+ const normalise = options.normalisePath ?? normalisePath;
37
+ function originFor(request) {
38
+ const origin = request.headers.get('origin');
39
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
40
+ if (!origin)
41
+ return host ? { kind: 'same', origin: `https://${host}` } : null;
42
+ let originHost;
43
+ try {
44
+ originHost = new URL(origin).host;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ return { kind: originHost === host ? 'same' : 'foreign', origin };
50
+ }
51
+ function corsFor(response, origin) {
52
+ response.headers.set('access-control-allow-origin', origin);
53
+ response.headers.set('vary', 'origin');
54
+ return response;
55
+ }
56
+ async function handle(request) {
57
+ const where = originFor(request);
58
+ if (request.method === 'OPTIONS') {
59
+ // Preflight for an allowed foreign origin; anything else gets nothing.
60
+ const allowed = where?.kind === 'foreign' &&
61
+ Object.values(options.sites ?? {}).some((s) => s.origins.includes(where.origin));
62
+ if (!allowed)
63
+ return NO_CONTENT();
64
+ const r = corsFor(NO_CONTENT(), where.origin);
65
+ r.headers.set('access-control-allow-methods', 'POST');
66
+ r.headers.set('access-control-allow-headers', 'content-type');
67
+ r.headers.set('access-control-max-age', '600');
68
+ return r;
69
+ }
70
+ if (request.method !== 'POST' || !where)
71
+ return NO_CONTENT();
72
+ const length = Number(request.headers.get('content-length') ?? '0');
73
+ if (length > ANALYTICS_LIMITS.bodyBytes)
74
+ return NO_CONTENT();
75
+ let text;
76
+ try {
77
+ text = await request.text();
78
+ }
79
+ catch {
80
+ return NO_CONTENT();
81
+ }
82
+ if (text.length > ANALYTICS_LIMITS.bodyBytes)
83
+ return NO_CONTENT();
84
+ let json;
85
+ try {
86
+ json = JSON.parse(text);
87
+ }
88
+ catch {
89
+ return NO_CONTENT();
90
+ }
91
+ const parsed = beaconBatchSchema.safeParse(json);
92
+ if (!parsed.success) {
93
+ options.log.info({ issues: parsed.error.issues.length }, 'reporting: beacon refused');
94
+ return NO_CONTENT();
95
+ }
96
+ const batch = parsed.data;
97
+ // Which site, and may this origin speak for it.
98
+ let foreign = false;
99
+ if (where.kind === 'same') {
100
+ if (batch.site !== options.site)
101
+ return NO_CONTENT();
102
+ }
103
+ else {
104
+ const allowed = options.sites?.[batch.site]?.origins.includes(where.origin) ?? false;
105
+ if (!allowed || batch.site === options.site)
106
+ return NO_CONTENT();
107
+ foreign = true;
108
+ }
109
+ const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for');
110
+ const n = batch.events.length;
111
+ if (!buckets.take(`ip:${ip ?? 'none'}`, n))
112
+ return NO_CONTENT();
113
+ if (batch.visitorId && !buckets.take(`v:${batch.visitorId}`, n))
114
+ return NO_CONTENT();
115
+ // Identity is the session's, same-origin only, and never the body's.
116
+ let userId = null;
117
+ if (!foreign && options.getUserId && ((await options.identifySignedIn?.()) ?? true)) {
118
+ try {
119
+ userId = await options.getUserId(request);
120
+ }
121
+ catch (error) {
122
+ options.log.warn({ err: error instanceof Error ? error.message : String(error) }, 'reporting: getUserId failed; recording anonymously');
123
+ }
124
+ }
125
+ const receivedAt = now();
126
+ const device = deviceOf(request.headers.get('user-agent'));
127
+ const country = countryOf(request.headers.get('cf-ipcountry'));
128
+ const rows = [];
129
+ for (const event of batch.events) {
130
+ const pathname = event.path.split(/[?#]/)[0] ?? '/';
131
+ const path = normalise(pathname);
132
+ let tenantId = null;
133
+ if (userId && options.tenantFor) {
134
+ try {
135
+ tenantId = await options.tenantFor(userId, pathname, path);
136
+ }
137
+ catch {
138
+ tenantId = null;
139
+ }
140
+ }
141
+ const props = { ...event.props };
142
+ if (event.duration !== undefined)
143
+ props.duration = event.duration;
144
+ rows.push({
145
+ occurredAt: clampOccurredAt(event.at, receivedAt),
146
+ receivedAt,
147
+ site: batch.site,
148
+ tenantId,
149
+ visitorId: batch.visitorId ?? null,
150
+ sessionId: batch.sessionId ?? null,
151
+ userId,
152
+ name: event.name,
153
+ path,
154
+ referrerHost: referrerHostOf(event.referrer),
155
+ device,
156
+ country,
157
+ props,
158
+ });
159
+ }
160
+ try {
161
+ await insertAnalytics(options.db, rows);
162
+ }
163
+ catch (error) {
164
+ options.log.error({ err: error instanceof Error ? error.message : String(error), rows: rows.length }, 'reporting: analytics insert failed');
165
+ }
166
+ const response = NO_CONTENT();
167
+ return foreign ? corsFor(response, where.origin) : response;
168
+ }
169
+ return { handle };
170
+ }
@@ -0,0 +1,63 @@
1
+ import type { Db } from '../types.js';
2
+ export type Grain = 'site' | 'tenant' | 'name' | 'path' | 'device' | 'country' | 'referrer';
3
+ export interface SeriesOptions {
4
+ readonly site: string;
5
+ /** A customer organisation's view: its own rows only, and small groups hidden. */
6
+ readonly tenantId?: string;
7
+ readonly from: string;
8
+ readonly to: string;
9
+ /** Which dimension to break down by. `site` (the default) is the total. */
10
+ readonly grain?: Exclude<Grain, 'tenant'>;
11
+ /** Below this many distinct visitors a bucket is shown as `(few)` when `tenantId` is set. Default 5. */
12
+ readonly floor?: number;
13
+ }
14
+ export interface SeriesPoint {
15
+ readonly day: string;
16
+ readonly key: string;
17
+ readonly views: number;
18
+ readonly visitors: number;
19
+ readonly people: number;
20
+ }
21
+ /**
22
+ * Daily counts from the rollup (addendum A4: every level is stored, none is
23
+ * summed from another). Only rows without a tenant carry dimensions, so a
24
+ * customer organisation's view is the site total for its tenant and nothing
25
+ * finer; the operator's view has every grain. Small groups (addendum A7)
26
+ * render as `(few)` for a tenant, so a bucket cannot single a person out.
27
+ */
28
+ export declare function analyticsSeries(db: Db, opts: SeriesOptions): Promise<readonly SeriesPoint[]>;
29
+ export interface WeeklyPoint {
30
+ readonly week: string;
31
+ readonly visitors: number;
32
+ readonly returningVisitors: number;
33
+ readonly people: number;
34
+ readonly returningPeople: number;
35
+ }
36
+ export declare function analyticsWeekly(db: Db, opts: {
37
+ site: string;
38
+ tenantId?: string;
39
+ from: string;
40
+ to: string;
41
+ }): Promise<readonly WeeklyPoint[]>;
42
+ export interface RecentOptions {
43
+ readonly site: string;
44
+ readonly tenantId?: string;
45
+ readonly name?: string;
46
+ readonly limit?: number;
47
+ }
48
+ export interface RecentEvent {
49
+ readonly occurredAt: Date;
50
+ readonly name: string;
51
+ readonly path: string;
52
+ readonly device: string;
53
+ readonly country: string | null;
54
+ readonly referrerHost: string | null;
55
+ readonly signedIn: boolean;
56
+ }
57
+ /** The latest raw rows as the operator sees them: what happened, never who. Ids are projected out. */
58
+ export declare function analyticsRecent(db: Db, opts: RecentOptions): Promise<readonly RecentEvent[]>;
59
+ /** `(few)` for a customer's bucket under the floor; the operator sees the number. */
60
+ export declare function hideSmallGroups<T extends {
61
+ visitors: number;
62
+ key: string;
63
+ }>(points: readonly T[], floor?: number): readonly T[];
@@ -0,0 +1,89 @@
1
+ import { and, asc, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
2
+ import { reportingAnalytics, reportingAnalyticsDaily, reportingAnalyticsWeekly, } from '../tables.js';
3
+ /**
4
+ * Daily counts from the rollup (addendum A4: every level is stored, none is
5
+ * summed from another). Only rows without a tenant carry dimensions, so a
6
+ * customer organisation's view is the site total for its tenant and nothing
7
+ * finer; the operator's view has every grain. Small groups (addendum A7)
8
+ * render as `(few)` for a tenant, so a bucket cannot single a person out.
9
+ */
10
+ export async function analyticsSeries(db, opts) {
11
+ const t = reportingAnalyticsDaily;
12
+ if (opts.tenantId) {
13
+ const rows = await db
14
+ .select({ day: t.day, views: t.views, visitors: t.visitors, people: t.people })
15
+ .from(t)
16
+ .where(and(eq(t.site, opts.site), eq(t.grain, 'tenant'), eq(t.tenantId, opts.tenantId), gte(t.day, opts.from), lte(t.day, opts.to)))
17
+ .orderBy(asc(t.day));
18
+ return rows.map((r) => ({
19
+ day: String(r.day),
20
+ key: 'total',
21
+ views: r.views,
22
+ visitors: r.visitors,
23
+ people: r.people,
24
+ }));
25
+ }
26
+ const grain = opts.grain ?? 'site';
27
+ const keyColumn = grain === 'name'
28
+ ? t.name
29
+ : grain === 'path'
30
+ ? t.path
31
+ : grain === 'device'
32
+ ? t.device
33
+ : grain === 'country'
34
+ ? t.country
35
+ : grain === 'referrer'
36
+ ? t.referrerHost
37
+ : sql `'total'`;
38
+ const rows = await db
39
+ .select({ day: t.day, key: keyColumn, views: t.views, visitors: t.visitors, people: t.people })
40
+ .from(t)
41
+ .where(and(eq(t.site, opts.site), eq(t.grain, grain), isNull(t.tenantId), gte(t.day, opts.from), lte(t.day, opts.to)))
42
+ .orderBy(asc(t.day), desc(t.views));
43
+ return rows.map((r) => ({
44
+ day: String(r.day),
45
+ key: String(r.key ?? 'total'),
46
+ views: r.views,
47
+ visitors: r.visitors,
48
+ people: r.people,
49
+ }));
50
+ }
51
+ export async function analyticsWeekly(db, opts) {
52
+ const t = reportingAnalyticsWeekly;
53
+ const rows = await db
54
+ .select()
55
+ .from(t)
56
+ .where(and(eq(t.site, opts.site), opts.tenantId ? eq(t.tenantId, opts.tenantId) : isNull(t.tenantId), gte(t.week, opts.from), lte(t.week, opts.to)))
57
+ .orderBy(asc(t.week));
58
+ return rows.map((r) => ({
59
+ week: String(r.week),
60
+ visitors: r.visitors,
61
+ returningVisitors: r.returningVisitors,
62
+ people: r.people,
63
+ returningPeople: r.returningPeople,
64
+ }));
65
+ }
66
+ /** The latest raw rows as the operator sees them: what happened, never who. Ids are projected out. */
67
+ export async function analyticsRecent(db, opts) {
68
+ const t = reportingAnalytics;
69
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
70
+ const rows = await db
71
+ .select({
72
+ occurredAt: t.occurredAt,
73
+ name: t.name,
74
+ path: t.path,
75
+ device: t.device,
76
+ country: t.country,
77
+ referrerHost: t.referrerHost,
78
+ signedIn: sql `${t.userId} is not null`,
79
+ })
80
+ .from(t)
81
+ .where(and(eq(t.site, opts.site), opts.tenantId ? eq(t.tenantId, opts.tenantId) : undefined, opts.name ? eq(t.name, opts.name) : undefined))
82
+ .orderBy(desc(t.occurredAt), desc(t.id))
83
+ .limit(limit);
84
+ return rows.map((r) => ({ ...r, signedIn: Boolean(r.signedIn) }));
85
+ }
86
+ /** `(few)` for a customer's bucket under the floor; the operator sees the number. */
87
+ export function hideSmallGroups(points, floor = 5) {
88
+ return points.map((p) => (p.visitors > 0 && p.visitors < floor ? { ...p, key: '(few)' } : p));
89
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+ export declare const ANALYTICS_NAME_PATTERN: RegExp;
3
+ export declare const DEVICES: readonly ["desktop", "mobile", "tablet", "bot", "unknown"];
4
+ export type Device = (typeof DEVICES)[number];
5
+ export declare const ANALYTICS_LIMITS: {
6
+ readonly path: 512;
7
+ readonly referrerHost: 253;
8
+ readonly propsBytes: 4096;
9
+ readonly bodyBytes: 16384;
10
+ readonly batch: 20;
11
+ /** The beacon's clock may disagree with the server's by this much, either way. */
12
+ readonly clockSkewMs: number;
13
+ };
14
+ /** The package's own event names; everything else is the host's. */
15
+ export declare const PAGE_VIEW = "page.view";
16
+ export declare const PAGE_LEAVE = "page.leave";
17
+ /** Flat, bounded, and never a key that names a person or a secret; the same rule the event log's `data` has, at a quarter of the size. */
18
+ export declare const propsSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
19
+ /** One event as the beacon sends it. Nothing here names a person: the collector joins identity from the session, never from the body. */
20
+ export declare const beaconEventSchema: z.ZodObject<{
21
+ name: z.ZodString;
22
+ at: z.ZodNumber;
23
+ path: z.ZodString;
24
+ referrer: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
26
+ duration: z.ZodOptional<z.ZodNumber>;
27
+ }, z.core.$strict>;
28
+ export declare const beaconBatchSchema: z.ZodObject<{
29
+ site: z.ZodString;
30
+ visitorId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
31
+ sessionId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
+ events: z.ZodArray<z.ZodObject<{
33
+ name: z.ZodString;
34
+ at: z.ZodNumber;
35
+ path: z.ZodString;
36
+ referrer: z.ZodOptional<z.ZodNullable<z.ZodString>>;
37
+ props: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>>;
38
+ duration: z.ZodOptional<z.ZodNumber>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>;
41
+ export type BeaconBatch = z.infer<typeof beaconBatchSchema>;
42
+ export type BeaconEvent = z.infer<typeof beaconEventSchema>;
43
+ /**
44
+ * The default route normalisation (addendum A7): a pathname with anything
45
+ * that looks like an identifier replaced by a placeholder, so no uuid,
46
+ * numeric id, token or address ever reaches the raw table or a rollup. A
47
+ * host with a route manifest supplies its own and this is the fallback for
48
+ * paths it does not know. The query string and fragment never arrive here;
49
+ * the beacon sends the pathname and the collector strips again anyway.
50
+ */
51
+ export declare function normalisePath(pathname: string): string;
52
+ /** The host only: `https://www.example.com/a?b` becomes `www.example.com`; junk becomes null. */
53
+ export declare function referrerHostOf(referrer: string | null | undefined): string | null;
54
+ /** A device class from the user agent, which is then discarded. Coarse on purpose. */
55
+ export declare function deviceOf(userAgent: string | null | undefined): Device;
56
+ /** Two upper-case letters or nothing; `cf-ipcountry` says `XX` and `T1` for unknown and Tor, which are not countries. */
57
+ export declare function countryOf(header: string | null | undefined): string | null;
58
+ /** The beacon's time, held to within the skew of arrival. */
59
+ export declare function clampOccurredAt(at: number, receivedAt: Date): Date;
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ import { BANNED_KEYS, SITE_PATTERN } from '../schema.js';
3
+ export const ANALYTICS_NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/;
4
+ export const DEVICES = ['desktop', 'mobile', 'tablet', 'bot', 'unknown'];
5
+ export const ANALYTICS_LIMITS = {
6
+ path: 512,
7
+ referrerHost: 253,
8
+ propsBytes: 4096,
9
+ bodyBytes: 16384,
10
+ batch: 20,
11
+ /** The beacon's clock may disagree with the server's by this much, either way. */
12
+ clockSkewMs: 10 * 60 * 1000,
13
+ };
14
+ /** The package's own event names; everything else is the host's. */
15
+ export const PAGE_VIEW = 'page.view';
16
+ export const PAGE_LEAVE = 'page.leave';
17
+ const scalar = z.union([z.string().max(1024), z.number(), z.boolean(), z.null()]);
18
+ /** Flat, bounded, and never a key that names a person or a secret; the same rule the event log's `data` has, at a quarter of the size. */
19
+ export const propsSchema = z.record(z.string().max(64), scalar).superRefine((props, ctx) => {
20
+ for (const key of Object.keys(props)) {
21
+ if (BANNED_KEYS.includes(key)) {
22
+ ctx.addIssue({ code: 'custom', path: [key], message: `"${key}" may not be a props key` });
23
+ }
24
+ const value = props[key];
25
+ if (typeof value === 'number' && !Number.isFinite(value)) {
26
+ ctx.addIssue({ code: 'custom', path: [key], message: 'must be a finite number' });
27
+ }
28
+ }
29
+ if (JSON.stringify(props).length > ANALYTICS_LIMITS.propsBytes) {
30
+ ctx.addIssue({
31
+ code: 'custom',
32
+ message: `must serialise to at most ${ANALYTICS_LIMITS.propsBytes} bytes`,
33
+ });
34
+ }
35
+ });
36
+ const opaqueId = z.string().regex(/^[A-Za-z0-9_-]{8,64}$/);
37
+ /** One event as the beacon sends it. Nothing here names a person: the collector joins identity from the session, never from the body. */
38
+ export const beaconEventSchema = z.strictObject({
39
+ name: z.string().regex(ANALYTICS_NAME_PATTERN),
40
+ /** Milliseconds since the epoch, by the browser's clock; clamped on arrival. */
41
+ at: z.number().int().nonnegative(),
42
+ path: z.string().min(1).max(2048),
43
+ referrer: z.string().max(2048).nullish(),
44
+ props: propsSchema.default({}),
45
+ /** Seconds on the page, for `page.leave`. */
46
+ duration: z.number().int().min(0).max(86_400).optional(),
47
+ });
48
+ export const beaconBatchSchema = z.strictObject({
49
+ site: z.string().regex(SITE_PATTERN),
50
+ visitorId: opaqueId.nullish(),
51
+ sessionId: opaqueId.nullish(),
52
+ events: z.array(beaconEventSchema).min(1).max(ANALYTICS_LIMITS.batch),
53
+ });
54
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
55
+ const NUMERIC = /^\d+$/;
56
+ const OPAQUE = /^[A-Za-z0-9_-]{20,}$/;
57
+ const HEXISH = /^[0-9a-f]{16,}$/i;
58
+ /**
59
+ * The default route normalisation (addendum A7): a pathname with anything
60
+ * that looks like an identifier replaced by a placeholder, so no uuid,
61
+ * numeric id, token or address ever reaches the raw table or a rollup. A
62
+ * host with a route manifest supplies its own and this is the fallback for
63
+ * paths it does not know. The query string and fragment never arrive here;
64
+ * the beacon sends the pathname and the collector strips again anyway.
65
+ */
66
+ export function normalisePath(pathname) {
67
+ const raw = pathname.split(/[?#]/)[0] ?? '/';
68
+ const segments = raw.split('/').filter((s) => s.length > 0);
69
+ const out = segments.map((segment) => {
70
+ let s = segment;
71
+ try {
72
+ s = decodeURIComponent(segment);
73
+ }
74
+ catch {
75
+ return ':x';
76
+ }
77
+ if (UUID.test(s))
78
+ return ':id';
79
+ if (NUMERIC.test(s))
80
+ return ':n';
81
+ if (s.includes('@') || s.includes('=') || s.includes(':'))
82
+ return ':x';
83
+ if (HEXISH.test(s) || OPAQUE.test(s) || s.length > 48)
84
+ return ':x';
85
+ return s.toLowerCase();
86
+ });
87
+ const joined = `/${out.join('/')}`;
88
+ return joined.length > ANALYTICS_LIMITS.path ? joined.slice(0, ANALYTICS_LIMITS.path) : joined;
89
+ }
90
+ /** The host only: `https://www.example.com/a?b` becomes `www.example.com`; junk becomes null. */
91
+ export function referrerHostOf(referrer) {
92
+ if (!referrer)
93
+ return null;
94
+ try {
95
+ const host = new URL(referrer).hostname.toLowerCase();
96
+ if (!host || host.length > ANALYTICS_LIMITS.referrerHost)
97
+ return null;
98
+ return host;
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ /** A device class from the user agent, which is then discarded. Coarse on purpose. */
105
+ export function deviceOf(userAgent) {
106
+ if (!userAgent)
107
+ return 'unknown';
108
+ const ua = userAgent;
109
+ if (/bot|crawl|spider|slurp|fetch|headless|lighthouse|preview|monitor|curl|wget|python-requests/i.test(ua))
110
+ return 'bot';
111
+ if (/iPad|Tablet|PlayBook|Silk|Kindle/i.test(ua) || (/Android/i.test(ua) && !/Mobile/i.test(ua)))
112
+ return 'tablet';
113
+ if (/Mobi|iPhone|iPod|Android|BlackBerry|IEMobile|Opera Mini/i.test(ua))
114
+ return 'mobile';
115
+ return 'desktop';
116
+ }
117
+ /** Two upper-case letters or nothing; `cf-ipcountry` says `XX` and `T1` for unknown and Tor, which are not countries. */
118
+ export function countryOf(header) {
119
+ return header && /^[A-Z]{2}$/.test(header) && header !== 'XX' && header !== 'T1' ? header : null;
120
+ }
121
+ /** The beacon's time, held to within the skew of arrival. */
122
+ export function clampOccurredAt(at, receivedAt) {
123
+ const lo = receivedAt.getTime() - ANALYTICS_LIMITS.clockSkewMs;
124
+ const hi = receivedAt.getTime() + ANALYTICS_LIMITS.clockSkewMs;
125
+ return new Date(Math.min(hi, Math.max(lo, at)));
126
+ }
@@ -0,0 +1,32 @@
1
+ import type { Task, TaskContext } from '../housekeeping/index.js';
2
+ import type { Db } from '../types.js';
3
+ /** The weekly rollup's raw lookback: returning means seen in one of the four weeks before. */
4
+ export declare const WEEKLY_LOOKBACK_DAYS = 28;
5
+ /** The Monday of the ISO week holding `d`, at UTC midnight. */
6
+ export declare const mondayOf: (d: Date) => Date;
7
+ /**
8
+ * The daily rollup (addendum A3). Completed days first: from the day after
9
+ * the watermark up to yesterday, each day's aggregate set and the watermark
10
+ * move committed together, fenced by the claim token, in bounded batches
11
+ * with the lease renewed between them. Then the open day (today) is
12
+ * recomputed without moving the watermark, so the dashboard is current and
13
+ * history stays contiguous.
14
+ */
15
+ export declare const rollupAnalytics: Task;
16
+ /**
17
+ * The weekly rollup: completed ISO weeks (Monday to Sunday, wholly before
18
+ * today) from the week after its own watermark, then the open week
19
+ * recomputed. Its watermark is the Monday of the last completed week.
20
+ */
21
+ export declare const rollupAnalyticsWeekly: Task;
22
+ /**
23
+ * Raw rows leave by age, and never past what the rollups still need: the
24
+ * daily watermark, and the weekly watermark less its lookback, whichever is
25
+ * earlier. Until both rollups have completed a day there is nothing safe to
26
+ * prune, and the function says so.
27
+ */
28
+ export declare const pruneAnalytics: Task;
29
+ /** The earliest day the rollups still need, as an ISO date, or null when a rollup has not completed anything yet. */
30
+ export declare function safePruneBoundary(db: Db): Promise<string | null>;
31
+ export declare function pruneAnalyticsBatch(db: Db, days: number, batch: number, safeBefore: string): Promise<number>;
32
+ export type { TaskContext };