@wtfalch/reporting 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +56 -0
  2. package/dist/analytics/collector.d.ts +40 -0
  3. package/dist/analytics/collector.js +146 -0
  4. package/dist/analytics/reader.d.ts +63 -0
  5. package/dist/analytics/reader.js +89 -0
  6. package/dist/analytics/schema.d.ts +59 -0
  7. package/dist/analytics/schema.js +126 -0
  8. package/dist/analytics/tasks.d.ts +32 -0
  9. package/dist/analytics/tasks.js +199 -0
  10. package/dist/analytics/write.d.ts +19 -0
  11. package/dist/analytics/write.js +20 -0
  12. package/dist/browser/index.d.ts +53 -0
  13. package/dist/browser/index.js +330 -0
  14. package/dist/errors/capture.d.ts +52 -0
  15. package/dist/errors/capture.js +187 -0
  16. package/dist/errors/fingerprint.d.ts +36 -0
  17. package/dist/errors/fingerprint.js +211 -0
  18. package/dist/errors/reader.d.ts +19 -0
  19. package/dist/errors/reader.js +66 -0
  20. package/dist/errors/redact.d.ts +46 -0
  21. package/dist/errors/redact.js +93 -0
  22. package/dist/errors/tasks.d.ts +16 -0
  23. package/dist/errors/tasks.js +50 -0
  24. package/dist/fake/index.d.ts +5 -1
  25. package/dist/fake/index.js +28 -0
  26. package/dist/housekeeping/index.d.ts +5 -0
  27. package/dist/housekeeping/index.js +18 -6
  28. package/dist/index.d.ts +5 -1
  29. package/dist/index.js +61 -5
  30. package/dist/ingest-limit.d.ts +22 -0
  31. package/dist/ingest-limit.js +39 -0
  32. package/dist/migrations/0002_analytics.sql +350 -0
  33. package/dist/migrations/0003_errors.sql +118 -0
  34. package/dist/next/client-errors.d.ts +46 -0
  35. package/dist/next/client-errors.js +165 -0
  36. package/dist/next/index.d.ts +55 -0
  37. package/dist/next/index.js +72 -0
  38. package/dist/react/charts.d.ts +24 -0
  39. package/dist/react/charts.js +45 -0
  40. package/dist/react/errors.d.ts +12 -0
  41. package/dist/react/errors.js +17 -0
  42. package/dist/react/index.d.ts +21 -0
  43. package/dist/react/index.js +52 -0
  44. package/dist/schema.d.ts +8 -0
  45. package/dist/schema.js +8 -0
  46. package/dist/settings.d.ts +1 -0
  47. package/dist/settings.js +15 -8
  48. package/dist/tables.d.ts +2287 -253
  49. package/dist/tables.js +111 -1
  50. package/dist/types.d.ts +70 -2
  51. package/package.json +19 -1
@@ -0,0 +1,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
+ }