@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.
- package/README.md +56 -0
- package/dist/analytics/collector.d.ts +40 -0
- package/dist/analytics/collector.js +146 -0
- package/dist/analytics/reader.d.ts +63 -0
- package/dist/analytics/reader.js +89 -0
- package/dist/analytics/schema.d.ts +59 -0
- package/dist/analytics/schema.js +126 -0
- package/dist/analytics/tasks.d.ts +32 -0
- package/dist/analytics/tasks.js +199 -0
- package/dist/analytics/write.d.ts +19 -0
- package/dist/analytics/write.js +20 -0
- package/dist/browser/index.d.ts +53 -0
- package/dist/browser/index.js +330 -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.d.ts +5 -1
- package/dist/fake/index.js +28 -0
- package/dist/housekeeping/index.d.ts +5 -0
- package/dist/housekeeping/index.js +18 -6
- package/dist/index.d.ts +5 -1
- package/dist/index.js +61 -5
- package/dist/ingest-limit.d.ts +22 -0
- package/dist/ingest-limit.js +39 -0
- package/dist/migrations/0002_analytics.sql +350 -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 +55 -0
- package/dist/next/index.js +72 -0
- package/dist/react/charts.d.ts +24 -0
- package/dist/react/charts.js +45 -0
- package/dist/react/errors.d.ts +12 -0
- package/dist/react/errors.js +17 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +52 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +8 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.js +15 -8
- package/dist/tables.d.ts +2287 -253
- package/dist/tables.js +111 -1
- package/dist/types.d.ts +70 -2
- package/package.json +19 -1
|
@@ -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;
|
|
@@ -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
|
+
}
|