@wtfalch/reporting 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics/collector.js +1 -25
- package/dist/browser/index.d.ts +10 -0
- package/dist/browser/index.js +127 -0
- package/dist/errors/capture.d.ts +52 -0
- package/dist/errors/capture.js +187 -0
- package/dist/errors/fingerprint.d.ts +36 -0
- package/dist/errors/fingerprint.js +211 -0
- package/dist/errors/reader.d.ts +19 -0
- package/dist/errors/reader.js +66 -0
- package/dist/errors/redact.d.ts +46 -0
- package/dist/errors/redact.js +93 -0
- package/dist/errors/tasks.d.ts +16 -0
- package/dist/errors/tasks.js +50 -0
- package/dist/fake/index.js +12 -0
- package/dist/index.js +18 -4
- package/dist/ingest-limit.d.ts +22 -0
- package/dist/ingest-limit.js +39 -0
- package/dist/migrations/0003_errors.sql +118 -0
- package/dist/next/client-errors.d.ts +46 -0
- package/dist/next/client-errors.js +165 -0
- package/dist/next/index.d.ts +39 -0
- package/dist/next/index.js +48 -0
- package/dist/react/errors.d.ts +12 -0
- package/dist/react/errors.js +17 -0
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +8 -0
- package/dist/tables.d.ts +526 -0
- package/dist/tables.js +24 -0
- package/dist/types.d.ts +33 -2
- package/package.json +5 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
-- @wtfalch/reporting 0.3.0: the error group table. Mirrors src/tables.ts.
|
|
2
|
+
--
|
|
3
|
+
-- One row per fingerprint (docs/plans/errors.md): an uncaught exception's
|
|
4
|
+
-- stack groups repeats into a count and a last-seen time instead of a
|
|
5
|
+
-- firehose of identical reporting_events rows. Every statement is
|
|
6
|
+
-- idempotent. Applied by the host's own migrate script after the host
|
|
7
|
+
-- copies this file into its drizzle/ directory as the next number
|
|
8
|
+
-- (reporting-migrations); never edited there.
|
|
9
|
+
--
|
|
10
|
+
-- Estate-shaped: the DO block at the end assumes the runtime-role convention
|
|
11
|
+
-- (a role named <database>_rt that owns nothing and serves the app). A host
|
|
12
|
+
-- without that role gets the table and no revokes take effect, which the
|
|
13
|
+
-- package README says plainly.
|
|
14
|
+
|
|
15
|
+
create table if not exists reporting_errors (
|
|
16
|
+
fingerprint text primary key,
|
|
17
|
+
site text not null,
|
|
18
|
+
kind text not null,
|
|
19
|
+
message text not null,
|
|
20
|
+
stack text,
|
|
21
|
+
runtime text not null,
|
|
22
|
+
release text,
|
|
23
|
+
first_seen_at timestamptz not null,
|
|
24
|
+
last_seen_at timestamptz not null,
|
|
25
|
+
occurrences bigint not null default 1,
|
|
26
|
+
state text not null default 'open',
|
|
27
|
+
resolved_at timestamptz,
|
|
28
|
+
resolved_by text,
|
|
29
|
+
tenant_id uuid,
|
|
30
|
+
request_id text,
|
|
31
|
+
-- The digest that groups occurrences (docs/plans/errors.md: sha256 of the
|
|
32
|
+
-- kind and the top normalised frames, truncated to 32 hex). Lower case
|
|
33
|
+
-- only: two casings of the same digest must not become two groups.
|
|
34
|
+
constraint reporting_errors_fingerprint_check check (fingerprint ~ '^[0-9a-f]{32}$'),
|
|
35
|
+
constraint reporting_errors_site_check check (site ~ '^[a-z][a-z0-9-]{0,63}$'),
|
|
36
|
+
constraint reporting_errors_message_check check (length(message) between 1 and 512),
|
|
37
|
+
-- Redacted before it is queued (src/schema.ts); this bound is a second net
|
|
38
|
+
-- against a pathological stack, not the redaction itself.
|
|
39
|
+
constraint reporting_errors_stack_check check (stack is null or length(stack) <= 16384),
|
|
40
|
+
constraint reporting_errors_runtime_check check (runtime in ('server', 'edge', 'browser')),
|
|
41
|
+
constraint reporting_errors_state_check check (state in ('open', 'resolved', 'ignored')),
|
|
42
|
+
-- The row exists because one occurrence created it; it can never describe
|
|
43
|
+
-- zero.
|
|
44
|
+
constraint reporting_errors_occurrences_check check (occurrences > 0),
|
|
45
|
+
-- The upsert only ever moves this forward. If it moved backward, the count
|
|
46
|
+
-- and the window it claims to describe would disagree.
|
|
47
|
+
constraint reporting_errors_seen_check check (last_seen_at >= first_seen_at),
|
|
48
|
+
-- Same pairing as reporting_events_actor_pair_check: who resolved it is
|
|
49
|
+
-- meaningless without when, and vice versa.
|
|
50
|
+
constraint reporting_errors_resolved_pair_check check ((resolved_at is null) = (resolved_by is null))
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
comment on column reporting_errors.tenant_id is
|
|
54
|
+
'From the most recent sample. No foreign key and no cascade, same as reporting_events.actor_id: the group outlives the request, and erasure must not delete the count.';
|
|
55
|
+
comment on column reporting_errors.request_id is
|
|
56
|
+
'From the most recent sample; overwritten on every occurrence, not a history.';
|
|
57
|
+
|
|
58
|
+
-- The default operator view: this site, still open, newest first. A partial
|
|
59
|
+
-- index because most errors end up resolved and should not cost this one.
|
|
60
|
+
create index if not exists reporting_errors_site_open_idx
|
|
61
|
+
on reporting_errors (site, last_seen_at desc) where state = 'open';
|
|
62
|
+
-- The same page's state filter, switched to resolved or ignored.
|
|
63
|
+
create index if not exists reporting_errors_state_idx
|
|
64
|
+
on reporting_errors (state, last_seen_at desc);
|
|
65
|
+
|
|
66
|
+
-- The one way rows leave reporting_errors on a timer. Unlike
|
|
67
|
+
-- reporting_prune_events, eligibility is state AND age, not age alone: only
|
|
68
|
+
-- 'resolved' or 'ignored' rows past the window are candidates. A row still
|
|
69
|
+
-- 'open' is NEVER a candidate here, no matter how old — an unresolved error
|
|
70
|
+
-- vanishing on a timer is the failure mode that makes an error tracker
|
|
71
|
+
-- untrustworthy, so the only way one leaves this table is an operator
|
|
72
|
+
-- resolving or ignoring it first, and it ageing out after. Same clamp and
|
|
73
|
+
-- batching shape as reporting_prune_events: the window is clamped to between
|
|
74
|
+
-- seven and four hundred days inside the function, and the batch is capped
|
|
75
|
+
-- at 5,000 so no statement holds a lock for long.
|
|
76
|
+
create or replace function reporting_prune_errors(retention interval, batch integer)
|
|
77
|
+
returns integer
|
|
78
|
+
language plpgsql
|
|
79
|
+
security definer
|
|
80
|
+
set search_path = pg_catalog, public
|
|
81
|
+
as $$
|
|
82
|
+
declare
|
|
83
|
+
w interval := greatest(interval '7 days', least(coalesce(retention, interval '30 days'), interval '400 days'));
|
|
84
|
+
b integer := greatest(1, least(coalesce(batch, 5000), 5000));
|
|
85
|
+
n integer;
|
|
86
|
+
begin
|
|
87
|
+
with victims as (
|
|
88
|
+
select fingerprint from reporting_errors
|
|
89
|
+
where state in ('resolved', 'ignored')
|
|
90
|
+
and last_seen_at < now() - w
|
|
91
|
+
order by last_seen_at, fingerprint
|
|
92
|
+
limit b
|
|
93
|
+
)
|
|
94
|
+
delete from reporting_errors e using victims v where e.fingerprint = v.fingerprint;
|
|
95
|
+
get diagnostics n = row_count;
|
|
96
|
+
return n;
|
|
97
|
+
end
|
|
98
|
+
$$;
|
|
99
|
+
revoke all on function reporting_prune_errors(interval, integer) from public;
|
|
100
|
+
|
|
101
|
+
comment on function reporting_prune_errors(interval, integer) is
|
|
102
|
+
'Deletes only resolved or ignored rows past the retention window. An open row is never a candidate, regardless of age: an unresolved error disappearing on a timer is the one failure mode that makes an error tracker untrustworthy. It leaves this table only after an operator resolves or ignores it, and then ages out.';
|
|
103
|
+
|
|
104
|
+
-- The runtime role keeps SELECT, INSERT and UPDATE: the capture path upserts
|
|
105
|
+
-- on repeat occurrences and an operator moves state through this table.
|
|
106
|
+
-- Nothing that deletes, the same as reporting_tasks and reporting_settings;
|
|
107
|
+
-- reporting_prune_errors runs as the function owner, not the runtime role,
|
|
108
|
+
-- so the grant below is execute only, same shape as reporting_prune_events.
|
|
109
|
+
do $$
|
|
110
|
+
declare
|
|
111
|
+
rt text := current_database() || '_rt';
|
|
112
|
+
begin
|
|
113
|
+
if exists (select 1 from pg_roles where rolname = rt) then
|
|
114
|
+
execute format('revoke delete, truncate on reporting_errors from %I', rt);
|
|
115
|
+
execute format('grant execute on function reporting_prune_errors(interval, integer) to %I', rt);
|
|
116
|
+
end if;
|
|
117
|
+
end
|
|
118
|
+
$$;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Reporting } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The public ingest route for browser-reported errors
|
|
4
|
+
* (docs/plans/errors.md, "Surface"): modelled exactly on the analytics
|
|
5
|
+
* collector (`../analytics/collector.js`) — always 204, the same origin
|
|
6
|
+
* allowance, the same body-size cap — because that ingest problem is
|
|
7
|
+
* already solved there and a second set of limits would be a second set to
|
|
8
|
+
* get wrong.
|
|
9
|
+
*
|
|
10
|
+
* Two things differ from the analytics collector, both because a
|
|
11
|
+
* `Reporting` instance is already bound to one site:
|
|
12
|
+
* 1. There is no `sites` map to route a foreign origin's claim to a
|
|
13
|
+
* different site's table — `origins` below is a flat allow-list, and a
|
|
14
|
+
* batch claiming any other site is dropped, same-origin or not.
|
|
15
|
+
* 2. A capture goes through `reporting.captureError`, not a direct insert,
|
|
16
|
+
* so there is no `now`/`db` to take here: capture.ts already has its own.
|
|
17
|
+
*/
|
|
18
|
+
export interface ClientErrorCollectorOptions {
|
|
19
|
+
readonly reporting: Reporting;
|
|
20
|
+
/** Foreign origins allowed to post errors for this site, beside same-origin. */
|
|
21
|
+
readonly origins?: readonly string[];
|
|
22
|
+
/**
|
|
23
|
+
* The signed-in person, from the host's session, for a same-origin
|
|
24
|
+
* request only — a foreign origin never reads it (addendum A5's rule,
|
|
25
|
+
* same as the analytics collector's `getUserId`).
|
|
26
|
+
*/
|
|
27
|
+
readonly getUserId?: (request: Request) => Promise<string | null>;
|
|
28
|
+
/**
|
|
29
|
+
* The host's tenant lookup for that person. Unlike the analytics
|
|
30
|
+
* collector's `tenantFor`, there is no pathname to hand over: an error
|
|
31
|
+
* batch names no single route the way a page view does.
|
|
32
|
+
*/
|
|
33
|
+
readonly tenantFor?: (userId: string) => Promise<string | null>;
|
|
34
|
+
/**
|
|
35
|
+
* Captures allowed per minute per client address. An unauthenticated route
|
|
36
|
+
* that writes rows needs a ceiling that does not depend on the caller being
|
|
37
|
+
* our own beacon: the browser's 5-distinct/20-total cap is one line of
|
|
38
|
+
* client code away from being ignored, and `reporting_errors` never prunes
|
|
39
|
+
* an open row. Lower than the analytics default because an error is rarer
|
|
40
|
+
* than a page view by design; a client hitting this is already broken.
|
|
41
|
+
*/
|
|
42
|
+
readonly ratePerMinute?: number;
|
|
43
|
+
}
|
|
44
|
+
export declare function createClientErrorCollector(options: ClientErrorCollectorOptions): {
|
|
45
|
+
handle(request: Request): Promise<Response>;
|
|
46
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ANALYTICS_LIMITS } from '../analytics/schema.js';
|
|
4
|
+
import { Buckets } from '../ingest-limit.js';
|
|
5
|
+
import { SITE_PATTERN } from '../schema.js';
|
|
6
|
+
/** At most this many entries of a batch are ever captured; the rest are dropped silently. */
|
|
7
|
+
const BATCH_MAX = 10;
|
|
8
|
+
/**
|
|
9
|
+
* Not `z.strictObject`, unlike the analytics beacon schema: a client that
|
|
10
|
+
* adds `tenantId`, `userId` or `requestId` here must have that field
|
|
11
|
+
* silently stripped, not the whole batch refused. `handle()` below never
|
|
12
|
+
* reads such a field either way — identity and the request id come only
|
|
13
|
+
* from this request itself (`getUserId`/`tenantFor`, and `cf-ray`/a fresh
|
|
14
|
+
* uuid), never from the body. The browser is not a trusted caller.
|
|
15
|
+
*/
|
|
16
|
+
const entrySchema = z.object({
|
|
17
|
+
kind: z.string().min(1),
|
|
18
|
+
message: z.string().min(1),
|
|
19
|
+
stack: z.string().nullish(),
|
|
20
|
+
release: z.string().nullish(),
|
|
21
|
+
});
|
|
22
|
+
const batchSchema = z.object({
|
|
23
|
+
site: z.string().regex(SITE_PATTERN),
|
|
24
|
+
errors: z.array(entrySchema).min(1),
|
|
25
|
+
});
|
|
26
|
+
const NO_CONTENT = () => new Response(null, { status: 204 });
|
|
27
|
+
/**
|
|
28
|
+
* The client's address as the tunnel presents it. `cf-connecting-ip` first
|
|
29
|
+
* because every request to this estate arrives through Cloudflare, then the
|
|
30
|
+
* first hop of `x-forwarded-for`; a header a client sets itself is no worse
|
|
31
|
+
* here than no key at all, since the bucket only ever costs that same client.
|
|
32
|
+
*/
|
|
33
|
+
function clientAddress(request) {
|
|
34
|
+
const cf = request.headers.get('cf-connecting-ip');
|
|
35
|
+
if (cf)
|
|
36
|
+
return cf.trim();
|
|
37
|
+
const xff = request.headers.get('x-forwarded-for');
|
|
38
|
+
return xff ? (xff.split(',')[0] ?? '').trim() || null : null;
|
|
39
|
+
}
|
|
40
|
+
/** `<16 hex>-<colo>`, as Cloudflare emits it — the same pattern `./index.ts` uses; kept here too, rather than imported, to avoid a circular import between the two files. */
|
|
41
|
+
const RAY_PATTERN = /^[0-9a-f]{16}-[A-Z0-9]{3,6}$/;
|
|
42
|
+
function requestIdOf(request) {
|
|
43
|
+
const ray = request.headers.get('cf-ray');
|
|
44
|
+
return ray && RAY_PATTERN.test(ray) ? ray : randomUUID();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Same shape as the collector's own `originFor` (`../analytics/collector.js`,
|
|
48
|
+
* private there): duplicated rather than exported, per this task's scope —
|
|
49
|
+
* it is ~10 lines and the two callers now diverge (no `sites` map here).
|
|
50
|
+
*/
|
|
51
|
+
function originOf(request) {
|
|
52
|
+
const origin = request.headers.get('origin');
|
|
53
|
+
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
|
|
54
|
+
if (!origin)
|
|
55
|
+
return host ? { kind: 'same', origin: `https://${host}` } : null;
|
|
56
|
+
let originHost;
|
|
57
|
+
try {
|
|
58
|
+
originHost = new URL(origin).host;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return { kind: originHost === host ? 'same' : 'foreign', origin };
|
|
64
|
+
}
|
|
65
|
+
function corsFor(response, origin) {
|
|
66
|
+
response.headers.set('access-control-allow-origin', origin);
|
|
67
|
+
response.headers.set('vary', 'origin');
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* An `Error` carrying exactly the client's own message and stack, so
|
|
72
|
+
* `captureError`'s existing redaction and fingerprinting run unchanged on
|
|
73
|
+
* it (errors/capture.ts is not this task's to edit). The class name is
|
|
74
|
+
* never read from this: `context.kind` below overrides it with the
|
|
75
|
+
* client's own `kind`.
|
|
76
|
+
*/
|
|
77
|
+
function toCapturable(entry) {
|
|
78
|
+
const err = new Error(entry.message);
|
|
79
|
+
err.stack = entry.stack ?? undefined;
|
|
80
|
+
return err;
|
|
81
|
+
}
|
|
82
|
+
export function createClientErrorCollector(options) {
|
|
83
|
+
const { reporting } = options;
|
|
84
|
+
const buckets = new Buckets(options.ratePerMinute ?? 60, () => new Date());
|
|
85
|
+
async function handle(request) {
|
|
86
|
+
if (request.method !== 'POST')
|
|
87
|
+
return NO_CONTENT();
|
|
88
|
+
const where = originOf(request);
|
|
89
|
+
if (!where)
|
|
90
|
+
return NO_CONTENT();
|
|
91
|
+
if (where.kind === 'foreign' && !(options.origins ?? []).includes(where.origin)) {
|
|
92
|
+
return NO_CONTENT();
|
|
93
|
+
}
|
|
94
|
+
const length = Number(request.headers.get('content-length') ?? '0');
|
|
95
|
+
if (length > ANALYTICS_LIMITS.bodyBytes)
|
|
96
|
+
return NO_CONTENT();
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = await request.text();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return NO_CONTENT();
|
|
103
|
+
}
|
|
104
|
+
if (text.length > ANALYTICS_LIMITS.bodyBytes)
|
|
105
|
+
return NO_CONTENT();
|
|
106
|
+
let json;
|
|
107
|
+
try {
|
|
108
|
+
json = JSON.parse(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return NO_CONTENT();
|
|
112
|
+
}
|
|
113
|
+
const parsed = batchSchema.safeParse(json);
|
|
114
|
+
if (!parsed.success) {
|
|
115
|
+
reporting.log.info({ issues: parsed.error.issues.length }, 'reporting: client error batch refused');
|
|
116
|
+
return NO_CONTENT();
|
|
117
|
+
}
|
|
118
|
+
if (parsed.data.site !== reporting.site)
|
|
119
|
+
return NO_CONTENT();
|
|
120
|
+
// After parsing, so the cost of a refused body is not charged to the
|
|
121
|
+
// address, and before any capture, so the bucket bounds what is written
|
|
122
|
+
// rather than what was asked. Keyed on the address alone: a visitor id
|
|
123
|
+
// would be the client's own claim, and this is the route that exists
|
|
124
|
+
// because the client is not trusted.
|
|
125
|
+
const entries = parsed.data.errors.slice(0, BATCH_MAX);
|
|
126
|
+
if (!buckets.take(`ip:${clientAddress(request) ?? 'none'}`, entries.length)) {
|
|
127
|
+
return NO_CONTENT();
|
|
128
|
+
}
|
|
129
|
+
// Identity is the session's, same-origin only, and never the body's
|
|
130
|
+
// (the entry schema above has no tenantId/userId field to begin with).
|
|
131
|
+
let userId = null;
|
|
132
|
+
if (where.kind === 'same' && options.getUserId) {
|
|
133
|
+
try {
|
|
134
|
+
userId = await options.getUserId(request);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
reporting.log.warn({ err: error instanceof Error ? error.message : String(error) }, 'reporting: client error getUserId failed; capturing without a tenant');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let tenantId = null;
|
|
141
|
+
if (userId && options.tenantFor) {
|
|
142
|
+
try {
|
|
143
|
+
tenantId = await options.tenantFor(userId);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
tenantId = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// This request's own id, cf-ray or a fresh uuid — never the body's;
|
|
150
|
+
// same rule `errorHandler` (./index.ts) follows for a server capture.
|
|
151
|
+
const requestId = requestIdOf(request);
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
reporting.captureError(toCapturable(entry), {
|
|
154
|
+
kind: entry.kind,
|
|
155
|
+
runtime: 'browser',
|
|
156
|
+
release: entry.release ?? null,
|
|
157
|
+
tenantId,
|
|
158
|
+
requestId,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const response = NO_CONTENT();
|
|
162
|
+
return where.kind === 'foreign' ? corsFor(response, where.origin) : response;
|
|
163
|
+
}
|
|
164
|
+
return { handle };
|
|
165
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type CollectorOptions } from '../analytics/collector.js';
|
|
2
2
|
import { deviceOf } from '../analytics/schema.js';
|
|
3
3
|
import type { Reporting } from '../types.js';
|
|
4
|
+
import { type ClientErrorCollectorOptions } from './client-errors.js';
|
|
4
5
|
/**
|
|
5
6
|
* The Next bindings: everything in the package that knows what a request is.
|
|
6
7
|
* Peer on `next`; a host without Next never imports this entry.
|
|
@@ -49,6 +50,44 @@ export declare function registerShutdownFlush(reporting: Reporting, opts?: {
|
|
|
49
50
|
export declare function collectHandler(options: Omit<CollectorOptions, 'log' | 'site'> & {
|
|
50
51
|
readonly reporting: Reporting;
|
|
51
52
|
}): (request: Request) => Promise<Response>;
|
|
53
|
+
/**
|
|
54
|
+
* `onRequestError` from Next's instrumentation hook
|
|
55
|
+
* (`next/dist/server/instrumentation/types.d.ts`, `InstrumentationOnRequestError`).
|
|
56
|
+
* Declared locally: no public `next` subpath exports the type, and this is
|
|
57
|
+
* the exact shape the compiled server calls it with
|
|
58
|
+
* (`base-server.js`'s `instrumentationOnRequestError`).
|
|
59
|
+
*/
|
|
60
|
+
export type NextOnRequestError = (error: unknown, request: Readonly<{
|
|
61
|
+
path: string;
|
|
62
|
+
method: string;
|
|
63
|
+
headers: NodeJS.Dict<string | string[]>;
|
|
64
|
+
}>, context: Readonly<{
|
|
65
|
+
routerKind: string;
|
|
66
|
+
routePath: string;
|
|
67
|
+
routeType: string;
|
|
68
|
+
}>) => void | Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Binds `reporting.captureError` to Next's `onRequestError`
|
|
71
|
+
* (docs/plans/errors.md, "Surface"). `runtime` is `edge` exactly when
|
|
72
|
+
* `NEXT_RUNTIME` says so — the same signal Next's own compiled server
|
|
73
|
+
* branches on (e.g. `dist/server/app-render/action-handler.js`), because
|
|
74
|
+
* the error context Next hands this hook carries no runtime field of its
|
|
75
|
+
* own. `requestId` is derived the same way `requestContext()` derives one:
|
|
76
|
+
* Cloudflare's `cf-ray`, else a fresh uuid.
|
|
77
|
+
*
|
|
78
|
+
* Never throws: Next calls this while already handling an error, and a
|
|
79
|
+
* reporter that throws back is the one failure worse than not reporting —
|
|
80
|
+
* the same rule `captureError` itself already keeps (errors/capture.ts).
|
|
81
|
+
* This catch is defence in depth for a `reporting` that violates it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function errorHandler(reporting: Reporting): NextOnRequestError;
|
|
84
|
+
/**
|
|
85
|
+
* The public ingest route for browser-reported errors: mount as `POST`
|
|
86
|
+
* (docs/plans/errors.md, "Surface"; `// authz: public`). See
|
|
87
|
+
* `createClientErrorCollector` (./client-errors.js) for the caps, the
|
|
88
|
+
* origin allowance and why identity is never the body's.
|
|
89
|
+
*/
|
|
90
|
+
export declare function clientErrorHandler(options: ClientErrorCollectorOptions): (request: Request) => Promise<Response>;
|
|
52
91
|
/** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
|
|
53
92
|
export declare function trackContext(): Promise<{
|
|
54
93
|
device: ReturnType<typeof deviceOf>;
|
package/dist/next/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { unstable_rethrow } from 'next/navigation';
|
|
|
4
4
|
import { after, connection } from 'next/server';
|
|
5
5
|
import { createCollector } from '../analytics/collector.js';
|
|
6
6
|
import { countryOf, deviceOf } from '../analytics/schema.js';
|
|
7
|
+
import { createClientErrorCollector } from './client-errors.js';
|
|
7
8
|
/**
|
|
8
9
|
* The Next bindings: everything in the package that knows what a request is.
|
|
9
10
|
* Peer on `next`; a host without Next never imports this entry.
|
|
@@ -84,6 +85,53 @@ export function collectHandler(options) {
|
|
|
84
85
|
const collector = createCollector({ ...rest, log: reporting.log, site: reporting.site });
|
|
85
86
|
return (request) => collector.handle(request);
|
|
86
87
|
}
|
|
88
|
+
function headerValue(dict, name) {
|
|
89
|
+
const lower = name.toLowerCase();
|
|
90
|
+
for (const key of Object.keys(dict)) {
|
|
91
|
+
if (key.toLowerCase() !== lower)
|
|
92
|
+
continue;
|
|
93
|
+
const value = dict[key];
|
|
94
|
+
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Binds `reporting.captureError` to Next's `onRequestError`
|
|
100
|
+
* (docs/plans/errors.md, "Surface"). `runtime` is `edge` exactly when
|
|
101
|
+
* `NEXT_RUNTIME` says so — the same signal Next's own compiled server
|
|
102
|
+
* branches on (e.g. `dist/server/app-render/action-handler.js`), because
|
|
103
|
+
* the error context Next hands this hook carries no runtime field of its
|
|
104
|
+
* own. `requestId` is derived the same way `requestContext()` derives one:
|
|
105
|
+
* Cloudflare's `cf-ray`, else a fresh uuid.
|
|
106
|
+
*
|
|
107
|
+
* Never throws: Next calls this while already handling an error, and a
|
|
108
|
+
* reporter that throws back is the one failure worse than not reporting —
|
|
109
|
+
* the same rule `captureError` itself already keeps (errors/capture.ts).
|
|
110
|
+
* This catch is defence in depth for a `reporting` that violates it.
|
|
111
|
+
*/
|
|
112
|
+
export function errorHandler(reporting) {
|
|
113
|
+
return (error, request) => {
|
|
114
|
+
try {
|
|
115
|
+
const runtime = process.env.NEXT_RUNTIME === 'edge' ? 'edge' : 'server';
|
|
116
|
+
const ray = headerValue(request.headers, 'cf-ray');
|
|
117
|
+
const requestId = ray && RAY_PATTERN.test(ray) ? ray : randomUUID();
|
|
118
|
+
reporting.captureError(error, { runtime, requestId });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// See the doc comment above: this must never throw back into Next.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The public ingest route for browser-reported errors: mount as `POST`
|
|
127
|
+
* (docs/plans/errors.md, "Surface"; `// authz: public`). See
|
|
128
|
+
* `createClientErrorCollector` (./client-errors.js) for the caps, the
|
|
129
|
+
* origin allowance and why identity is never the body's.
|
|
130
|
+
*/
|
|
131
|
+
export function clientErrorHandler(options) {
|
|
132
|
+
const collector = createClientErrorCollector(options);
|
|
133
|
+
return (request) => collector.handle(request);
|
|
134
|
+
}
|
|
87
135
|
/** Device and country for a server-side `track`, from the request's headers; nothing else is kept. */
|
|
88
136
|
export async function trackContext() {
|
|
89
137
|
try {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReportingErrorRow } from '../tables.js';
|
|
2
|
+
/**
|
|
3
|
+
* The operator's error list: a plain table over the rows `errorsPage()`
|
|
4
|
+
* already returned, newest last-seen first — the same props-in split as
|
|
5
|
+
* `charts.tsx`, no fetching and no client state. The stack and the rest of
|
|
6
|
+
* one group's detail belong in a detail view, not here; this table only ever
|
|
7
|
+
* renders the group's summary columns.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ErrorsTable(props: {
|
|
10
|
+
readonly rows: readonly ReportingErrorRow[];
|
|
11
|
+
readonly label?: string;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The operator's error list: a plain table over the rows `errorsPage()`
|
|
4
|
+
* already returned, newest last-seen first — the same props-in split as
|
|
5
|
+
* `charts.tsx`, no fetching and no client state. The stack and the rest of
|
|
6
|
+
* one group's detail belong in a detail view, not here; this table only ever
|
|
7
|
+
* renders the group's summary columns.
|
|
8
|
+
*/
|
|
9
|
+
export function ErrorsTable(props) {
|
|
10
|
+
if (props.rows.length === 0) {
|
|
11
|
+
return _jsx("p", { "data-reporting-errors-empty": "", children: "No errors captured. All clear." });
|
|
12
|
+
}
|
|
13
|
+
return (_jsxs("table", { "data-reporting-errors": "", "aria-label": props.label ?? 'Errors, newest last seen first', children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Kind" }), _jsx("th", { scope: "col", children: "Message" }), _jsx("th", { scope: "col", children: "Occurrences" }), _jsx("th", { scope: "col", children: "First seen" }), _jsx("th", { scope: "col", children: "Last seen" }), _jsx("th", { scope: "col", children: "Runtime" }), _jsx("th", { scope: "col", children: "Release" }), _jsx("th", { scope: "col", children: "State" })] }) }), _jsx("tbody", { children: props.rows.map((row) => (_jsxs("tr", { children: [_jsx("td", { children: row.kind }), _jsx("td", { style: { maxWidth: '32rem', overflowWrap: 'anywhere', wordBreak: 'break-word' }, title: row.message, children: row.message }), _jsx("td", { style: { fontVariantNumeric: 'tabular-nums' }, children: String(row.occurrences) }), _jsx("td", { children: formatTimestamp(row.firstSeenAt) }), _jsx("td", { children: formatTimestamp(row.lastSeenAt) }), _jsx("td", { children: row.runtime }), _jsx("td", { children: row.release ?? '—' }), _jsx("td", { children: row.state })] }, row.fingerprint))) })] }));
|
|
14
|
+
}
|
|
15
|
+
function formatTimestamp(value) {
|
|
16
|
+
return `${value.toISOString().slice(0, 19).replace('T', ' ')} UTC`;
|
|
17
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -9,9 +9,15 @@ export declare const LEVELS: readonly ["info", "warn", "error", "alert"];
|
|
|
9
9
|
export type Level = (typeof LEVELS)[number];
|
|
10
10
|
export declare const ACTOR_CLASSES: readonly ["human", "api_key", "agent", "service"];
|
|
11
11
|
export type ActorClass = (typeof ACTOR_CLASSES)[number];
|
|
12
|
+
export declare const ERROR_STATES: readonly ["open", "resolved", "ignored"];
|
|
13
|
+
export type ErrorState = (typeof ERROR_STATES)[number];
|
|
14
|
+
export declare const ERROR_RUNTIMES: readonly ["server", "edge", "browser"];
|
|
15
|
+
export type ErrorRuntime = (typeof ERROR_RUNTIMES)[number];
|
|
12
16
|
/** `namespace.name`: one dot, lower case, digits and underscores. */
|
|
13
17
|
export declare const KIND_PATTERN: RegExp;
|
|
14
18
|
export declare const SITE_PATTERN: RegExp;
|
|
19
|
+
/** sha256 of the kind and the top normalised frames, truncated to 32 hex, lower case. */
|
|
20
|
+
export declare const FINGERPRINT_PATTERN: RegExp;
|
|
15
21
|
/**
|
|
16
22
|
* Keys a `data` object may not carry, because each names a person or a
|
|
17
23
|
* secret. A guardrail against the accidental case, not proof of absence: a
|
|
@@ -27,6 +33,7 @@ export declare const LIMITS: {
|
|
|
27
33
|
readonly targetId: 256;
|
|
28
34
|
readonly dataBytes: 16384;
|
|
29
35
|
readonly site: 64;
|
|
36
|
+
readonly stack: 16384;
|
|
30
37
|
};
|
|
31
38
|
/** Flat, bounded, banned keys refused, numbers finite. */
|
|
32
39
|
export declare const flatDataSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
|
|
@@ -83,3 +90,4 @@ export declare const eventInputSchema: z.ZodObject<{
|
|
|
83
90
|
export type EventInput = z.input<typeof eventInputSchema>;
|
|
84
91
|
export type ValidEvent = z.output<typeof eventInputSchema>;
|
|
85
92
|
export declare const siteSchema: z.ZodString;
|
|
93
|
+
export declare const fingerprintSchema: z.ZodString;
|
package/dist/schema.js
CHANGED
|
@@ -7,9 +7,13 @@ import { z } from 'zod';
|
|
|
7
7
|
*/
|
|
8
8
|
export const LEVELS = ['info', 'warn', 'error', 'alert'];
|
|
9
9
|
export const ACTOR_CLASSES = ['human', 'api_key', 'agent', 'service'];
|
|
10
|
+
export const ERROR_STATES = ['open', 'resolved', 'ignored'];
|
|
11
|
+
export const ERROR_RUNTIMES = ['server', 'edge', 'browser'];
|
|
10
12
|
/** `namespace.name`: one dot, lower case, digits and underscores. */
|
|
11
13
|
export const KIND_PATTERN = /^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$/;
|
|
12
14
|
export const SITE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
15
|
+
/** sha256 of the kind and the top normalised frames, truncated to 32 hex, lower case. */
|
|
16
|
+
export const FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
|
|
13
17
|
/**
|
|
14
18
|
* Keys a `data` object may not carry, because each names a person or a
|
|
15
19
|
* secret. A guardrail against the accidental case, not proof of absence: a
|
|
@@ -34,6 +38,7 @@ export const LIMITS = {
|
|
|
34
38
|
targetId: 256,
|
|
35
39
|
dataBytes: 16384,
|
|
36
40
|
site: 64,
|
|
41
|
+
stack: 16384,
|
|
37
42
|
};
|
|
38
43
|
const scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
39
44
|
/** Flat, bounded, banned keys refused, numbers finite. */
|
|
@@ -85,3 +90,6 @@ export const eventInputSchema = z.strictObject({
|
|
|
85
90
|
export const siteSchema = z
|
|
86
91
|
.string()
|
|
87
92
|
.regex(SITE_PATTERN, 'a site id is lower case, digits and dashes');
|
|
93
|
+
export const fingerprintSchema = z
|
|
94
|
+
.string()
|
|
95
|
+
.regex(FINGERPRINT_PATTERN, 'a fingerprint is 32 lower-case hex characters');
|