@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.
- package/dist/analytics/collector.js +1 -25
- package/dist/browser/index.d.ts +10 -0
- package/dist/browser/index.js +127 -0
- package/dist/errors/capture.d.ts +52 -0
- package/dist/errors/capture.js +187 -0
- package/dist/errors/fingerprint.d.ts +36 -0
- package/dist/errors/fingerprint.js +211 -0
- package/dist/errors/reader.d.ts +19 -0
- package/dist/errors/reader.js +66 -0
- package/dist/errors/redact.d.ts +46 -0
- package/dist/errors/redact.js +93 -0
- package/dist/errors/tasks.d.ts +16 -0
- package/dist/errors/tasks.js +50 -0
- package/dist/fake/index.js +12 -0
- package/dist/index.js +18 -4
- package/dist/ingest-limit.d.ts +22 -0
- package/dist/ingest-limit.js +39 -0
- package/dist/migrations/0003_errors.sql +118 -0
- package/dist/next/client-errors.d.ts +46 -0
- package/dist/next/client-errors.js +165 -0
- package/dist/next/index.d.ts +39 -0
- package/dist/next/index.js +48 -0
- package/dist/react/errors.d.ts +12 -0
- package/dist/react/errors.js +17 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +8 -0
- package/dist/tables.d.ts +526 -0
- package/dist/tables.js +24 -0
- package/dist/types.d.ts +33 -2
- package/package.json +5 -1
|
@@ -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
|
+
}
|
package/dist/fake/index.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|