@wtfalch/reporting 0.1.1 → 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.
@@ -0,0 +1,199 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { rowsOf } from '../housekeeping/tasks.js';
3
+ const HOUR = 60 * 60 * 1000;
4
+ const DAY = 24 * HOUR;
5
+ /** Completed days rolled up per run, so a long outage catches up in bounded steps under a renewed lease. */
6
+ const CATCH_UP_DAYS = 14;
7
+ /** The weekly rollup's raw lookback: returning means seen in one of the four weeks before. */
8
+ export const WEEKLY_LOOKBACK_DAYS = 28;
9
+ const iso = (d) => d.toISOString().slice(0, 10);
10
+ const utcDay = (d) => new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
11
+ const addDays = (d, n) => new Date(d.getTime() + n * DAY);
12
+ /** The Monday of the ISO week holding `d`, at UTC midnight. */
13
+ export const mondayOf = (d) => {
14
+ const day = utcDay(d);
15
+ const dow = (day.getUTCDay() + 6) % 7;
16
+ return addDays(day, -dow);
17
+ };
18
+ async function rollupDay(db, day) {
19
+ const result = await db.execute(sql `select reporting_rollup_day(${day}::date) as n`);
20
+ return Number(rowsOf(result)[0]?.n ?? 0);
21
+ }
22
+ async function rollupWeek(db, monday) {
23
+ const result = await db.execute(sql `select reporting_rollup_week(${monday}::date) as n`);
24
+ return Number(rowsOf(result)[0]?.n ?? 0);
25
+ }
26
+ /** The first day worth rolling up when there is no watermark yet: the oldest raw row's day, or nothing. */
27
+ async function firstRawDay(db) {
28
+ const result = await db.execute(sql `select min((occurred_at at time zone 'UTC')::date)::text as d from reporting_analytics`);
29
+ const d = rowsOf(result)[0]?.d;
30
+ return typeof d === 'string' && d.length === 10 ? d : null;
31
+ }
32
+ /**
33
+ * The daily rollup (addendum A3). Completed days first: from the day after
34
+ * the watermark up to yesterday, each day's aggregate set and the watermark
35
+ * move committed together, fenced by the claim token, in bounded batches
36
+ * with the lease renewed between them. Then the open day (today) is
37
+ * recomputed without moving the watermark, so the dashboard is current and
38
+ * history stays contiguous.
39
+ */
40
+ export const rollupAnalytics = {
41
+ name: 'reporting.rollup_analytics',
42
+ every: HOUR,
43
+ lease: 10 * 60 * 1000,
44
+ retry: 15 * 60 * 1000,
45
+ async run(ctx) {
46
+ const today = utcDay(ctx.now());
47
+ const yesterday = addDays(today, -1);
48
+ let cursor = ctx.watermark ? addDays(new Date(`${ctx.watermark}T00:00:00Z`), 1) : null;
49
+ if (!cursor) {
50
+ const first = await firstRawDay(ctx.db);
51
+ cursor = first ? new Date(`${first}T00:00:00Z`) : null;
52
+ }
53
+ let completed = 0;
54
+ while (cursor && cursor.getTime() <= yesterday.getTime() && completed < CATCH_UP_DAYS) {
55
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 60_000)
56
+ break;
57
+ const day = iso(cursor);
58
+ const moved = await ctx.db.transaction(async (tx) => {
59
+ await rollupDay(tx, day);
60
+ return ctx.commitWatermark(day, tx);
61
+ });
62
+ if (!moved) {
63
+ // The lease went to another process; its run owns the watermark now.
64
+ ctx.reporting.event({
65
+ kind: 'reporting.rollup_yielded',
66
+ level: 'warn',
67
+ message: `rollup of ${day} yielded: the lease was lost before the watermark moved`,
68
+ data: { day },
69
+ });
70
+ return;
71
+ }
72
+ completed += 1;
73
+ cursor = addDays(cursor, 1);
74
+ if (!(await ctx.renew()))
75
+ return;
76
+ }
77
+ // The open day, and yesterday if the catch-up has not reached it yet:
78
+ // recomputed for the dashboard, never vouched for.
79
+ const openFrom = cursor && cursor.getTime() < today.getTime() ? cursor : today;
80
+ for (let d = openFrom; d.getTime() <= today.getTime(); d = addDays(d, 1)) {
81
+ await rollupDay(ctx.db, iso(d));
82
+ }
83
+ if (completed > 0) {
84
+ ctx.reporting.event({
85
+ kind: 'reporting.rolled_up',
86
+ message: `rolled up ${completed} completed day(s), watermark ${iso(addDays(cursor ?? today, -1))}`,
87
+ data: { days: completed, watermark: iso(addDays(cursor ?? today, -1)) },
88
+ });
89
+ }
90
+ },
91
+ };
92
+ /**
93
+ * The weekly rollup: completed ISO weeks (Monday to Sunday, wholly before
94
+ * today) from the week after its own watermark, then the open week
95
+ * recomputed. Its watermark is the Monday of the last completed week.
96
+ */
97
+ export const rollupAnalyticsWeekly = {
98
+ name: 'reporting.rollup_analytics_weekly',
99
+ every: DAY,
100
+ lease: 10 * 60 * 1000,
101
+ retry: HOUR,
102
+ async run(ctx) {
103
+ const today = utcDay(ctx.now());
104
+ const thisMonday = mondayOf(today);
105
+ let cursor = ctx.watermark
106
+ ? addDays(new Date(`${ctx.watermark}T00:00:00Z`), 7)
107
+ : null;
108
+ if (!cursor) {
109
+ const first = await firstRawDay(ctx.db);
110
+ cursor = first ? mondayOf(new Date(`${first}T00:00:00Z`)) : null;
111
+ }
112
+ let completed = 0;
113
+ while (cursor && cursor.getTime() < thisMonday.getTime() && completed < 26) {
114
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 60_000)
115
+ break;
116
+ const monday = iso(cursor);
117
+ const moved = await ctx.db.transaction(async (tx) => {
118
+ await rollupWeek(tx, monday);
119
+ return ctx.commitWatermark(monday, tx);
120
+ });
121
+ if (!moved)
122
+ return;
123
+ completed += 1;
124
+ cursor = addDays(cursor, 7);
125
+ if (!(await ctx.renew()))
126
+ return;
127
+ }
128
+ await rollupWeek(ctx.db, iso(thisMonday));
129
+ },
130
+ };
131
+ /**
132
+ * Raw rows leave by age, and never past what the rollups still need: the
133
+ * daily watermark, and the weekly watermark less its lookback, whichever is
134
+ * earlier. Until both rollups have completed a day there is nothing safe to
135
+ * prune, and the function says so.
136
+ */
137
+ export const pruneAnalytics = {
138
+ name: 'reporting.prune_analytics',
139
+ every: HOUR,
140
+ lease: 10 * 60 * 1000,
141
+ retry: 15 * 60 * 1000,
142
+ async run(ctx) {
143
+ const days = ctx.settings['analytics.retention_days'];
144
+ const safeBefore = await safePruneBoundary(ctx.db);
145
+ if (!safeBefore)
146
+ return;
147
+ // The window's cutoff must fall before what the rollups still need. A
148
+ // window shorter than that (an operator's edit against a stalled
149
+ // rollup, or one shorter than the weekly lookback) defers pruning with
150
+ // a warning rather than failing every hour; the function refuses too.
151
+ const cutoffDay = iso(addDays(utcDay(ctx.now()), -days));
152
+ if (cutoffDay >= safeBefore) {
153
+ ctx.reporting.event({
154
+ kind: 'reporting.prune_deferred',
155
+ level: 'warn',
156
+ message: `analytics pruning deferred: a ${days}-day window would cut at ${cutoffDay}, but the rollups still need raw rows from ${safeBefore}`,
157
+ data: { days, cutoffDay, safeBefore },
158
+ });
159
+ return;
160
+ }
161
+ let total = 0;
162
+ for (let i = 0; i < 20; i += 1) {
163
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 30_000)
164
+ break;
165
+ const n = await pruneAnalyticsBatch(ctx.db, days, 5000, safeBefore);
166
+ total += n;
167
+ if (n < 5000)
168
+ break;
169
+ if (!(await ctx.renew()))
170
+ break;
171
+ }
172
+ if (total > 0) {
173
+ ctx.reporting.event({
174
+ kind: 'reporting.pruned_analytics',
175
+ message: `pruned ${total} analytics row(s) older than ${days} days`,
176
+ data: { rows: total, days, safeBefore },
177
+ });
178
+ }
179
+ },
180
+ };
181
+ /** The earliest day the rollups still need, as an ISO date, or null when a rollup has not completed anything yet. */
182
+ export async function safePruneBoundary(db) {
183
+ const result = await db.execute(sql `select task, watermark from reporting_tasks where task in ('reporting.rollup_analytics', 'reporting.rollup_analytics_weekly')`);
184
+ const marks = new Map(rowsOf(result).map((r) => [String(r.task), r.watermark]));
185
+ const daily = marks.get('reporting.rollup_analytics');
186
+ const weekly = marks.get('reporting.rollup_analytics_weekly');
187
+ if (typeof daily !== 'string' || typeof weekly !== 'string')
188
+ return null;
189
+ const weeklyNeeds = addDays(new Date(`${weekly}T00:00:00Z`), -WEEKLY_LOOKBACK_DAYS);
190
+ const dailyDate = new Date(`${daily}T00:00:00Z`);
191
+ return iso(weeklyNeeds.getTime() < dailyDate.getTime() ? weeklyNeeds : dailyDate);
192
+ }
193
+ export async function pruneAnalyticsBatch(db, days, batch, safeBefore) {
194
+ return db.transaction(async (tx) => {
195
+ await tx.execute(sql `set local statement_timeout = '30s'`);
196
+ const result = await tx.execute(sql `select reporting_prune_analytics(make_interval(days => ${days}::int), ${batch}::int, ${safeBefore}::date) as n`);
197
+ return Number(rowsOf(result)[0]?.n ?? 0);
198
+ });
199
+ }
@@ -0,0 +1,19 @@
1
+ import type { Db } from '../types.js';
2
+ import type { Device } from './schema.js';
3
+ /** One raw row as the collector or `track` writes it; every field already validated and normalised. */
4
+ export interface AnalyticsRow {
5
+ readonly occurredAt: Date;
6
+ readonly receivedAt: Date;
7
+ readonly site: string;
8
+ readonly tenantId: string | null;
9
+ readonly visitorId: string | null;
10
+ readonly sessionId: string | null;
11
+ readonly userId: string | null;
12
+ readonly name: string;
13
+ readonly path: string;
14
+ readonly referrerHost: string | null;
15
+ readonly device: Device;
16
+ readonly country: string | null;
17
+ readonly props: Record<string, string | number | boolean | null>;
18
+ }
19
+ export declare function insertAnalytics(db: Db, rows: readonly AnalyticsRow[]): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { reportingAnalytics } from '../tables.js';
2
+ export async function insertAnalytics(db, rows) {
3
+ if (rows.length === 0)
4
+ return;
5
+ await db.insert(reportingAnalytics).values(rows.map((r) => ({
6
+ occurredAt: r.occurredAt,
7
+ receivedAt: r.receivedAt,
8
+ site: r.site,
9
+ tenantId: r.tenantId,
10
+ visitorId: r.visitorId,
11
+ sessionId: r.sessionId,
12
+ userId: r.userId,
13
+ name: r.name,
14
+ path: r.path,
15
+ referrerHost: r.referrerHost,
16
+ device: r.device,
17
+ country: r.country,
18
+ props: r.props,
19
+ })));
20
+ }
@@ -12,11 +12,5 @@ import { copyMigrations, describeCopy } from './copy.js';
12
12
  const here = dirname(fileURLToPath(import.meta.url));
13
13
  const from = join(here, '..', 'migrations');
14
14
  const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
15
- let version;
16
- try {
17
- version = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8')).version;
18
- }
19
- catch {
20
- version = undefined;
21
- }
15
+ const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
22
16
  console.log(describeCopy(copyMigrations({ from, to, version })));
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The beacon: `page.view` on load and on history changes, `page.leave` with
3
+ * the seconds on the page, and whatever the host `track`s, batched twenty at
4
+ * a time or every five seconds and posted with `sendBeacon` (falling back to
5
+ * `fetch` with `keepalive`). It sends the pathname, never the query string,
6
+ * and never a user id: the collector knows who is signed in from the session
7
+ * cookie, and a client-supplied id would be a claim (D14).
8
+ *
9
+ * Consent (D15): `none` keeps nothing; `consented` starts as `none` until
10
+ * `accept()`; `always` sets the first-party cookie without asking. The cookie
11
+ * `_rp` holds the visitor id and the answer, one year, SameSite=Lax, Secure;
12
+ * the session id lives in `sessionStorage` under `consented`/`always` and in
13
+ * memory under `none`. Nothing about the browser is stored beyond that.
14
+ *
15
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
+ */
17
+ export type ConsentMode = 'none' | 'consented' | 'always';
18
+ export type ConsentAnswer = 'accepted' | 'declined' | null;
19
+ export interface BeaconOptions {
20
+ /** The collector URL, same-origin (`/api/reporting/collect`) or the hub's. */
21
+ readonly collector: string;
22
+ /** This app's site id. */
23
+ readonly site: string;
24
+ readonly consent?: ConsentMode;
25
+ /** Batch size and flush interval; the defaults are the collector's caps. */
26
+ readonly batch?: number;
27
+ readonly flushMs?: number;
28
+ /** For tests: the window to observe and the transport to post with. */
29
+ readonly window?: Window;
30
+ readonly send?: (url: string, body: string) => void;
31
+ }
32
+ export interface Beacon {
33
+ track(name: string, props?: Record<string, string | number | boolean | null>): void;
34
+ pageView(): void;
35
+ /** The person's answer under `consented`; sets or clears the cookie and remembers it. */
36
+ accept(): void;
37
+ decline(): void;
38
+ answer(): ConsentAnswer;
39
+ visitorId(): string | null;
40
+ flush(): void;
41
+ destroy(): void;
42
+ }
43
+ export declare function createBeacon(options: BeaconOptions): Beacon;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The beacon: `page.view` on load and on history changes, `page.leave` with
3
+ * the seconds on the page, and whatever the host `track`s, batched twenty at
4
+ * a time or every five seconds and posted with `sendBeacon` (falling back to
5
+ * `fetch` with `keepalive`). It sends the pathname, never the query string,
6
+ * and never a user id: the collector knows who is signed in from the session
7
+ * cookie, and a client-supplied id would be a claim (D14).
8
+ *
9
+ * Consent (D15): `none` keeps nothing; `consented` starts as `none` until
10
+ * `accept()`; `always` sets the first-party cookie without asking. The cookie
11
+ * `_rp` holds the visitor id and the answer, one year, SameSite=Lax, Secure;
12
+ * the session id lives in `sessionStorage` under `consented`/`always` and in
13
+ * memory under `none`. Nothing about the browser is stored beyond that.
14
+ *
15
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
+ */
17
+ const COOKIE = '_rp';
18
+ const YEAR = 365 * 24 * 60 * 60;
19
+ const NAME = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/;
20
+ function randomId() {
21
+ const bytes = new Uint8Array(16);
22
+ crypto.getRandomValues(bytes);
23
+ let s = '';
24
+ for (const b of bytes)
25
+ s += b.toString(16).padStart(2, '0');
26
+ return s;
27
+ }
28
+ function readCookie(doc) {
29
+ const m = doc.cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]*)`));
30
+ if (!m?.[1])
31
+ return null;
32
+ const [id, answer] = decodeURIComponent(m[1]).split('.');
33
+ if (!id || !/^[a-f0-9]{32}$/.test(id))
34
+ return null;
35
+ return { id, answer: answer === 'a' ? 'accepted' : answer === 'd' ? 'declined' : null };
36
+ }
37
+ function writeCookie(doc, value) {
38
+ doc.cookie =
39
+ value === null
40
+ ? `${COOKIE}=; Max-Age=0; Path=/; SameSite=Lax; Secure`
41
+ : `${COOKIE}=${encodeURIComponent(value)}; Max-Age=${YEAR}; Path=/; SameSite=Lax; Secure`;
42
+ }
43
+ export function createBeacon(options) {
44
+ const win = options.window ?? window;
45
+ const doc = win.document;
46
+ const mode = options.consent ?? 'consented';
47
+ const batchSize = Math.min(20, options.batch ?? 20);
48
+ const flushMs = options.flushMs ?? 5000;
49
+ const send = options.send ??
50
+ ((url, body) => {
51
+ const blob = new Blob([body], { type: 'application/json' });
52
+ if (!win.navigator.sendBeacon?.(url, blob)) {
53
+ void win.fetch(url, { method: 'POST', body, keepalive: true, credentials: 'include' });
54
+ }
55
+ });
56
+ // Identity: a visitor id only with consent; a session id per tab.
57
+ let cookie = readCookie(doc);
58
+ let answer = cookie?.answer ?? null;
59
+ const declined = () => mode === 'none' || (mode === 'consented' && answer !== 'accepted');
60
+ if (mode === 'always' && !cookie) {
61
+ cookie = { id: randomId(), answer: 'accepted' };
62
+ writeCookie(doc, `${cookie.id}.a`);
63
+ }
64
+ if (declined()) {
65
+ if (cookie && answer !== 'declined')
66
+ cookie = null;
67
+ }
68
+ let sessionId = null;
69
+ function inMemorySession() {
70
+ if (!sessionId)
71
+ sessionId = randomId();
72
+ return sessionId;
73
+ }
74
+ function session() {
75
+ if (declined())
76
+ return inMemorySession();
77
+ try {
78
+ const stored = win.sessionStorage.getItem(`${COOKIE}s`);
79
+ if (stored && /^[a-f0-9]{32}$/.test(stored))
80
+ return stored;
81
+ const fresh = randomId();
82
+ win.sessionStorage.setItem(`${COOKIE}s`, fresh);
83
+ return fresh;
84
+ }
85
+ catch {
86
+ return inMemorySession();
87
+ }
88
+ }
89
+ const queue = [];
90
+ let timer = null;
91
+ function flush() {
92
+ if (timer) {
93
+ clearTimeout(timer);
94
+ timer = null;
95
+ }
96
+ if (queue.length === 0)
97
+ return;
98
+ const events = queue.splice(0, batchSize);
99
+ const body = JSON.stringify({
100
+ site: options.site,
101
+ visitorId: declined() ? null : (cookie?.id ?? null),
102
+ sessionId: session(),
103
+ events,
104
+ });
105
+ try {
106
+ send(options.collector, body);
107
+ }
108
+ catch {
109
+ // A beacon that cannot leave is dropped; nothing here may throw into the page.
110
+ }
111
+ if (queue.length > 0)
112
+ flush();
113
+ }
114
+ function push(event) {
115
+ queue.push(event);
116
+ if (queue.length >= batchSize)
117
+ flush();
118
+ else if (!timer)
119
+ timer = setTimeout(flush, flushMs);
120
+ }
121
+ const pathOf = () => win.location.pathname || '/';
122
+ let currentPath = pathOf();
123
+ let enteredAt = Date.now();
124
+ let lastReferrer = doc.referrer || null;
125
+ function pageView() {
126
+ currentPath = pathOf();
127
+ enteredAt = Date.now();
128
+ push({
129
+ name: 'page.view',
130
+ at: Date.now(),
131
+ path: currentPath,
132
+ referrer: lastReferrer,
133
+ props: {},
134
+ });
135
+ lastReferrer = null;
136
+ }
137
+ function pageLeave() {
138
+ const duration = Math.max(0, Math.round((Date.now() - enteredAt) / 1000));
139
+ push({ name: 'page.leave', at: Date.now(), path: currentPath, props: {}, duration });
140
+ flush();
141
+ }
142
+ function track(name, props = {}) {
143
+ if (!NAME.test(name) || name.startsWith('page.'))
144
+ return;
145
+ push({ name, at: Date.now(), path: pathOf(), props });
146
+ }
147
+ // History: a SPA navigation is a page view too.
148
+ const history = win.history;
149
+ const pushState = history.pushState.bind(history);
150
+ const replaceState = history.replaceState.bind(history);
151
+ const onNavigate = () => {
152
+ if (pathOf() === currentPath)
153
+ return;
154
+ pageLeave();
155
+ pageView();
156
+ };
157
+ history.pushState = (...args) => {
158
+ pushState(...args);
159
+ onNavigate();
160
+ };
161
+ history.replaceState = (...args) => {
162
+ replaceState(...args);
163
+ onNavigate();
164
+ };
165
+ const onPop = () => onNavigate();
166
+ const onHide = () => {
167
+ if (doc.visibilityState === 'hidden')
168
+ pageLeave();
169
+ };
170
+ win.addEventListener('popstate', onPop);
171
+ doc.addEventListener('visibilitychange', onHide);
172
+ win.addEventListener('pagehide', pageLeave);
173
+ pageView();
174
+ return {
175
+ track,
176
+ pageView,
177
+ accept() {
178
+ if (mode !== 'consented')
179
+ return;
180
+ answer = 'accepted';
181
+ cookie = { id: cookie?.id ?? randomId(), answer };
182
+ writeCookie(doc, `${cookie.id}.a`);
183
+ },
184
+ decline() {
185
+ if (mode !== 'consented')
186
+ return;
187
+ answer = 'declined';
188
+ cookie = null;
189
+ writeCookie(doc, `${randomId()}.d`);
190
+ },
191
+ answer: () => answer,
192
+ visitorId: () => (declined() ? null : (cookie?.id ?? null)),
193
+ flush,
194
+ destroy() {
195
+ flush();
196
+ history.pushState = pushState;
197
+ history.replaceState = replaceState;
198
+ win.removeEventListener('popstate', onPop);
199
+ doc.removeEventListener('visibilitychange', onHide);
200
+ win.removeEventListener('pagehide', pageLeave);
201
+ },
202
+ };
203
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ReportingEventRow } from '../tables.js';
2
- import type { AlertFinding, Logger, Reporting } from '../types.js';
2
+ import type { AlertFinding, Logger, Reporting, TrackInput } from '../types.js';
3
3
  /**
4
4
  * An in-memory `Reporting` for a host's unit tests: same validation, same
5
5
  * shape, no database. `rows` is the table; `alerts` is what `onAlert` would
@@ -8,6 +8,10 @@ import type { AlertFinding, Logger, Reporting } from '../types.js';
8
8
  export interface FakeReporting extends Reporting {
9
9
  readonly rows: ReportingEventRow[];
10
10
  readonly alerts: AlertFinding[];
11
+ /** What `analytics.track` received. */
12
+ readonly tracked: (TrackInput & {
13
+ site: string;
14
+ })[];
11
15
  reset(): void;
12
16
  }
13
17
  export declare function createFakeReporting(opts?: {
@@ -8,6 +8,7 @@ export function createFakeReporting(opts = {}) {
8
8
  const log = opts.log ?? silent;
9
9
  const rows = [];
10
10
  const alerts = [];
11
+ const tracked = [];
11
12
  let settings = { ...DEFAULT_SETTINGS };
12
13
  let id = 0;
13
14
  const fake = {
@@ -16,11 +17,7 @@ export function createFakeReporting(opts = {}) {
16
17
  tables,
17
18
  rows,
18
19
  alerts,
19
- // Not a database; a host's test that reaches `db` through the fake is
20
- // testing the wrong thing, and this makes that loud.
21
- get db() {
22
- throw new Error('createFakeReporting has no database');
23
- },
20
+ tracked,
24
21
  event(input) {
25
22
  const v = eventInputSchema.parse(input);
26
23
  id += 1;
@@ -93,7 +90,21 @@ export function createFakeReporting(opts = {}) {
93
90
  return { before, after: settings, by, changed };
94
91
  },
95
92
  },
96
- stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length, failedFlushes: 0 }),
93
+ analytics: {
94
+ async track(input) {
95
+ tracked.push({ ...input, site });
96
+ },
97
+ async series() {
98
+ return [];
99
+ },
100
+ async weekly() {
101
+ return [];
102
+ },
103
+ async recent() {
104
+ return [];
105
+ },
106
+ },
107
+ stats: () => ({ queued: 0, dropped: 0, invalid: 0, flushed: rows.length }),
97
108
  reset() {
98
109
  rows.length = 0;
99
110
  alerts.length = 0;
@@ -40,15 +40,17 @@ export interface TaskContext {
40
40
  renew(): Promise<boolean>;
41
41
  /** The run must finish, or stop scheduling work, by this instant. */
42
42
  readonly deadline: Date;
43
+ /** The task's completed-history watermark as claimed, or null. */
44
+ readonly watermark: string | null;
45
+ /** Moves the watermark, fenced by the claim token; false when the lease was lost. Pass a transaction to commit it with the work it vouches for. */
46
+ commitWatermark(value: string, tx?: Db): Promise<boolean>;
43
47
  }
44
48
  export interface HousekeepingOptions {
45
49
  reporting: Reporting;
46
- db?: Db;
50
+ db: Db;
47
51
  now?: () => Date;
48
52
  /** Per process. Default 60,000 ms. */
49
53
  debounceMs?: number;
50
- /** Tasks run at once. Default 2. */
51
- concurrency?: number;
52
54
  }
53
55
  export interface Housekeeping {
54
56
  /** Idempotent by name. The row is created on the next tick. */
@@ -61,3 +63,4 @@ export interface Housekeeping {
61
63
  }
62
64
  export declare function createHousekeeping(options: HousekeepingOptions): Housekeeping;
63
65
  export { pruneEvents, retentionLag } from './tasks.js';
66
+ export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';
@@ -2,12 +2,13 @@ import { eq, sql } from 'drizzle-orm';
2
2
  import { KIND_PATTERN } from '../schema.js';
3
3
  import { reportingTasks } from '../tables.js';
4
4
  import { describe } from '../writer.js';
5
+ /** Tasks run at once: enough that a slow prune does not delay the alerts, few enough to be one connection each. */
6
+ const CONCURRENCY = 2;
5
7
  export function createHousekeeping(options) {
6
8
  const { reporting } = options;
7
- const db = options.db ?? reporting.db;
9
+ const db = options.db;
8
10
  const now = options.now ?? (() => new Date());
9
11
  const debounceMs = options.debounceMs ?? 60_000;
10
- const concurrency = Math.max(1, options.concurrency ?? 2);
11
12
  const registry = new Map();
12
13
  let lastTick = 0;
13
14
  let ticking = null;
@@ -50,10 +51,19 @@ export function createHousekeeping(options) {
50
51
  .where(sql `${reportingTasks.task} = ${task.name}
51
52
  and ${reportingTasks.nextDueAt} <= now()
52
53
  and (${reportingTasks.leaseExpiresAt} is null or ${reportingTasks.leaseExpiresAt} < now())`)
53
- .returning({ token: reportingTasks.claimToken });
54
- return claimed[0]?.token ?? null;
54
+ .returning({ token: reportingTasks.claimToken, watermark: reportingTasks.watermark });
55
+ const row = claimed[0];
56
+ return row?.token ? { token: row.token, watermark: row.watermark ?? null } : null;
55
57
  });
56
58
  }
59
+ async function commitWatermark(task, token, value, handle) {
60
+ const rows = await handle
61
+ .update(reportingTasks)
62
+ .set({ watermark: value })
63
+ .where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`)
64
+ .returning({ task: reportingTasks.task });
65
+ return rows.length === 1;
66
+ }
57
67
  async function renew(task, token) {
58
68
  const rows = await db
59
69
  .update(reportingTasks)
@@ -77,7 +87,7 @@ export function createHousekeeping(options) {
77
87
  })
78
88
  .where(sql `${reportingTasks.task} = ${task.name} and ${reportingTasks.claimToken} = ${token}`);
79
89
  }
80
- async function runClaimed(task, token, settings) {
90
+ async function runClaimed(task, token, watermark, settings) {
81
91
  const started = now();
82
92
  const deadline = new Date(started.getTime() + task.lease);
83
93
  let timer;
@@ -94,6 +104,8 @@ export function createHousekeeping(options) {
94
104
  now,
95
105
  renew: () => renew(task, token),
96
106
  deadline,
107
+ watermark,
108
+ commitWatermark: (value, tx) => commitWatermark(task, token, value, tx ?? db),
97
109
  }),
98
110
  timeout,
99
111
  ]);
@@ -122,17 +134,17 @@ export function createHousekeeping(options) {
122
134
  }
123
135
  }
124
136
  async function runOne(task, settings) {
125
- const token = await claim(task);
126
- if (!token)
137
+ const claimed = await claim(task);
138
+ if (!claimed)
127
139
  return 'skipped';
128
- return runClaimed(task, token, settings);
140
+ return runClaimed(task, claimed.token, claimed.watermark, settings);
129
141
  }
130
142
  async function tickNow() {
131
143
  await ensureRows();
132
144
  const settings = await reporting.settings.get();
133
145
  const queue = [...registry.values()];
134
146
  const counts = { claimed: 0, skipped: 0, ran: 0, failed: 0 };
135
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
147
+ const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
136
148
  for (let task = queue.shift(); task; task = queue.shift()) {
137
149
  try {
138
150
  const outcome = await runOne(task, settings);
@@ -196,3 +208,4 @@ export function createHousekeeping(options) {
196
208
  };
197
209
  }
198
210
  export { pruneEvents, retentionLag } from './tasks.js';
211
+ export { pruneAnalytics, rollupAnalytics, rollupAnalyticsWeekly } from '../analytics/tasks.js';