@wtfalch/reporting 0.2.1 → 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.
@@ -0,0 +1,211 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * Turning an exception into a group, without a database and without a
4
+ * database of known errors: `sha256(kind + top five normalised frames)`,
5
+ * truncated to 32 hex characters (docs/plans/errors.md, "Fingerprint").
6
+ *
7
+ * The whole design is one constraint: the same bug, thrown a thousand
8
+ * times across deploys, must fingerprint the same every time, and a
9
+ * different bug must not collide with it. That is why every normalisation
10
+ * below throws away information that varies for reasons unrelated to which
11
+ * bug this is — a line number, a package's patch version, a machine's
12
+ * absolute filesystem path — while keeping what actually names the bug: the
13
+ * error's class, the call chain's function names, and which files they are
14
+ * in relative to the project.
15
+ */
16
+ /** node_modules/<pkg>, node_modules/@scope/pkg — a scoped package's name is two segments. */
17
+ const SCOPED_PACKAGE = /^(@[^/]+\/[^/]+)/;
18
+ const PLAIN_PACKAGE = /^([^/]+)/;
19
+ /** webpack, webpack-internal and turbopack all prefix a location with a scheme and a fake "host". */
20
+ const BUNDLER_SCHEME_PREFIX = /^[a-zA-Z][\w+.-]*:\/\/[^/]*\//;
21
+ /** Trailing `:<line>:<column>` or, more rarely, just `:<line>`. */
22
+ const TRAILING_LINE_COL = /:\d+:\d+$/;
23
+ const TRAILING_LINE = /:\d+$/;
24
+ /** `at fn (location)` — the parenthesised form. Function-only frames have no match. */
25
+ const FRAME_WITH_FUNCTION = /^(.*)\s\((.+)\)$/;
26
+ /**
27
+ * One V8 stack frame to its stable identity: the function name (if any) and
28
+ * the location, with everything that drifts for reasons unrelated to the
29
+ * bug removed.
30
+ *
31
+ * Handles both formats Node/V8 produce:
32
+ * at functionName (/path/to/file.ts:10:5)
33
+ * at /path/to/file.ts:10:5
34
+ */
35
+ export function normaliseFrame(frame) {
36
+ let s = frame.trim();
37
+ if (s.startsWith('at '))
38
+ s = s.slice(3).trim();
39
+ const withFunction = s.match(FRAME_WITH_FUNCTION);
40
+ const functionName = withFunction ? withFunction[1]?.trim() : undefined;
41
+ const location = withFunction ? (withFunction[2]?.trim() ?? '') : s;
42
+ const normalisedLocation = normaliseLocation(location);
43
+ return functionName ? `${functionName} (${normalisedLocation})` : normalisedLocation;
44
+ }
45
+ function normaliseLocation(rawLocation) {
46
+ // Next.js emits webpack-internal:///, webpack://_N_E/ and turbopack://[project]/
47
+ // ahead of the real path; a bundled build and a plain `node file.js` run of
48
+ // the same source must fingerprint the same, so the scheme goes first.
49
+ let location = rawLocation.replace(BUNDLER_SCHEME_PREFIX, '');
50
+ location = location.replace(/^file:\/\//, '');
51
+ // A dependency's own file layout is not this app's bug: two patch releases
52
+ // of the same package rename nothing an app author can act on, and pnpm's
53
+ // content-addressed store puts the version number in the path itself
54
+ // (node_modules/.pnpm/lodash@4.17.20/node_modules/lodash/index.js). Taking
55
+ // the LAST node_modules/ segment lands on the package's own copy of
56
+ // itself, past any version-numbered store directory, so both versions
57
+ // collapse to the same "node_modules/lodash".
58
+ const nodeModulesIndex = location.lastIndexOf('node_modules/');
59
+ if (nodeModulesIndex !== -1) {
60
+ const afterNodeModules = location.slice(nodeModulesIndex + 'node_modules/'.length);
61
+ const scoped = afterNodeModules.match(SCOPED_PACKAGE);
62
+ const plain = afterNodeModules.match(PLAIN_PACKAGE);
63
+ const pkg = scoped?.[1] ?? plain?.[1];
64
+ return pkg ? `node_modules/${pkg}` : 'node_modules';
65
+ }
66
+ // Line and column drift on every edit to the file, even ones nowhere near
67
+ // the throw (a comment added above it shifts every line below). Keeping
68
+ // them would turn one bug into a new group on every deploy, which is the
69
+ // exact failure mode a grouped error list exists to avoid.
70
+ location = location.replace(TRAILING_LINE_COL, '').replace(TRAILING_LINE, '');
71
+ // A bundler names its output after a build-varying token — a chunk id
72
+ // (8471.js) or a content hash appended for cache-busting (main-abc123.js).
73
+ // Neither identifies the bug; both identify the build, so a browser or
74
+ // Next.js chunk stack would otherwise open a brand new group on every
75
+ // deploy — the exact failure line numbers were already excluded to avoid.
76
+ location = maskBasenameHash(location);
77
+ return stripAbsolutePrefix(location);
78
+ }
79
+ /** A run of 6+ hex characters straight after the last `-` or `.` in a name. */
80
+ const HASH_SUFFIX = /[-.][0-9a-fA-F]{6,}$/;
81
+ /**
82
+ * Masks a build-varying token in the FILENAME only — never a directory name
83
+ * — so `chunks/8471.js` and `chunks/main-abc123.js` both collapse to a form
84
+ * that survives a rebuild, while `dashboard/page.js` is untouched because
85
+ * there is nothing in it to mask.
86
+ */
87
+ function maskBasenameHash(path) {
88
+ const lastSlash = path.lastIndexOf('/');
89
+ const dir = lastSlash === -1 ? '' : path.slice(0, lastSlash + 1);
90
+ const basename = lastSlash === -1 ? path : path.slice(lastSlash + 1);
91
+ return `${dir}${maskBuildHash(basename)}`;
92
+ }
93
+ function maskBuildHash(basename) {
94
+ const lastDot = basename.lastIndexOf('.');
95
+ // No extension, or a dotfile (".env"): nothing to anchor an extension on,
96
+ // so there is no safe way to tell a hash from the rest of the name.
97
+ if (lastDot <= 0)
98
+ return basename;
99
+ const ext = basename.slice(lastDot);
100
+ const stem = basename.slice(0, lastDot);
101
+ // A chunk named after nothing but its id: "8471.js", "9032.js". The whole
102
+ // stem changes on every build even though it is the same chunk.
103
+ if (/^\d+$/.test(stem))
104
+ return `#${ext}`;
105
+ // A real name with a hash tacked on for cache-busting: "main-abc123.js",
106
+ // "page.4f2a91b.js". Anchored on the LAST separator so it only ever
107
+ // consumes a trailing suffix, and constrained to hex characters so an
108
+ // ordinary multi-word filename is never mistaken for one — "component" in
109
+ // "my-component.tsx" contains 'o', 'p', 'n', 't', none of them hex digits,
110
+ // so it does not match and the name survives untouched.
111
+ const masked = stem.replace(HASH_SUFFIX, (match) => `${match[0]}#`);
112
+ return `${masked}${ext}`;
113
+ }
114
+ /**
115
+ * The same file has a different absolute path in every environment it
116
+ * runs in — a developer's home directory, the CI runner's workspace, the
117
+ * container's /app — so keeping it would fragment one bug into one group
118
+ * per place it was seen. `src/` is the one directory name this monorepo's
119
+ * own code always sits under, so anything from there on is kept in full.
120
+ * Failing that (a frame with no recognisable project root, e.g. a bare
121
+ * dependency-free script), the last two path segments are kept: enough to
122
+ * tell apart two files that happen to share a name, without the
123
+ * environment-specific parent directories.
124
+ */
125
+ function stripAbsolutePrefix(path) {
126
+ const marker = '/src/';
127
+ const markerIndex = path.indexOf(marker);
128
+ if (markerIndex !== -1)
129
+ return path.slice(markerIndex + 1);
130
+ if (path.startsWith('src/'))
131
+ return path;
132
+ const segments = path.split('/').filter((segment) => segment.length > 0);
133
+ return segments.slice(-2).join('/');
134
+ }
135
+ const FRAME_LINE = /^at /;
136
+ function parseFrames(stack) {
137
+ if (!stack)
138
+ return [];
139
+ return stack
140
+ .split('\n')
141
+ .map((line) => line.trim())
142
+ .filter((line) => FRAME_LINE.test(line));
143
+ }
144
+ /**
145
+ * A message differs by the one thing that made this particular occurrence
146
+ * unique — a user id, a filename, a value that failed validation — so a
147
+ * fallback that used the raw message would give one group per occurrence,
148
+ * which is exactly the firehose grouping exists to prevent. Digits and
149
+ * quoted substrings are the two shapes that carry that kind of unique data
150
+ * in practice, so both are masked before hashing: "user 4821 not found" and
151
+ * "user 9913 not found" become the same "user # not found".
152
+ */
153
+ function maskMessage(message) {
154
+ return message
155
+ .replace(/"[^"]*"/g, '"…"')
156
+ .replace(/'[^']*'/g, "'…'")
157
+ .replace(/`[^`]*`/g, '`…`')
158
+ .replace(/\d+/g, '#');
159
+ }
160
+ /**
161
+ * The group an error belongs to: sha256 over the kind and the top five
162
+ * normalised frames, truncated to 32 lower-case hex characters. Five frames
163
+ * because the throw site and its immediate callers are what identifies a
164
+ * bug; frames further up are shared by every request and add nothing but
165
+ * noise a coincidental match in them could exploit.
166
+ *
167
+ * Falls back to `kind + masked message` when there is no stack at all, or
168
+ * the stack has no line this package recognises as a frame (a non-V8
169
+ * runtime, or a stack someone already stripped).
170
+ */
171
+ export function fingerprint(input) {
172
+ const frames = parseFrames(input.stack).slice(0, 5).map(normaliseFrame);
173
+ const basis = frames.length > 0
174
+ ? `${input.kind}\n${frames.join('\n')}`
175
+ : `${input.kind}\n${maskMessage(input.message)}`;
176
+ return createHash('sha256').update(basis).digest('hex').slice(0, 32);
177
+ }
178
+ /**
179
+ * The class name a `kind` column and a fingerprint are built from —
180
+ * `TypeError`, `ZodError` — bounded so a pathological name cannot blow out
181
+ * the 512-character message column it eventually contributes to, and safe
182
+ * for the throws JavaScript actually allows: a string, `null`, an object
183
+ * with no prototype at all. None of those may throw out of this function;
184
+ * an error reporter that itself throws while reporting an error is the one
185
+ * failure mode worse than not reporting it.
186
+ */
187
+ export function errorKind(error) {
188
+ if (error instanceof Error) {
189
+ return bound(error.constructor?.name || error.name || 'Error');
190
+ }
191
+ if (error === null)
192
+ return 'null';
193
+ if (error === undefined)
194
+ return 'undefined';
195
+ if (Array.isArray(error))
196
+ return 'array';
197
+ if (typeof error === 'object') {
198
+ // Object.create(null) has no `.constructor` at all; a plain `{}` has one
199
+ // whose name is the unhelpful "Object". Either way there is no class
200
+ // name worth keeping, so it is grouped as a plain object rather than
201
+ // risking a crash reading a property that may not exist.
202
+ const ctorName = error.constructor?.name;
203
+ return bound(ctorName && ctorName !== 'Object' ? ctorName : 'object');
204
+ }
205
+ // string, number, boolean, symbol, bigint, function.
206
+ return bound(typeof error);
207
+ }
208
+ function bound(name) {
209
+ const trimmed = name.trim();
210
+ return trimmed.length > 0 ? trimmed.slice(0, 100) : 'Error';
211
+ }
@@ -0,0 +1,19 @@
1
+ import type { ErrorState } from '../schema.js';
2
+ import type { ReportingErrorRow } from '../tables.js';
3
+ import type { Actor, Db, ErrorsPage, ErrorsPageOptions } from '../types.js';
4
+ /**
5
+ * Newest first, keyset-paged on `(last_seen_at, fingerprint)`, the table's
6
+ * index. Every filter is an equality on an indexed column. No permission is
7
+ * applied here: the host gates, and this returns rows as stored.
8
+ */
9
+ export declare function errorsPage(db: Db, opts?: ErrorsPageOptions): Promise<ErrorsPage>;
10
+ /** One row by fingerprint, or null when it is unknown. */
11
+ export declare function errorDetail(db: Db, fingerprint: string): Promise<ReportingErrorRow | null>;
12
+ /**
13
+ * Moves an error's state. 'resolved' and 'ignored' stamp `resolved_at` and
14
+ * `resolved_by` with this instant and who did it; moving back to 'open'
15
+ * clears both, never one alone — `reporting_errors_resolved_pair_check`
16
+ * (0003_errors.sql) refuses a row with only one of the pair set. Returns the
17
+ * updated row, or null when the fingerprint is unknown.
18
+ */
19
+ export declare function setErrorState(db: Db, fingerprint: string, state: ErrorState, by: Actor): Promise<ReportingErrorRow | null>;
@@ -0,0 +1,66 @@
1
+ import { and, desc, eq, lt, or, sql } from 'drizzle-orm';
2
+ import { reportingErrors } from '../tables.js';
3
+ /**
4
+ * Newest first, keyset-paged on `(last_seen_at, fingerprint)`, the table's
5
+ * index. Every filter is an equality on an indexed column. No permission is
6
+ * applied here: the host gates, and this returns rows as stored.
7
+ */
8
+ export async function errorsPage(db, opts = {}) {
9
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
10
+ const conditions = [];
11
+ if (opts.site)
12
+ conditions.push(eq(reportingErrors.site, opts.site));
13
+ if (opts.state)
14
+ conditions.push(eq(reportingErrors.state, opts.state));
15
+ if (opts.runtime)
16
+ conditions.push(eq(reportingErrors.runtime, opts.runtime));
17
+ if (opts.after) {
18
+ const { lastSeenAt, fingerprint } = opts.after;
19
+ const keyset = or(lt(reportingErrors.lastSeenAt, lastSeenAt), and(eq(reportingErrors.lastSeenAt, lastSeenAt), lt(reportingErrors.fingerprint, fingerprint)));
20
+ if (keyset)
21
+ conditions.push(keyset);
22
+ }
23
+ const rows = await db
24
+ .select()
25
+ .from(reportingErrors)
26
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
27
+ .orderBy(desc(reportingErrors.lastSeenAt), desc(reportingErrors.fingerprint))
28
+ .limit(limit + 1);
29
+ const items = rows.slice(0, limit);
30
+ const last = items.at(-1);
31
+ return {
32
+ items,
33
+ next: rows.length > limit && last
34
+ ? { lastSeenAt: last.lastSeenAt, fingerprint: last.fingerprint }
35
+ : null,
36
+ };
37
+ }
38
+ /** One row by fingerprint, or null when it is unknown. */
39
+ export async function errorDetail(db, fingerprint) {
40
+ const rows = await db
41
+ .select()
42
+ .from(reportingErrors)
43
+ .where(eq(reportingErrors.fingerprint, fingerprint))
44
+ .limit(1);
45
+ return rows[0] ?? null;
46
+ }
47
+ /**
48
+ * Moves an error's state. 'resolved' and 'ignored' stamp `resolved_at` and
49
+ * `resolved_by` with this instant and who did it; moving back to 'open'
50
+ * clears both, never one alone — `reporting_errors_resolved_pair_check`
51
+ * (0003_errors.sql) refuses a row with only one of the pair set. Returns the
52
+ * updated row, or null when the fingerprint is unknown.
53
+ */
54
+ export async function setErrorState(db, fingerprint, state, by) {
55
+ const closing = state === 'resolved' || state === 'ignored';
56
+ const rows = await db
57
+ .update(reportingErrors)
58
+ .set({
59
+ state,
60
+ resolvedAt: closing ? sql `now()` : null,
61
+ resolvedBy: closing ? `${by.class}:${by.id}` : null,
62
+ })
63
+ .where(eq(reportingErrors.fingerprint, fingerprint))
64
+ .returning();
65
+ return rows[0] ?? null;
66
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Keeping the wrapping keys out of anything this package captures.
3
+ *
4
+ * **Why this is worth a file.** The store's whole promise is that a database
5
+ * dump is ciphertext, and that promise has exactly one premise: the wrapping
6
+ * key is not where the ciphertext is. An error report is the most likely way
7
+ * for it to end up somewhere else — a crash during boot with the environment
8
+ * attached, a configuration error whose message quotes the value it could not
9
+ * parse, a breadcrumb from a library that logs what it was given. None of
10
+ * those is a bug in this module; all of them would hand the key to whoever
11
+ * can read the error.
12
+ *
13
+ * So the rule is stated once, here, and applied on the capture path rather
14
+ * than trusted to every path in. `scrub` walks a structure and replaces any
15
+ * exact occurrence of a held key with a marker; `redactText` does the same
16
+ * for the plain-string case — a captured `message` or `stack` — without
17
+ * walking a structure that does not exist yet.
18
+ *
19
+ * **It cannot cover stored values**, and does not pretend to: those are not in
20
+ * the environment, so there is nothing to enumerate. What protects them is
21
+ * that no code path writes one anywhere but the response that asked for it.
22
+ *
23
+ * No `server-only`: the edge runtime captures errors too, and this reaches
24
+ * nothing but its arguments and `process.env`.
25
+ */
26
+ export declare const REDACTED = "[redacted: wrapping key]";
27
+ /**
28
+ * The exact strings that must never appear in anything sent anywhere: both
29
+ * environment variables in full, and each key's base64 half on its own, since
30
+ * a parser that split on the colon would report only that part.
31
+ */
32
+ export declare function heldSecrets(env: Readonly<Record<string, string | undefined>>): string[];
33
+ /**
34
+ * The string case alone: a captured `message` and `stack` are strings before
35
+ * they are anything else, so the capture path scrubs them directly rather
36
+ * than wrapping each in an object just to hand it to `scrub`.
37
+ */
38
+ export declare function redactText(text: string, secrets: readonly string[]): string;
39
+ /**
40
+ * The same structure with every held secret replaced.
41
+ *
42
+ * Walks rather than serialising and string-replacing, so the shape a caller
43
+ * expects survives. Cycles are tracked because an error object graph has them
44
+ * and a scrubber that hangs is worse than one that missed something.
45
+ */
46
+ export declare function scrub<T>(value: T, secrets: readonly string[]): T;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Keeping the wrapping keys out of anything this package captures.
3
+ *
4
+ * **Why this is worth a file.** The store's whole promise is that a database
5
+ * dump is ciphertext, and that promise has exactly one premise: the wrapping
6
+ * key is not where the ciphertext is. An error report is the most likely way
7
+ * for it to end up somewhere else — a crash during boot with the environment
8
+ * attached, a configuration error whose message quotes the value it could not
9
+ * parse, a breadcrumb from a library that logs what it was given. None of
10
+ * those is a bug in this module; all of them would hand the key to whoever
11
+ * can read the error.
12
+ *
13
+ * So the rule is stated once, here, and applied on the capture path rather
14
+ * than trusted to every path in. `scrub` walks a structure and replaces any
15
+ * exact occurrence of a held key with a marker; `redactText` does the same
16
+ * for the plain-string case — a captured `message` or `stack` — without
17
+ * walking a structure that does not exist yet.
18
+ *
19
+ * **It cannot cover stored values**, and does not pretend to: those are not in
20
+ * the environment, so there is nothing to enumerate. What protects them is
21
+ * that no code path writes one anywhere but the response that asked for it.
22
+ *
23
+ * No `server-only`: the edge runtime captures errors too, and this reaches
24
+ * nothing but its arguments and `process.env`.
25
+ */
26
+ export const REDACTED = '[redacted: wrapping key]';
27
+ /**
28
+ * The exact strings that must never appear in anything sent anywhere: both
29
+ * environment variables in full, and each key's base64 half on its own, since
30
+ * a parser that split on the colon would report only that part.
31
+ */
32
+ export function heldSecrets(env) {
33
+ const specs = [env.KEYSTORE_KEK ?? '', ...(env.KEYSTORE_KEK_PREVIOUS ?? '').split(',')]
34
+ .map((spec) => spec.trim())
35
+ .filter((spec) => spec.length > 0);
36
+ const secrets = new Set();
37
+ for (const spec of specs) {
38
+ secrets.add(spec);
39
+ const separator = spec.indexOf(':');
40
+ // A short tail is not worth replacing: it would be a common substring
41
+ // rather than a secret, and a scrubber that rewrites ordinary text is one
42
+ // somebody turns off.
43
+ if (separator > 0 && spec.length - separator > 16)
44
+ secrets.add(spec.slice(separator + 1));
45
+ }
46
+ return [...secrets];
47
+ }
48
+ /**
49
+ * The string case alone: a captured `message` and `stack` are strings before
50
+ * they are anything else, so the capture path scrubs them directly rather
51
+ * than wrapping each in an object just to hand it to `scrub`.
52
+ */
53
+ export function redactText(text, secrets) {
54
+ let out = text;
55
+ for (const secret of secrets) {
56
+ if (out.includes(secret))
57
+ out = out.split(secret).join(REDACTED);
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * The same structure with every held secret replaced.
63
+ *
64
+ * Walks rather than serialising and string-replacing, so the shape a caller
65
+ * expects survives. Cycles are tracked because an error object graph has them
66
+ * and a scrubber that hangs is worse than one that missed something.
67
+ */
68
+ export function scrub(value, secrets) {
69
+ if (secrets.length === 0)
70
+ return value;
71
+ return walk(value, secrets, new WeakMap());
72
+ }
73
+ function walk(value, secrets, seen) {
74
+ if (typeof value === 'string')
75
+ return redactText(value, secrets);
76
+ if (value === null || typeof value !== 'object')
77
+ return value;
78
+ const existing = seen.get(value);
79
+ if (existing !== undefined)
80
+ return existing;
81
+ if (Array.isArray(value)) {
82
+ const copy = [];
83
+ seen.set(value, copy);
84
+ for (const item of value)
85
+ copy.push(walk(item, secrets, seen));
86
+ return copy;
87
+ }
88
+ const copy = {};
89
+ seen.set(value, copy);
90
+ for (const [key, item] of Object.entries(value))
91
+ copy[key] = walk(item, secrets, seen);
92
+ return copy;
93
+ }
@@ -0,0 +1,16 @@
1
+ import type { Task } from '../housekeeping/index.js';
2
+ import type { Db } from '../types.js';
3
+ /**
4
+ * Deletes resolved/ignored error rows past the operator's window, up to
5
+ * twenty batches of 5,000 per run, each in its own statement with a bounded
6
+ * timeout, renewing the lease between batches. `reporting_prune_errors`
7
+ * (0003_errors.sql) does the clamping and, critically, never touches a row
8
+ * still in state 'open' regardless of age.
9
+ *
10
+ * Reuses `events.retention_days` rather than adding an `errors.*` setting: a
11
+ * new setting means a migration to reporting_settings and an operator
12
+ * control nobody asked for. One retention window for the whole operational
13
+ * log (events and the errors grouped from them) is the simpler default.
14
+ */
15
+ export declare const pruneErrors: Task;
16
+ export declare function pruneErrorsBatch(db: Db, days: number, batch: number): Promise<number>;
@@ -0,0 +1,50 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { rowsOf } from '../housekeeping/tasks.js';
3
+ const HOUR = 60 * 60 * 1000;
4
+ /**
5
+ * Deletes resolved/ignored error rows past the operator's window, up to
6
+ * twenty batches of 5,000 per run, each in its own statement with a bounded
7
+ * timeout, renewing the lease between batches. `reporting_prune_errors`
8
+ * (0003_errors.sql) does the clamping and, critically, never touches a row
9
+ * still in state 'open' regardless of age.
10
+ *
11
+ * Reuses `events.retention_days` rather than adding an `errors.*` setting: a
12
+ * new setting means a migration to reporting_settings and an operator
13
+ * control nobody asked for. One retention window for the whole operational
14
+ * log (events and the errors grouped from them) is the simpler default.
15
+ */
16
+ export const pruneErrors = {
17
+ name: 'reporting.prune_errors',
18
+ every: HOUR,
19
+ lease: 10 * 60 * 1000,
20
+ retry: 15 * 60 * 1000,
21
+ async run(ctx) {
22
+ const days = ctx.settings['events.retention_days'];
23
+ let total = 0;
24
+ for (let i = 0; i < 20; i += 1) {
25
+ if (ctx.now().getTime() > ctx.deadline.getTime() - 30_000)
26
+ break;
27
+ const n = await pruneErrorsBatch(ctx.db, days, 5000);
28
+ total += n;
29
+ if (n < 5000)
30
+ break;
31
+ if (!(await ctx.renew()))
32
+ break;
33
+ }
34
+ if (total > 0) {
35
+ ctx.reporting.event({
36
+ kind: 'reporting.pruned_errors',
37
+ message: `pruned ${total} error(s) older than ${days} days`,
38
+ data: { rows: total, days },
39
+ });
40
+ }
41
+ },
42
+ };
43
+ export async function pruneErrorsBatch(db, days, batch) {
44
+ return db.transaction(async (tx) => {
45
+ await tx.execute(sql `set local statement_timeout = '30s'`);
46
+ const result = await tx.execute(sql `select reporting_prune_errors(make_interval(days => ${days}::int), ${batch}::int) as n`);
47
+ const [row] = rowsOf(result);
48
+ return Number(row?.n ?? 0);
49
+ });
50
+ }
@@ -1,4 +1,5 @@
1
1
  import { alertRow } from '../alerts.js';
2
+ import { createCapture } from '../errors/capture.js';
2
3
  import { eventInputSchema } from '../schema.js';
3
4
  import { DEFAULT_SETTINGS, RETENTION_BOUNDS } from '../settings.js';
4
5
  import { tables } from '../tables.js';
@@ -11,6 +12,16 @@ export function createFakeReporting(opts = {}) {
11
12
  const tracked = [];
12
13
  let settings = { ...DEFAULT_SETTINGS };
13
14
  let id = 0;
15
+ // No group table to fake, so the upsert never runs (`defer` is a no-op);
16
+ // the timeline row through `fake.event` is what a host's test asserts on.
17
+ const capture = createCapture({
18
+ db: undefined,
19
+ log,
20
+ site,
21
+ now: () => new Date(),
22
+ defer: () => { },
23
+ event: (input) => fake.event(input),
24
+ });
14
25
  const fake = {
15
26
  site,
16
27
  log,
@@ -43,6 +54,7 @@ export function createFakeReporting(opts = {}) {
43
54
  fake.event(alertRow(finding));
44
55
  alerts.push(finding);
45
56
  },
57
+ captureError: (error, context) => capture(error, context),
46
58
  async flush() { },
47
59
  events: {
48
60
  async page(o = {}) {
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ import { alertRow } from './alerts.js';
2
2
  import { analyticsRecent, analyticsSeries, analyticsWeekly } from './analytics/reader.js';
3
3
  import { ANALYTICS_NAME_PATTERN, normalisePath, propsSchema } from './analytics/schema.js';
4
4
  import { insertAnalytics } from './analytics/write.js';
5
+ import { createCapture } from './errors/capture.js';
5
6
  import { eventsPage } from './reader.js';
6
7
  import { siteSchema } from './schema.js';
7
8
  import { getSettings, setSettings } from './settings.js';
@@ -29,25 +30,38 @@ export function createReporting(options) {
29
30
  const site = siteSchema.parse(options.site);
30
31
  const now = options.now ?? (() => new Date());
31
32
  const mode = options.mode ?? modeFromEnv();
33
+ const defer = options.defer ??
34
+ ((fn) => {
35
+ setTimeout(() => void fn(), 0);
36
+ });
32
37
  const writer = new Writer({
33
38
  db: options.db,
34
39
  log: options.log,
35
40
  site,
36
41
  mode,
37
42
  now,
38
- defer: options.defer ??
39
- ((fn) => {
40
- setTimeout(() => void fn(), 0);
41
- }),
43
+ defer,
42
44
  queueLimit: options.queueLimit ?? 1000,
43
45
  batchSize: options.batchSize ?? 500,
44
46
  flushEveryMs: options.flushEveryMs ?? 1000,
45
47
  });
48
+ // Same db, log, defer, now and site as the writer: one instance, one
49
+ // upsert path, no second connection or timer to keep in sync.
50
+ const capture = createCapture({
51
+ db: options.db,
52
+ log: options.log,
53
+ site,
54
+ now,
55
+ defer,
56
+ event: (input) => writer.event(input),
57
+ release: process.env.REPORTING_RELEASE ?? null,
58
+ });
46
59
  const reporting = {
47
60
  site,
48
61
  log: options.log,
49
62
  tables,
50
63
  event: (input) => writer.event(input),
64
+ captureError: (error, context) => capture(error, context),
51
65
  alert(finding) {
52
66
  writer.event(alertRow(finding));
53
67
  if (!options.onAlert)
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The token bucket both public ingest routes share.
3
+ *
4
+ * `./next`'s analytics collector and its client-error collector are the only
5
+ * two routes in this package a host mounts unauthenticated, and both write to
6
+ * the database on a stranger's say-so. They had one limiter between them: the
7
+ * analytics one. The error route went without until it was noticed that
8
+ * `reporting_errors` never prunes an open row (migrations/0003_errors.sql),
9
+ * so an unbounded public write lands in a table nothing ages out — the two
10
+ * decisions are safe apart and not together.
11
+ *
12
+ * Per process, not per deploy: several containers each keep their own, so the
13
+ * real ceiling is this times the container count. Enough to bound abuse,
14
+ * never to meter.
15
+ */
16
+ export declare class Buckets {
17
+ private readonly perMinute;
18
+ private readonly now;
19
+ private readonly buckets;
20
+ constructor(perMinute: number, now: () => Date);
21
+ take(key: string, n: number): boolean;
22
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The token bucket both public ingest routes share.
3
+ *
4
+ * `./next`'s analytics collector and its client-error collector are the only
5
+ * two routes in this package a host mounts unauthenticated, and both write to
6
+ * the database on a stranger's say-so. They had one limiter between them: the
7
+ * analytics one. The error route went without until it was noticed that
8
+ * `reporting_errors` never prunes an open row (migrations/0003_errors.sql),
9
+ * so an unbounded public write lands in a table nothing ages out — the two
10
+ * decisions are safe apart and not together.
11
+ *
12
+ * Per process, not per deploy: several containers each keep their own, so the
13
+ * real ceiling is this times the container count. Enough to bound abuse,
14
+ * never to meter.
15
+ */
16
+ export class Buckets {
17
+ perMinute;
18
+ now;
19
+ buckets = new Map();
20
+ constructor(perMinute, now) {
21
+ this.perMinute = perMinute;
22
+ this.now = now;
23
+ }
24
+ take(key, n) {
25
+ const t = this.now().getTime();
26
+ const b = this.buckets.get(key) ?? { tokens: this.perMinute, at: t };
27
+ b.tokens = Math.min(this.perMinute, b.tokens + ((t - b.at) / 60_000) * this.perMinute);
28
+ b.at = t;
29
+ if (b.tokens < n) {
30
+ this.buckets.set(key, b);
31
+ return false;
32
+ }
33
+ b.tokens -= n;
34
+ this.buckets.set(key, b);
35
+ if (this.buckets.size > 10_000)
36
+ this.buckets.clear();
37
+ return true;
38
+ }
39
+ }