@wtfalch/reporting 0.2.1 → 0.3.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.
@@ -1,30 +1,6 @@
1
+ import { Buckets } from '../ingest-limit.js';
1
2
  import { ANALYTICS_LIMITS, beaconBatchSchema, clampOccurredAt, countryOf, deviceOf, normalisePath, referrerHostOf, } from './schema.js';
2
3
  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
4
  const NO_CONTENT = () => new Response(null, { status: 204 });
29
5
  /**
30
6
  * The collector: a public route handler over the Web `Request`. Always 204,
@@ -12,6 +12,12 @@
12
12
  * the session id lives in `sessionStorage` under `consented`/`always` and in
13
13
  * memory under `none`. Nothing about the browser is stored beyond that.
14
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
+ *
15
21
  * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
22
  */
17
23
  export type ConsentMode = 'none' | 'consented' | 'always';
@@ -28,6 +34,10 @@ export interface BeaconOptions {
28
34
  /** For tests: the window to observe and the transport to post with. */
29
35
  readonly window?: Window;
30
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;
31
41
  }
32
42
  export interface Beacon {
33
43
  track(name: string, props?: Record<string, string | number | boolean | null>): void;
@@ -37,6 +47,14 @@ export interface Beacon {
37
47
  decline(): void;
38
48
  answer(): ConsentAnswer;
39
49
  visitorId(): string | null;
50
+ /**
51
+ * Report an error the browser never threw at `window` -- the one a React
52
+ * error boundary caught, which is swallowed before `window.onerror` can
53
+ * see it, and is exactly the case `error.tsx` exists for. Subject to the
54
+ * same de-duplication and the same per-page caps as a thrown one; a no-op
55
+ * when `errors` is off.
56
+ */
57
+ captureError(error: unknown, kind?: string): void;
40
58
  flush(): void;
41
59
  destroy(): void;
42
60
  }
@@ -12,6 +12,12 @@
12
12
  * the session id lives in `sessionStorage` under `consented`/`always` and in
13
13
  * memory under `none`. Nothing about the browser is stored beyond that.
14
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
+ *
15
21
  * No dependencies, no globals beyond `window`; under 3 KB gzipped.
16
22
  */
17
23
  const COOKIE = '_rp';
@@ -118,6 +124,131 @@ export function createBeacon(options) {
118
124
  else if (!timer)
119
125
  timer = setTimeout(flush, flushMs);
120
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
+ let recordError = null;
138
+ if (options.errors && options.errorCollector) {
139
+ const errorCollector = options.errorCollector;
140
+ const seen = new Map();
141
+ let total = 0;
142
+ const errQueue = [];
143
+ let errTimer = null;
144
+ function errFlush() {
145
+ if (errTimer) {
146
+ clearTimeout(errTimer);
147
+ errTimer = null;
148
+ }
149
+ if (errQueue.length === 0)
150
+ return;
151
+ const errors = errQueue.splice(0, batchSize);
152
+ const body = JSON.stringify({ site: options.site, errors });
153
+ try {
154
+ send(errorCollector, body);
155
+ }
156
+ catch {
157
+ // Same rule as the transport above: never throw into the page.
158
+ }
159
+ if (errQueue.length > 0)
160
+ errFlush();
161
+ }
162
+ function errPush(entry) {
163
+ errQueue.push(entry);
164
+ if (errQueue.length >= batchSize)
165
+ errFlush();
166
+ else if (!errTimer)
167
+ errTimer = setTimeout(errFlush, flushMs);
168
+ }
169
+ // A query string can carry a token; a stack's frame URLs are the only
170
+ // place one could hide here, stripped exactly as the collector strips
171
+ // one from a pathname (analytics/schema.ts's normalisePath).
172
+ function stripQuery(text) {
173
+ return text.replace(/\?[^\s:)]*/g, '');
174
+ }
175
+ // The literal first line of `.stack` is just `kind: message` again
176
+ // (V8) or, on Firefox, already a frame; either way the second line is
177
+ // the first real call frame when there is one — what actually tells
178
+ // two same-kind, same-message errors apart.
179
+ function firstStackLine(stack) {
180
+ if (!stack)
181
+ return '';
182
+ const lines = stack.split('\n');
183
+ return (lines[1] ?? lines[0] ?? '').trim();
184
+ }
185
+ function record(kind, message, stack) {
186
+ const cleanMessage = stripQuery(message);
187
+ const cleanStack = stack === null ? null : stripQuery(stack);
188
+ const key = `${kind}\u0000${cleanMessage}\u0000${firstStackLine(cleanStack)}`;
189
+ const count = seen.get(key);
190
+ if (count !== undefined) {
191
+ seen.set(key, count + 1);
192
+ return; // a repeat: counted, never resent.
193
+ }
194
+ if (seen.size >= ERROR_DISTINCT_MAX || total >= ERROR_TOTAL_MAX)
195
+ return;
196
+ seen.set(key, 1);
197
+ total += 1;
198
+ errPush({ kind, message: cleanMessage, stack: cleanStack });
199
+ }
200
+ function describeReason(reason, fallbackKind) {
201
+ if (reason instanceof Error) {
202
+ return {
203
+ kind: reason.name || fallbackKind,
204
+ message: reason.message || fallbackKind,
205
+ stack: typeof reason.stack === 'string' ? reason.stack : null,
206
+ };
207
+ }
208
+ if (typeof reason === 'string')
209
+ return { kind: fallbackKind, message: reason, stack: null };
210
+ try {
211
+ return {
212
+ kind: fallbackKind,
213
+ message: JSON.stringify(reason) ?? String(reason),
214
+ stack: null,
215
+ };
216
+ }
217
+ catch {
218
+ return { kind: fallbackKind, message: fallbackKind, stack: null };
219
+ }
220
+ }
221
+ recordError = (error, kind) => {
222
+ const info = describeReason(error, kind ?? 'Error');
223
+ record(info.kind, info.message, info.stack);
224
+ };
225
+ const onError = (event) => {
226
+ // A cross-origin script with no CORS grant reports exactly this
227
+ // message, with no line, column or Error object: nothing here is
228
+ // useful, and every throw from a foreign <script> would otherwise
229
+ // repeat it.
230
+ if (event.message === 'Script error.')
231
+ return;
232
+ if (event.error instanceof Error || typeof event.error === 'string') {
233
+ const info = describeReason(event.error, 'Error');
234
+ record(info.kind, info.message, info.stack);
235
+ }
236
+ else {
237
+ record('Error', event.message || 'Error', null);
238
+ }
239
+ };
240
+ const onRejection = (event) => {
241
+ const info = describeReason(event.reason, 'UnhandledRejection');
242
+ record(info.kind, info.message, info.stack);
243
+ };
244
+ win.addEventListener('error', onError);
245
+ win.addEventListener('unhandledrejection', onRejection);
246
+ errCleanup = () => {
247
+ errFlush();
248
+ win.removeEventListener('error', onError);
249
+ win.removeEventListener('unhandledrejection', onRejection);
250
+ };
251
+ }
121
252
  const pathOf = () => win.location.pathname || '/';
122
253
  let currentPath = pathOf();
123
254
  let enteredAt = Date.now();
@@ -190,9 +321,11 @@ export function createBeacon(options) {
190
321
  },
191
322
  answer: () => answer,
192
323
  visitorId: () => (declined() ? null : (cookie?.id ?? null)),
324
+ captureError: (error, kind) => recordError?.(error, kind),
193
325
  flush,
194
326
  destroy() {
195
327
  flush();
328
+ errCleanup?.();
196
329
  history.pushState = pushState;
197
330
  history.replaceState = replaceState;
198
331
  win.removeEventListener('popstate', onPop);
@@ -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;
@@ -0,0 +1,187 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { ERROR_RUNTIMES, KIND_PATTERN, LIMITS } from '../schema.js';
3
+ import { reportingErrors } from '../tables.js';
4
+ import { describe } from '../writer.js';
5
+ import { errorKind, fingerprint } from './fingerprint.js';
6
+ import { heldSecrets, redactText } from './redact.js';
7
+ const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g;
8
+ const INVALID_KIND_CHARS = /[^a-z0-9_]+/g;
9
+ /** camelCase/PascalCase/anything, folded down to what `KIND_PATTERN` allows. */
10
+ function snakeCase(name) {
11
+ const lowered = name.replace(CAMEL_BOUNDARY, '$1_$2').toLowerCase();
12
+ return lowered
13
+ .replace(INVALID_KIND_CHARS, '_')
14
+ .replace(/_+/g, '_')
15
+ .replace(/^_+|_+$/g, '');
16
+ }
17
+ /**
18
+ * `error.<snake_case class name>` for the timeline row's `kind`. Falls back
19
+ * to `error.unknown` rather than refuse the capture: a class name that
20
+ * cannot become a valid `namespace.name` once lower-cased (it leads with a
21
+ * digit, or is nothing but punctuation) is itself informative, but not worth
22
+ * losing the whole report over.
23
+ */
24
+ function eventKindFor(kind) {
25
+ const candidate = `error.${snakeCase(kind)}`;
26
+ return KIND_PATTERN.test(candidate) ? candidate : 'error.unknown';
27
+ }
28
+ /** `errorKind(error)`, unless the caller already knows the class better than the thrown value can say (a JSON-serialised browser report, say). */
29
+ function resolveKind(error, override) {
30
+ const trimmed = override?.trim();
31
+ return trimmed && trimmed.length > 0 ? trimmed.slice(0, 100) : errorKind(error);
32
+ }
33
+ /** Safe for every throw JavaScript actually allows: a string, `null`, an object with no message at all. Never empty, so it always clears the table's and the schema's non-empty checks. */
34
+ function messageOf(error, kind) {
35
+ if (error instanceof Error)
36
+ return error.message || error.name || kind;
37
+ if (typeof error === 'string')
38
+ return error || kind;
39
+ if (error === null)
40
+ return 'null';
41
+ if (error === undefined)
42
+ return 'undefined';
43
+ try {
44
+ return String(error) || kind;
45
+ }
46
+ catch {
47
+ return kind;
48
+ }
49
+ }
50
+ /** Only a real `Error` has a stack worth keeping; everything else groups on `kind` and the message alone (fingerprint.ts). */
51
+ function stackOf(error) {
52
+ return error instanceof Error && typeof error.stack === 'string' ? error.stack : null;
53
+ }
54
+ /** Keeps the top of the text — the start, where the throw site and its callers are — and drops the rest. */
55
+ function truncateEnd(text, max) {
56
+ return text.length > max ? text.slice(0, max) : text;
57
+ }
58
+ /**
59
+ * `reporting_errors.tenant_id` is a uuid column and the event row's schema
60
+ * refuses anything else, so a caller that hands over a slug, an empty string
61
+ * or a number loses the whole group row rather than just the attribution.
62
+ * Callers are not all trusted to the same degree -- `./next`'s client ingest
63
+ * takes what a browser posted -- so the guard lives here, at the one door
64
+ * every capture goes through.
65
+ */
66
+ function validTenant(value) {
67
+ return typeof value === 'string' && UUID.test(value) ? value : null;
68
+ }
69
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
70
+ function normaliseRuntime(runtime) {
71
+ return runtime && ERROR_RUNTIMES.includes(runtime) ? runtime : 'server';
72
+ }
73
+ /**
74
+ * `insert ... on conflict (fingerprint) do update`, one round trip so two
75
+ * occurrences arriving at once still land as one row: bump `occurrences`,
76
+ * move `last_seen_at`, overwrite the sample fields with the newest
77
+ * occurrence. `first_seen_at` is left out of `set` entirely, so a conflict
78
+ * never moves it.
79
+ *
80
+ * A resolved or ignored group reopens: the pair the table's own CHECK
81
+ * enforces (`reporting_errors_resolved_pair_check`, 0003_errors.sql) means
82
+ * `state` can only move back to 'open' by clearing `resolved_at` and
83
+ * `resolved_by` in the same statement, never one alone.
84
+ */
85
+ async function upsertGroup(db, site, s) {
86
+ const reopened = sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then 'open' else ${reportingErrors.state} end`;
87
+ await db
88
+ .insert(reportingErrors)
89
+ .values({
90
+ fingerprint: s.fingerprint,
91
+ site,
92
+ kind: s.kind,
93
+ message: s.message,
94
+ stack: s.stack,
95
+ runtime: s.runtime,
96
+ release: s.release,
97
+ firstSeenAt: s.at,
98
+ lastSeenAt: s.at,
99
+ occurrences: 1,
100
+ tenantId: s.tenantId,
101
+ requestId: s.requestId,
102
+ })
103
+ .onConflictDoUpdate({
104
+ target: reportingErrors.fingerprint,
105
+ set: {
106
+ occurrences: sql `${reportingErrors.occurrences} + 1`,
107
+ lastSeenAt: s.at,
108
+ message: s.message,
109
+ stack: s.stack,
110
+ tenantId: s.tenantId,
111
+ requestId: s.requestId,
112
+ release: s.release,
113
+ state: reopened,
114
+ resolvedAt: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedAt} end`,
115
+ resolvedBy: sql `case when ${reportingErrors.state} in ('resolved', 'ignored') then null else ${reportingErrors.resolvedBy} end`,
116
+ },
117
+ });
118
+ }
119
+ /**
120
+ * `captureError(error, context?)`: the class from `errorKind` (or the
121
+ * context's own, when the caller already knows it), the message and stack
122
+ * from the value itself — never assuming it is an `Error` — redacted before
123
+ * anything else touches them, bounded to what the table accepts, then
124
+ * fingerprinted on that same redacted, bounded text so a secret can never
125
+ * reach the digest either.
126
+ *
127
+ * Writes the timeline row synchronously through `o.event()` (the writer
128
+ * queues and logs it), then schedules the group upsert through `o.defer` —
129
+ * no timer of its own, same as the writer's flush. The whole body is one
130
+ * try/catch: nothing here may throw back into the code that threw the
131
+ * original error.
132
+ */
133
+ export function createCapture(o) {
134
+ return function captureError(error, context = {}) {
135
+ try {
136
+ const kind = resolveKind(error, context.kind);
137
+ const secrets = heldSecrets(process.env);
138
+ const message = truncateEnd(redactText(messageOf(error, kind), secrets), LIMITS.message);
139
+ const rawStack = stackOf(error);
140
+ const stack = rawStack === null ? null : truncateEnd(redactText(rawStack, secrets), LIMITS.stack);
141
+ const fp = fingerprint({ kind, message, stack });
142
+ const runtime = normaliseRuntime(context.runtime);
143
+ // A tenant id that is not a uuid would be refused by the event row's
144
+ // own zod check (schema.ts: `tenantId: z.uuid().nullish()`) and would
145
+ // fail the group upsert at Postgres's cast, which is caught, logged and
146
+ // dropped -- leaving an occurrence with a timeline row and no group.
147
+ // The attribution is worth less than the report, so a malformed id is
148
+ // dropped and the capture goes on.
149
+ const tenantId = validTenant(context.tenantId);
150
+ const requestId = context.requestId ?? null;
151
+ const release = context.release ?? o.release ?? null;
152
+ const at = o.now();
153
+ o.event({
154
+ kind: eventKindFor(kind),
155
+ level: 'error',
156
+ message,
157
+ tenantId,
158
+ requestId,
159
+ data: { fingerprint: fp, runtime },
160
+ });
161
+ const sample = {
162
+ fingerprint: fp,
163
+ kind,
164
+ message,
165
+ stack,
166
+ runtime,
167
+ release,
168
+ tenantId,
169
+ requestId,
170
+ at,
171
+ };
172
+ try {
173
+ o.defer(() => upsertGroup(o.db, o.site, sample).catch((err) => {
174
+ o.log.error({ err: describe(err), fingerprint: fp }, 'reporting: error group upsert failed');
175
+ }));
176
+ }
177
+ catch (err) {
178
+ // A host's defer that throws (called outside a request scope, say)
179
+ // must not take the report with it, same as writer.ts's schedule().
180
+ o.log.warn({ err: describe(err) }, 'reporting: defer threw; the error group upsert was dropped');
181
+ }
182
+ }
183
+ catch (err) {
184
+ o.log.error({ err: describe(err) }, 'reporting: captureError failed');
185
+ }
186
+ };
187
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One V8 stack frame to its stable identity: the function name (if any) and
3
+ * the location, with everything that drifts for reasons unrelated to the
4
+ * bug removed.
5
+ *
6
+ * Handles both formats Node/V8 produce:
7
+ * at functionName (/path/to/file.ts:10:5)
8
+ * at /path/to/file.ts:10:5
9
+ */
10
+ export declare function normaliseFrame(frame: string): string;
11
+ /**
12
+ * The group an error belongs to: sha256 over the kind and the top five
13
+ * normalised frames, truncated to 32 lower-case hex characters. Five frames
14
+ * because the throw site and its immediate callers are what identifies a
15
+ * bug; frames further up are shared by every request and add nothing but
16
+ * noise a coincidental match in them could exploit.
17
+ *
18
+ * Falls back to `kind + masked message` when there is no stack at all, or
19
+ * the stack has no line this package recognises as a frame (a non-V8
20
+ * runtime, or a stack someone already stripped).
21
+ */
22
+ export declare function fingerprint(input: {
23
+ kind: string;
24
+ message: string;
25
+ stack?: string | null;
26
+ }): string;
27
+ /**
28
+ * The class name a `kind` column and a fingerprint are built from —
29
+ * `TypeError`, `ZodError` — bounded so a pathological name cannot blow out
30
+ * the 512-character message column it eventually contributes to, and safe
31
+ * for the throws JavaScript actually allows: a string, `null`, an object
32
+ * with no prototype at all. None of those may throw out of this function;
33
+ * an error reporter that itself throws while reporting an error is the one
34
+ * failure mode worse than not reporting it.
35
+ */
36
+ export declare function errorKind(error: unknown): string;