@wtfalch/reporting 0.1.2 → 0.3.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.
Files changed (51) hide show
  1. package/README.md +56 -0
  2. package/dist/analytics/collector.d.ts +40 -0
  3. package/dist/analytics/collector.js +146 -0
  4. package/dist/analytics/reader.d.ts +63 -0
  5. package/dist/analytics/reader.js +89 -0
  6. package/dist/analytics/schema.d.ts +59 -0
  7. package/dist/analytics/schema.js +126 -0
  8. package/dist/analytics/tasks.d.ts +32 -0
  9. package/dist/analytics/tasks.js +199 -0
  10. package/dist/analytics/write.d.ts +19 -0
  11. package/dist/analytics/write.js +20 -0
  12. package/dist/browser/index.d.ts +53 -0
  13. package/dist/browser/index.js +330 -0
  14. package/dist/errors/capture.d.ts +52 -0
  15. package/dist/errors/capture.js +187 -0
  16. package/dist/errors/fingerprint.d.ts +36 -0
  17. package/dist/errors/fingerprint.js +211 -0
  18. package/dist/errors/reader.d.ts +19 -0
  19. package/dist/errors/reader.js +66 -0
  20. package/dist/errors/redact.d.ts +46 -0
  21. package/dist/errors/redact.js +93 -0
  22. package/dist/errors/tasks.d.ts +16 -0
  23. package/dist/errors/tasks.js +50 -0
  24. package/dist/fake/index.d.ts +5 -1
  25. package/dist/fake/index.js +28 -0
  26. package/dist/housekeeping/index.d.ts +5 -0
  27. package/dist/housekeeping/index.js +18 -6
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +61 -5
  30. package/dist/ingest-limit.d.ts +22 -0
  31. package/dist/ingest-limit.js +39 -0
  32. package/dist/migrations/0002_analytics.sql +350 -0
  33. package/dist/migrations/0003_errors.sql +118 -0
  34. package/dist/next/client-errors.d.ts +46 -0
  35. package/dist/next/client-errors.js +165 -0
  36. package/dist/next/index.d.ts +55 -0
  37. package/dist/next/index.js +72 -0
  38. package/dist/react/charts.d.ts +24 -0
  39. package/dist/react/charts.js +45 -0
  40. package/dist/react/errors.d.ts +12 -0
  41. package/dist/react/errors.js +17 -0
  42. package/dist/react/index.d.ts +21 -0
  43. package/dist/react/index.js +52 -0
  44. package/dist/schema.d.ts +8 -0
  45. package/dist/schema.js +8 -0
  46. package/dist/settings.d.ts +1 -0
  47. package/dist/settings.js +15 -8
  48. package/dist/tables.d.ts +2287 -253
  49. package/dist/tables.js +111 -1
  50. package/dist/types.d.ts +70 -2
  51. package/package.json +19 -1
@@ -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
+ }
@@ -0,0 +1,53 @@
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
+ * With `errors: true` (docs/plans/errors.md, "Surface"), the beacon also
16
+ * listens for `window.onerror` and `unhandledrejection` and posts them to
17
+ * `errorCollector` with the same transport and batching as everything
18
+ * above — capped at 5 distinct errors and 20 sends total per page load, so
19
+ * a render loop throwing every frame cannot become a request flood.
20
+ *
21
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
22
+ */
23
+ export type ConsentMode = 'none' | 'consented' | 'always';
24
+ export type ConsentAnswer = 'accepted' | 'declined' | null;
25
+ export interface BeaconOptions {
26
+ /** The collector URL, same-origin (`/api/reporting/collect`) or the hub's. */
27
+ readonly collector: string;
28
+ /** This app's site id. */
29
+ readonly site: string;
30
+ readonly consent?: ConsentMode;
31
+ /** Batch size and flush interval; the defaults are the collector's caps. */
32
+ readonly batch?: number;
33
+ readonly flushMs?: number;
34
+ /** For tests: the window to observe and the transport to post with. */
35
+ readonly window?: Window;
36
+ readonly send?: (url: string, body: string) => void;
37
+ /** `window.onerror` + `unhandledrejection`, capped and de-duplicated. Default false. */
38
+ readonly errors?: boolean;
39
+ /** The route `clientErrorHandler` (./next) is mounted at. Required when `errors` is true. */
40
+ readonly errorCollector?: string;
41
+ }
42
+ export interface Beacon {
43
+ track(name: string, props?: Record<string, string | number | boolean | null>): void;
44
+ pageView(): void;
45
+ /** The person's answer under `consented`; sets or clears the cookie and remembers it. */
46
+ accept(): void;
47
+ decline(): void;
48
+ answer(): ConsentAnswer;
49
+ visitorId(): string | null;
50
+ flush(): void;
51
+ destroy(): void;
52
+ }
53
+ export declare function createBeacon(options: BeaconOptions): Beacon;
@@ -0,0 +1,330 @@
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
+ * With `errors: true` (docs/plans/errors.md, "Surface"), the beacon also
16
+ * listens for `window.onerror` and `unhandledrejection` and posts them to
17
+ * `errorCollector` with the same transport and batching as everything
18
+ * above — capped at 5 distinct errors and 20 sends total per page load, so
19
+ * a render loop throwing every frame cannot become a request flood.
20
+ *
21
+ * No dependencies, no globals beyond `window`; under 3 KB gzipped.
22
+ */
23
+ const COOKIE = '_rp';
24
+ const YEAR = 365 * 24 * 60 * 60;
25
+ const NAME = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)?$/;
26
+ function randomId() {
27
+ const bytes = new Uint8Array(16);
28
+ crypto.getRandomValues(bytes);
29
+ let s = '';
30
+ for (const b of bytes)
31
+ s += b.toString(16).padStart(2, '0');
32
+ return s;
33
+ }
34
+ function readCookie(doc) {
35
+ const m = doc.cookie.match(new RegExp(`(?:^|; )${COOKIE}=([^;]*)`));
36
+ if (!m?.[1])
37
+ return null;
38
+ const [id, answer] = decodeURIComponent(m[1]).split('.');
39
+ if (!id || !/^[a-f0-9]{32}$/.test(id))
40
+ return null;
41
+ return { id, answer: answer === 'a' ? 'accepted' : answer === 'd' ? 'declined' : null };
42
+ }
43
+ function writeCookie(doc, value) {
44
+ doc.cookie =
45
+ value === null
46
+ ? `${COOKIE}=; Max-Age=0; Path=/; SameSite=Lax; Secure`
47
+ : `${COOKIE}=${encodeURIComponent(value)}; Max-Age=${YEAR}; Path=/; SameSite=Lax; Secure`;
48
+ }
49
+ export function createBeacon(options) {
50
+ const win = options.window ?? window;
51
+ const doc = win.document;
52
+ const mode = options.consent ?? 'consented';
53
+ const batchSize = Math.min(20, options.batch ?? 20);
54
+ const flushMs = options.flushMs ?? 5000;
55
+ const send = options.send ??
56
+ ((url, body) => {
57
+ const blob = new Blob([body], { type: 'application/json' });
58
+ if (!win.navigator.sendBeacon?.(url, blob)) {
59
+ void win.fetch(url, { method: 'POST', body, keepalive: true, credentials: 'include' });
60
+ }
61
+ });
62
+ // Identity: a visitor id only with consent; a session id per tab.
63
+ let cookie = readCookie(doc);
64
+ let answer = cookie?.answer ?? null;
65
+ const declined = () => mode === 'none' || (mode === 'consented' && answer !== 'accepted');
66
+ if (mode === 'always' && !cookie) {
67
+ cookie = { id: randomId(), answer: 'accepted' };
68
+ writeCookie(doc, `${cookie.id}.a`);
69
+ }
70
+ if (declined()) {
71
+ if (cookie && answer !== 'declined')
72
+ cookie = null;
73
+ }
74
+ let sessionId = null;
75
+ function inMemorySession() {
76
+ if (!sessionId)
77
+ sessionId = randomId();
78
+ return sessionId;
79
+ }
80
+ function session() {
81
+ if (declined())
82
+ return inMemorySession();
83
+ try {
84
+ const stored = win.sessionStorage.getItem(`${COOKIE}s`);
85
+ if (stored && /^[a-f0-9]{32}$/.test(stored))
86
+ return stored;
87
+ const fresh = randomId();
88
+ win.sessionStorage.setItem(`${COOKIE}s`, fresh);
89
+ return fresh;
90
+ }
91
+ catch {
92
+ return inMemorySession();
93
+ }
94
+ }
95
+ const queue = [];
96
+ let timer = null;
97
+ function flush() {
98
+ if (timer) {
99
+ clearTimeout(timer);
100
+ timer = null;
101
+ }
102
+ if (queue.length === 0)
103
+ return;
104
+ const events = queue.splice(0, batchSize);
105
+ const body = JSON.stringify({
106
+ site: options.site,
107
+ visitorId: declined() ? null : (cookie?.id ?? null),
108
+ sessionId: session(),
109
+ events,
110
+ });
111
+ try {
112
+ send(options.collector, body);
113
+ }
114
+ catch {
115
+ // A beacon that cannot leave is dropped; nothing here may throw into the page.
116
+ }
117
+ if (queue.length > 0)
118
+ flush();
119
+ }
120
+ function push(event) {
121
+ queue.push(event);
122
+ if (queue.length >= batchSize)
123
+ flush();
124
+ else if (!timer)
125
+ timer = setTimeout(flush, flushMs);
126
+ }
127
+ // Errors: window.onerror + unhandledrejection, capped and de-duplicated
128
+ // (docs/plans/errors.md, "Surface"). A render loop throwing every frame
129
+ // must not become a request flood: at most 5 distinct errors — by kind +
130
+ // message + first stack line — and 20 sends total are ever posted per
131
+ // page load; a repeat past the first occurrence only bumps a counter
132
+ // kept in memory, never resent. Same transport, same batching and the
133
+ // same destroy() teardown as the queue above.
134
+ const ERROR_DISTINCT_MAX = 5;
135
+ const ERROR_TOTAL_MAX = 20;
136
+ let errCleanup = null;
137
+ if (options.errors && options.errorCollector) {
138
+ const errorCollector = options.errorCollector;
139
+ const seen = new Map();
140
+ let total = 0;
141
+ const errQueue = [];
142
+ let errTimer = null;
143
+ function errFlush() {
144
+ if (errTimer) {
145
+ clearTimeout(errTimer);
146
+ errTimer = null;
147
+ }
148
+ if (errQueue.length === 0)
149
+ return;
150
+ const errors = errQueue.splice(0, batchSize);
151
+ const body = JSON.stringify({ site: options.site, errors });
152
+ try {
153
+ send(errorCollector, body);
154
+ }
155
+ catch {
156
+ // Same rule as the transport above: never throw into the page.
157
+ }
158
+ if (errQueue.length > 0)
159
+ errFlush();
160
+ }
161
+ function errPush(entry) {
162
+ errQueue.push(entry);
163
+ if (errQueue.length >= batchSize)
164
+ errFlush();
165
+ else if (!errTimer)
166
+ errTimer = setTimeout(errFlush, flushMs);
167
+ }
168
+ // A query string can carry a token; a stack's frame URLs are the only
169
+ // place one could hide here, stripped exactly as the collector strips
170
+ // one from a pathname (analytics/schema.ts's normalisePath).
171
+ function stripQuery(text) {
172
+ return text.replace(/\?[^\s:)]*/g, '');
173
+ }
174
+ // The literal first line of `.stack` is just `kind: message` again
175
+ // (V8) or, on Firefox, already a frame; either way the second line is
176
+ // the first real call frame when there is one — what actually tells
177
+ // two same-kind, same-message errors apart.
178
+ function firstStackLine(stack) {
179
+ if (!stack)
180
+ return '';
181
+ const lines = stack.split('\n');
182
+ return (lines[1] ?? lines[0] ?? '').trim();
183
+ }
184
+ function record(kind, message, stack) {
185
+ const cleanMessage = stripQuery(message);
186
+ const cleanStack = stack === null ? null : stripQuery(stack);
187
+ const key = `${kind}\u0000${cleanMessage}\u0000${firstStackLine(cleanStack)}`;
188
+ const count = seen.get(key);
189
+ if (count !== undefined) {
190
+ seen.set(key, count + 1);
191
+ return; // a repeat: counted, never resent.
192
+ }
193
+ if (seen.size >= ERROR_DISTINCT_MAX || total >= ERROR_TOTAL_MAX)
194
+ return;
195
+ seen.set(key, 1);
196
+ total += 1;
197
+ errPush({ kind, message: cleanMessage, stack: cleanStack });
198
+ }
199
+ function describeReason(reason, fallbackKind) {
200
+ if (reason instanceof Error) {
201
+ return {
202
+ kind: reason.name || fallbackKind,
203
+ message: reason.message || fallbackKind,
204
+ stack: typeof reason.stack === 'string' ? reason.stack : null,
205
+ };
206
+ }
207
+ if (typeof reason === 'string')
208
+ return { kind: fallbackKind, message: reason, stack: null };
209
+ try {
210
+ return {
211
+ kind: fallbackKind,
212
+ message: JSON.stringify(reason) ?? String(reason),
213
+ stack: null,
214
+ };
215
+ }
216
+ catch {
217
+ return { kind: fallbackKind, message: fallbackKind, stack: null };
218
+ }
219
+ }
220
+ const onError = (event) => {
221
+ // A cross-origin script with no CORS grant reports exactly this
222
+ // message, with no line, column or Error object: nothing here is
223
+ // useful, and every throw from a foreign <script> would otherwise
224
+ // repeat it.
225
+ if (event.message === 'Script error.')
226
+ return;
227
+ if (event.error instanceof Error || typeof event.error === 'string') {
228
+ const info = describeReason(event.error, 'Error');
229
+ record(info.kind, info.message, info.stack);
230
+ }
231
+ else {
232
+ record('Error', event.message || 'Error', null);
233
+ }
234
+ };
235
+ const onRejection = (event) => {
236
+ const info = describeReason(event.reason, 'UnhandledRejection');
237
+ record(info.kind, info.message, info.stack);
238
+ };
239
+ win.addEventListener('error', onError);
240
+ win.addEventListener('unhandledrejection', onRejection);
241
+ errCleanup = () => {
242
+ errFlush();
243
+ win.removeEventListener('error', onError);
244
+ win.removeEventListener('unhandledrejection', onRejection);
245
+ };
246
+ }
247
+ const pathOf = () => win.location.pathname || '/';
248
+ let currentPath = pathOf();
249
+ let enteredAt = Date.now();
250
+ let lastReferrer = doc.referrer || null;
251
+ function pageView() {
252
+ currentPath = pathOf();
253
+ enteredAt = Date.now();
254
+ push({
255
+ name: 'page.view',
256
+ at: Date.now(),
257
+ path: currentPath,
258
+ referrer: lastReferrer,
259
+ props: {},
260
+ });
261
+ lastReferrer = null;
262
+ }
263
+ function pageLeave() {
264
+ const duration = Math.max(0, Math.round((Date.now() - enteredAt) / 1000));
265
+ push({ name: 'page.leave', at: Date.now(), path: currentPath, props: {}, duration });
266
+ flush();
267
+ }
268
+ function track(name, props = {}) {
269
+ if (!NAME.test(name) || name.startsWith('page.'))
270
+ return;
271
+ push({ name, at: Date.now(), path: pathOf(), props });
272
+ }
273
+ // History: a SPA navigation is a page view too.
274
+ const history = win.history;
275
+ const pushState = history.pushState.bind(history);
276
+ const replaceState = history.replaceState.bind(history);
277
+ const onNavigate = () => {
278
+ if (pathOf() === currentPath)
279
+ return;
280
+ pageLeave();
281
+ pageView();
282
+ };
283
+ history.pushState = (...args) => {
284
+ pushState(...args);
285
+ onNavigate();
286
+ };
287
+ history.replaceState = (...args) => {
288
+ replaceState(...args);
289
+ onNavigate();
290
+ };
291
+ const onPop = () => onNavigate();
292
+ const onHide = () => {
293
+ if (doc.visibilityState === 'hidden')
294
+ pageLeave();
295
+ };
296
+ win.addEventListener('popstate', onPop);
297
+ doc.addEventListener('visibilitychange', onHide);
298
+ win.addEventListener('pagehide', pageLeave);
299
+ pageView();
300
+ return {
301
+ track,
302
+ pageView,
303
+ accept() {
304
+ if (mode !== 'consented')
305
+ return;
306
+ answer = 'accepted';
307
+ cookie = { id: cookie?.id ?? randomId(), answer };
308
+ writeCookie(doc, `${cookie.id}.a`);
309
+ },
310
+ decline() {
311
+ if (mode !== 'consented')
312
+ return;
313
+ answer = 'declined';
314
+ cookie = null;
315
+ writeCookie(doc, `${randomId()}.d`);
316
+ },
317
+ answer: () => answer,
318
+ visitorId: () => (declined() ? null : (cookie?.id ?? null)),
319
+ flush,
320
+ destroy() {
321
+ flush();
322
+ errCleanup?.();
323
+ history.pushState = pushState;
324
+ history.replaceState = replaceState;
325
+ win.removeEventListener('popstate', onPop);
326
+ doc.removeEventListener('visibilitychange', onHide);
327
+ win.removeEventListener('pagehide', pageLeave);
328
+ },
329
+ };
330
+ }
@@ -0,0 +1,52 @@
1
+ import type { CaptureContext, Db, EventInput, Logger } from '../types.js';
2
+ /**
3
+ * The one path an uncaught exception takes into the estate
4
+ * (docs/plans/errors.md, "Surface" and "Why a second table, not just an
5
+ * event row"): one `reporting_events` row for the timeline, through the
6
+ * writer's own `event()` so it shares its queue, batching and logger line,
7
+ * and one upsert into the fingerprint's group — first seen, last seen, how
8
+ * many, and whether an operator has already dealt with it.
9
+ *
10
+ * Two contracts this must never break, both sharpened from the writer's own
11
+ * (writer.ts's header comment) for an error reporter specifically:
12
+ *
13
+ * 1. A capture must never carry a secret. Redaction runs on `message` and
14
+ * `stack` before anything else touches them — a stack is the likeliest
15
+ * way a wrapping key leaves the process (redact.ts) — and the
16
+ * fingerprint is computed on that same redacted text, so a secret can
17
+ * never reach the digest basis either.
18
+ * 2. A capture must never throw back into the code that threw the original
19
+ * error. The whole body of `captureError` is one try/catch, and the
20
+ * group upsert — the one part of this that touches a database that can
21
+ * be down — is scheduled through the host's `defer` and wrapped in its
22
+ * own catch, exactly as the writer's flush protects the request from a
23
+ * failed insert. An error reporter that itself throws while reporting an
24
+ * error is the one failure mode worse than not reporting it.
25
+ */
26
+ export interface CaptureOptions {
27
+ readonly db: Db;
28
+ readonly log: Logger;
29
+ readonly site: string;
30
+ readonly now: () => Date;
31
+ /** Runs the group upsert after the current unit of work, same as the writer's flush (writer.ts). */
32
+ readonly defer: (fn: () => Promise<void>) => void;
33
+ /** The Writer's own `event()`, so the timeline row shares its queue, batching and logger line. */
34
+ readonly event: (input: EventInput) => void;
35
+ /** Stamped on a fresh group when a capture's own context supplies none. */
36
+ readonly release?: string | null;
37
+ }
38
+ /**
39
+ * `captureError(error, context?)`: the class from `errorKind` (or the
40
+ * context's own, when the caller already knows it), the message and stack
41
+ * from the value itself — never assuming it is an `Error` — redacted before
42
+ * anything else touches them, bounded to what the table accepts, then
43
+ * fingerprinted on that same redacted, bounded text so a secret can never
44
+ * reach the digest either.
45
+ *
46
+ * Writes the timeline row synchronously through `o.event()` (the writer
47
+ * queues and logs it), then schedules the group upsert through `o.defer` —
48
+ * no timer of its own, same as the writer's flush. The whole body is one
49
+ * try/catch: nothing here may throw back into the code that threw the
50
+ * original error.
51
+ */
52
+ export declare function createCapture(o: CaptureOptions): (error: unknown, context?: CaptureContext) => void;