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