@ultimat3/admin 29.3.2 → 30.0.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/CLAUDE.md +7 -6
- package/README.md +36 -2
- package/package.json +29 -18
- package/src/audit-pg.ts +7 -1
- package/src/audit-request.ts +129 -0
- package/src/audit-schema.ts +7 -3
- package/src/audit.ts +61 -9
- package/src/crud-input.ts +9 -7
- package/src/detail.tsx +39 -30
- package/src/dev/data.ts +9 -0
- package/src/dev/facts.ts +25 -0
- package/src/dev/index.ts +2 -0
- package/src/dev/panel-mail.ts +29 -4
- package/src/error-groups-pages.ts +50 -0
- package/src/error-groups-pg.ts +163 -0
- package/src/error-groups-schema.ts +36 -0
- package/src/error-groups-sink.ts +181 -0
- package/src/error-groups-store.ts +173 -0
- package/src/error-groups.ts +24 -0
- package/src/errors-detail.tsx +60 -0
- package/src/errors-list.tsx +74 -0
- package/src/errors.ts +7 -1
- package/src/index.ts +12 -1
- package/src/list-filters.ts +14 -10
- package/src/mcp-tools.ts +2 -1
- package/src/registry.ts +5 -0
- package/src/resource-operations.ts +36 -0
- package/src/resource.ts +3 -2
- package/src/screen-resource.tsx +2 -1
- package/src/screen-system.tsx +136 -14
package/src/dev/data.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
LiveQueryFact,
|
|
23
23
|
LiveSubscriberFact,
|
|
24
24
|
MailFact,
|
|
25
|
+
MailTemplateFact,
|
|
25
26
|
ManifestFact,
|
|
26
27
|
PolicyFact,
|
|
27
28
|
QueueFact,
|
|
@@ -54,6 +55,7 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
|
|
|
54
55
|
runSql:
|
|
55
56
|
facts.runSql ?? ((): Promise<SqlResult> => empty({ columns: [], rows: [], elapsedMs: 0 })),
|
|
56
57
|
mail: facts.mail ?? ((): Promise<readonly MailFact[]> => empty([])),
|
|
58
|
+
mailTemplates: facts.mailTemplates ?? ((): Promise<readonly MailTemplateFact[]> => empty([])),
|
|
57
59
|
cacheGraph: facts.cacheGraph ?? ((): Promise<readonly CacheEdgeFact[]> => empty([])),
|
|
58
60
|
invalidations: facts.invalidations ?? ((): Promise<readonly InvalidationFact[]> => empty([])),
|
|
59
61
|
policyMatrix: facts.policyMatrix ?? ((): Promise<readonly PolicyFact[]> => empty([])),
|
|
@@ -122,6 +124,10 @@ const STEP_STATUS: Readonly<Record<StepStatus, JobStepFact['status']>> = {
|
|
|
122
124
|
sleeping: 'sleeping',
|
|
123
125
|
waiting: 'pending',
|
|
124
126
|
failed: 'failed',
|
|
127
|
+
// A `once` step whose `fn` began and recorded no outcome: in flight, or unknown until an operator
|
|
128
|
+
// says so with `x jobs resolve-step`. `cleared` is that operator's "it did not run": it runs again.
|
|
129
|
+
started: 'running',
|
|
130
|
+
cleared: 'pending',
|
|
125
131
|
};
|
|
126
132
|
|
|
127
133
|
/**
|
|
@@ -335,6 +341,9 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
335
341
|
|
|
336
342
|
runSql: unwired<SqlResult>('runSql', 'db'),
|
|
337
343
|
mail: unwired<readonly MailFact[]>('mail', 'mail'),
|
|
344
|
+
// The mail registry and the app's locales are the host's: `x dev` renders each declared
|
|
345
|
+
// `preview` through `@ultimat3/mail`, which this package does not depend on.
|
|
346
|
+
mailTemplates: unwired<readonly MailTemplateFact[]>('mailTemplates', 'mail'),
|
|
338
347
|
|
|
339
348
|
/**
|
|
340
349
|
* The tag graph is read through `dependentsOf`, one entity tag at a time: the cache owns
|
package/src/dev/facts.ts
CHANGED
|
@@ -225,6 +225,30 @@ export interface MailFact {
|
|
|
225
225
|
readonly sentAt: string;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/** One locale's render of a declared mail's `preview`, or the instruction its schema refused with. */
|
|
229
|
+
export type MailTemplatePreviewFact =
|
|
230
|
+
| {
|
|
231
|
+
readonly locale: string;
|
|
232
|
+
readonly ok: true;
|
|
233
|
+
readonly subject: string;
|
|
234
|
+
readonly html: string;
|
|
235
|
+
readonly text: string;
|
|
236
|
+
/** Catalog keys this locale could not resolve: `send` would refuse these. */
|
|
237
|
+
readonly missing: readonly string[];
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
readonly locale: string;
|
|
241
|
+
readonly ok: false;
|
|
242
|
+
readonly error: { readonly code: string; readonly cause: string; readonly fix: string };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** A declared mail (`defineMail`) and its preview in every locale; none when it declares none. */
|
|
246
|
+
export interface MailTemplateFact {
|
|
247
|
+
readonly id: string;
|
|
248
|
+
readonly hasPreview: boolean;
|
|
249
|
+
readonly previews: readonly MailTemplatePreviewFact[];
|
|
250
|
+
}
|
|
251
|
+
|
|
228
252
|
export interface CacheEdgeFact {
|
|
229
253
|
readonly tag: string;
|
|
230
254
|
readonly dependents: readonly { readonly kind: string; readonly id: string }[];
|
|
@@ -270,6 +294,7 @@ export interface DevSources {
|
|
|
270
294
|
drift(): Promise<readonly DriftFact[]>;
|
|
271
295
|
runSql(sql: string): Promise<SqlResult>;
|
|
272
296
|
mail(): Promise<readonly MailFact[]>;
|
|
297
|
+
mailTemplates(): Promise<readonly MailTemplateFact[]>;
|
|
273
298
|
cacheGraph(): Promise<readonly CacheEdgeFact[]>;
|
|
274
299
|
invalidations(): Promise<readonly InvalidationFact[]>;
|
|
275
300
|
policyMatrix(): Promise<readonly PolicyFact[]>;
|
package/src/dev/index.ts
CHANGED
package/src/dev/panel-mail.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Panel: Mail.
|
|
2
|
-
// Kills: "what did that email actually look like, in that locale?" — every caught message,
|
|
3
|
-
//
|
|
2
|
+
// Kills: "what did that email actually look like, in that locale?" — every caught message, and
|
|
3
|
+
// every declared template rendered from its `preview` in every locale with no flow triggered.
|
|
4
4
|
|
|
5
|
-
import type { MailFact } from './facts';
|
|
5
|
+
import type { MailFact, MailTemplateFact } from './facts';
|
|
6
6
|
import type { DevPanel } from './panel';
|
|
7
7
|
|
|
8
8
|
export interface MailPanelData {
|
|
@@ -14,14 +14,30 @@ export interface MailPanelData {
|
|
|
14
14
|
readonly subject: string;
|
|
15
15
|
readonly missing: readonly string[];
|
|
16
16
|
}[];
|
|
17
|
+
/** Every declared mail, each with its preview rendered per locale (`defineMail({ preview })`). */
|
|
18
|
+
readonly templates: readonly MailTemplateFact[];
|
|
19
|
+
readonly selectedTemplate: MailTemplateFact | null;
|
|
20
|
+
/** A template's catalog keys a locale could not resolve — the gap before anything is sent. */
|
|
21
|
+
readonly templateGaps: readonly {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly locale: string;
|
|
24
|
+
readonly missing: readonly string[];
|
|
25
|
+
}[];
|
|
17
26
|
}
|
|
18
27
|
|
|
28
|
+
/** `?template=` names one; none named is the first; an unknown name is nothing, never the first. */
|
|
29
|
+
const selectedOf = (
|
|
30
|
+
templates: readonly MailTemplateFact[],
|
|
31
|
+
wanted: string | null,
|
|
32
|
+
): MailTemplateFact | null =>
|
|
33
|
+
(wanted === null ? templates[0] : templates.find((one) => one.id === wanted)) ?? null;
|
|
34
|
+
|
|
19
35
|
export const mailPanel: DevPanel<MailPanelData> = {
|
|
20
36
|
key: 'mail',
|
|
21
37
|
titleKey: 'dev.panel.mail.title',
|
|
22
38
|
questionKey: 'dev.panel.mail.question',
|
|
23
39
|
async data(sources, params): Promise<MailPanelData> {
|
|
24
|
-
const all = await sources.mail();
|
|
40
|
+
const [all, templates] = await Promise.all([sources.mail(), sources.mailTemplates()]);
|
|
25
41
|
const locale = params.get('locale');
|
|
26
42
|
const messages = locale === null ? all : all.filter((mail) => mail.locale === locale);
|
|
27
43
|
const wanted = params.get('id');
|
|
@@ -46,6 +62,15 @@ export const mailPanel: DevPanel<MailPanelData> = {
|
|
|
46
62
|
missing: locales.filter((candidate) => !seen.has(candidate)),
|
|
47
63
|
}))
|
|
48
64
|
.filter((entry) => entry.missing.length > 0),
|
|
65
|
+
templates,
|
|
66
|
+
selectedTemplate: selectedOf(templates, params.get('template')),
|
|
67
|
+
templateGaps: templates.flatMap((template) =>
|
|
68
|
+
template.previews.flatMap((preview) =>
|
|
69
|
+
preview.ok && preview.missing.length > 0
|
|
70
|
+
? [{ id: template.id, locale: preview.locale, missing: preview.missing }]
|
|
71
|
+
: [],
|
|
72
|
+
),
|
|
73
|
+
),
|
|
49
74
|
};
|
|
50
75
|
},
|
|
51
76
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Single responsibility: `/admin/errors` as `pages:` entries — the list and one group's detail,
|
|
2
|
+
// guarded like every custom page (`admin:read` plus `errors:read`). Data, not a new screen kind:
|
|
3
|
+
// the route table, the nav and the audit of a refusal are the ones `pages.ts` already builds.
|
|
4
|
+
|
|
5
|
+
import type { ErrorGroupsStore } from './error-groups-store';
|
|
6
|
+
import { ErrorsDetail } from './errors-detail';
|
|
7
|
+
import { ErrorsList } from './errors-list';
|
|
8
|
+
import type { AdminCustomPage } from './pages';
|
|
9
|
+
|
|
10
|
+
/** The permission the two pages compose after `admin:read`. */
|
|
11
|
+
export const ERRORS_READ = 'errors:read';
|
|
12
|
+
|
|
13
|
+
export interface ErrorGroupsPagesOptions {
|
|
14
|
+
readonly store: ErrorGroupsStore;
|
|
15
|
+
/** The nav group the list is linked under; omitted, reachable by URL only. */
|
|
16
|
+
readonly navGroup?: string | undefined;
|
|
17
|
+
/** Rooted at the admin's `basePath`. Default `/errors`. */
|
|
18
|
+
readonly path?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function errorGroupsPages(options: ErrorGroupsPagesOptions): readonly AdminCustomPage[] {
|
|
22
|
+
const path = options.path ?? '/errors';
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
path,
|
|
26
|
+
titleKey: 'admin.errors.title',
|
|
27
|
+
permissions: [ERRORS_READ],
|
|
28
|
+
...(options.navGroup === undefined ? {} : { navGroup: options.navGroup }),
|
|
29
|
+
component: (props) =>
|
|
30
|
+
ErrorsList({
|
|
31
|
+
store: options.store,
|
|
32
|
+
basePath: new URL(props.url).pathname,
|
|
33
|
+
url: props.url,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
path: `${path}/:fingerprint`,
|
|
38
|
+
titleKey: 'admin.errors.detail.title',
|
|
39
|
+
permissions: [ERRORS_READ],
|
|
40
|
+
component: (props) => {
|
|
41
|
+
const pathname = new URL(props.url).pathname;
|
|
42
|
+
return ErrorsDetail({
|
|
43
|
+
store: options.store,
|
|
44
|
+
listPath: pathname.slice(0, pathname.lastIndexOf('/')),
|
|
45
|
+
fingerprint: props.params['fingerprint'] ?? '',
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The durable error-group store: one upsert per occurrence on `x_error_groups`, read back newest
|
|
2
|
+
// first. What `errorGroupsSink({ store: postgresErrorGroupsStore() })` names in a deployment with
|
|
3
|
+
// more than one process — the memory twin forgets at every restart and counts per replica.
|
|
4
|
+
|
|
5
|
+
import { isJsonObject } from '@ultimat3/core';
|
|
6
|
+
import { type DbClient, db } from '@ultimat3/db';
|
|
7
|
+
import { ERROR_GROUPS_TABLE } from './error-groups-schema';
|
|
8
|
+
import {
|
|
9
|
+
ERROR_GROUP_STATUSES,
|
|
10
|
+
type ErrorGroup,
|
|
11
|
+
type ErrorGroupSample,
|
|
12
|
+
type ErrorGroupStatus,
|
|
13
|
+
type ErrorGroupsStore,
|
|
14
|
+
errorGroupsLimit,
|
|
15
|
+
} from './error-groups-store';
|
|
16
|
+
|
|
17
|
+
const COLUMNS =
|
|
18
|
+
'fingerprint, code, source, operation, message, status, first_seen, last_seen, count, sample';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `xmax = 0` is true only on the row version an INSERT wrote: under a concurrent conflict one
|
|
22
|
+
* session inserts and every other one updates, so exactly one caller reads `inserted`. A resolved
|
|
23
|
+
* group re-opens here, in the same statement, so a regression can never be counted and stay shut.
|
|
24
|
+
*/
|
|
25
|
+
export const SQL_ERROR_GROUP_UPSERT = `
|
|
26
|
+
insert into x_error_groups as g (
|
|
27
|
+
fingerprint, code, source, operation, message, status, first_seen, last_seen, count, sample
|
|
28
|
+
) values ($1, $2, $3, $4, $5, 'open', $6::timestamptz, $6::timestamptz, $7, $8::jsonb)
|
|
29
|
+
on conflict (fingerprint) do update set
|
|
30
|
+
message = excluded.message,
|
|
31
|
+
status = case when g.status = 'resolved' then 'open' else g.status end,
|
|
32
|
+
last_seen = greatest(g.last_seen, excluded.last_seen),
|
|
33
|
+
count = g.count + excluded.count,
|
|
34
|
+
sample = excluded.sample
|
|
35
|
+
returning (xmax = 0) as inserted
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
interface GroupRow {
|
|
39
|
+
readonly fingerprint: string;
|
|
40
|
+
readonly code: string;
|
|
41
|
+
readonly source: string;
|
|
42
|
+
readonly operation: string | null;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly status: string;
|
|
45
|
+
readonly first_seen: Date | string;
|
|
46
|
+
readonly last_seen: Date | string;
|
|
47
|
+
readonly count: number | string | bigint;
|
|
48
|
+
readonly sample: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const iso = (value: Date | string): string =>
|
|
52
|
+
(value instanceof Date ? value : new Date(value)).toISOString();
|
|
53
|
+
|
|
54
|
+
/** A `jsonb` column, whichever way the driver hands it back: parsed, or as its text. */
|
|
55
|
+
const parsed = (value: unknown): unknown => {
|
|
56
|
+
if (typeof value !== 'string') return value;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(value);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** An own string field, or `null`: a stored row is data, never a prototype to read through. */
|
|
65
|
+
const text = (record: Readonly<Record<string, unknown>>, key: string): string | null => {
|
|
66
|
+
const value = Object.hasOwn(record, key) ? record[key] : undefined;
|
|
67
|
+
return typeof value === 'string' ? value : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Written by this file from `ErrorGroupSample`; a hand-edited row reads as what it holds. */
|
|
71
|
+
function sampleOfRow(value: unknown): ErrorGroupSample {
|
|
72
|
+
const record = isJsonObject(value) ? value : {};
|
|
73
|
+
const meta = record['meta'];
|
|
74
|
+
// Spelled apart: the stored instruction is data read back, not a `fix:` this file writes.
|
|
75
|
+
const fix = text(record, 'fix') ?? '';
|
|
76
|
+
return {
|
|
77
|
+
at: text(record, 'at') ?? '',
|
|
78
|
+
severity: text(record, 'severity') ?? 'error',
|
|
79
|
+
title: text(record, 'title') ?? '',
|
|
80
|
+
cause: text(record, 'cause') ?? '',
|
|
81
|
+
fix,
|
|
82
|
+
release: text(record, 'release'),
|
|
83
|
+
environment: text(record, 'environment') ?? '',
|
|
84
|
+
requestId: text(record, 'requestId'),
|
|
85
|
+
traceId: text(record, 'traceId'),
|
|
86
|
+
meta: isJsonObject(meta) ? meta : null,
|
|
87
|
+
stack: text(record, 'stack'),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const groupOf = (row: GroupRow): ErrorGroup => ({
|
|
92
|
+
fingerprint: row.fingerprint,
|
|
93
|
+
code: row.code,
|
|
94
|
+
source: row.source,
|
|
95
|
+
operation: row.operation,
|
|
96
|
+
message: row.message,
|
|
97
|
+
status: ERROR_GROUP_STATUSES.find((known) => known === row.status) ?? 'open',
|
|
98
|
+
firstSeen: iso(row.first_seen),
|
|
99
|
+
lastSeen: iso(row.last_seen),
|
|
100
|
+
count: Number(row.count),
|
|
101
|
+
sample: sampleOfRow(parsed(row.sample)),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export interface PostgresErrorGroupsStoreOptions {
|
|
105
|
+
/** Omitted, the process's own `db()`. The sink already left any open transaction. */
|
|
106
|
+
readonly client?: DbClient | undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function postgresErrorGroupsStore(
|
|
110
|
+
options: PostgresErrorGroupsStoreOptions = {},
|
|
111
|
+
): ErrorGroupsStore {
|
|
112
|
+
const client = (): DbClient => options.client ?? db();
|
|
113
|
+
return {
|
|
114
|
+
kind: 'postgres',
|
|
115
|
+
async record(occurrence) {
|
|
116
|
+
const rows = await client().query<{ inserted: boolean }>({
|
|
117
|
+
text: SQL_ERROR_GROUP_UPSERT,
|
|
118
|
+
values: [
|
|
119
|
+
occurrence.fingerprint,
|
|
120
|
+
occurrence.code,
|
|
121
|
+
occurrence.source,
|
|
122
|
+
occurrence.operation,
|
|
123
|
+
occurrence.message,
|
|
124
|
+
occurrence.at.toISOString(),
|
|
125
|
+
occurrence.count,
|
|
126
|
+
JSON.stringify(occurrence.sample),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
return { isNew: rows[0]?.inserted === true };
|
|
130
|
+
},
|
|
131
|
+
async list(query = {}) {
|
|
132
|
+
const limit = errorGroupsLimit('postgresErrorGroupsStore', query.limit);
|
|
133
|
+
const filtered = query.status !== undefined;
|
|
134
|
+
return (
|
|
135
|
+
await client().query<GroupRow>({
|
|
136
|
+
text: `select ${COLUMNS} from ${ERROR_GROUPS_TABLE}${filtered ? ' where status = $2' : ''} order by last_seen desc, fingerprint limit $1`,
|
|
137
|
+
values: filtered ? [limit, query.status] : [limit],
|
|
138
|
+
})
|
|
139
|
+
).map(groupOf);
|
|
140
|
+
},
|
|
141
|
+
async get(fingerprint) {
|
|
142
|
+
const rows = await client().query<GroupRow>({
|
|
143
|
+
text: `select ${COLUMNS} from ${ERROR_GROUPS_TABLE} where fingerprint = $1`,
|
|
144
|
+
values: [fingerprint],
|
|
145
|
+
});
|
|
146
|
+
return rows[0] === undefined ? undefined : groupOf(rows[0]);
|
|
147
|
+
},
|
|
148
|
+
async setStatus(fingerprint, status: ErrorGroupStatus) {
|
|
149
|
+
const rows = await client().query<GroupRow>({
|
|
150
|
+
text: `update ${ERROR_GROUPS_TABLE} set status = $2 where fingerprint = $1 returning ${COLUMNS}`,
|
|
151
|
+
values: [fingerprint, status],
|
|
152
|
+
});
|
|
153
|
+
return rows[0] === undefined ? undefined : groupOf(rows[0]);
|
|
154
|
+
},
|
|
155
|
+
async purge(beforeMs) {
|
|
156
|
+
const rows = await client().query<{ removed: number | string }>({
|
|
157
|
+
text: `with gone as (delete from ${ERROR_GROUPS_TABLE} where last_seen < $1::timestamptz returning 1) select count(*)::int as removed from gone`,
|
|
158
|
+
values: [new Date(beforeMs).toISOString()],
|
|
159
|
+
});
|
|
160
|
+
return Number(rows[0]?.removed ?? 0);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The DDL of the error-groups table, and nothing else: a LEAF module, so the boot's
|
|
2
|
+
// `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply it in every role without pulling one screen into
|
|
3
|
+
// a worker's module graph — `audit-schema.ts`'s rule, for the same reason.
|
|
4
|
+
|
|
5
|
+
/** The table. One name, read by this DDL and by `error-groups-pg.ts`'s statements. */
|
|
6
|
+
export const ERROR_GROUPS_TABLE = 'x_error_groups';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Applied by the boot, never by an app migration — the rule every framework table follows.
|
|
10
|
+
*
|
|
11
|
+
* **One row per fingerprint** (`errorFingerprint`, core): the primary key IS the group, so the
|
|
12
|
+
* upsert's `xmax = 0` answers "this insert made it" for exactly one of N racing replicas.
|
|
13
|
+
*
|
|
14
|
+
* **`sample` is what the sink already redacted** — credentials by key, the stack cut — and never
|
|
15
|
+
* the thrown value. A replayable bundle is `x_failures`', not this table's.
|
|
16
|
+
*
|
|
17
|
+
* **One index**, `(last_seen desc, fingerprint)`: the list screen's order and the purge's range.
|
|
18
|
+
* Retention is the app's: `errorGroupsPurgeTarget(store, { olderThanMs })` has no default.
|
|
19
|
+
*/
|
|
20
|
+
export const SQL_ERROR_GROUPS_TABLE = `
|
|
21
|
+
create table if not exists x_error_groups (
|
|
22
|
+
fingerprint text primary key,
|
|
23
|
+
code text not null,
|
|
24
|
+
source text not null,
|
|
25
|
+
operation text,
|
|
26
|
+
message text not null,
|
|
27
|
+
status text not null default 'open',
|
|
28
|
+
first_seen timestamptz not null,
|
|
29
|
+
last_seen timestamptz not null,
|
|
30
|
+
count bigint not null,
|
|
31
|
+
sample jsonb not null
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
create index if not exists x_error_groups_seen_idx
|
|
35
|
+
on x_error_groups (last_seen desc, fingerprint);
|
|
36
|
+
`;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Single responsibility: `errorGroupsSink` — an `ErrorReporter` that folds every report into its
|
|
2
|
+
// `errorFingerprint` group in a store, outside the request's transaction, bounded per minute, and
|
|
3
|
+
// enqueues the app's `onNewGroup` job exactly once per new group. It never re-enters the reporter.
|
|
4
|
+
|
|
5
|
+
import type { Clock, ErrorReport, ErrorReporter } from '@ultimat3/core';
|
|
6
|
+
import {
|
|
7
|
+
errorFingerprint,
|
|
8
|
+
finiteCount,
|
|
9
|
+
isRedactedKey,
|
|
10
|
+
logger,
|
|
11
|
+
REDACTED,
|
|
12
|
+
renderThrowable,
|
|
13
|
+
runToCompletion,
|
|
14
|
+
systemClock,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import { outsideTransaction } from '@ultimat3/db';
|
|
17
|
+
import type { JobHandle } from '@ultimat3/jobs';
|
|
18
|
+
import type {
|
|
19
|
+
ErrorGroupOccurrence,
|
|
20
|
+
ErrorGroupSample,
|
|
21
|
+
ErrorGroupsStore,
|
|
22
|
+
} from './error-groups-store';
|
|
23
|
+
|
|
24
|
+
/** What `onNewGroup` is enqueued with: enough to title an issue, nothing a report did not group on. */
|
|
25
|
+
export interface ErrorGroupNotice {
|
|
26
|
+
readonly fingerprint: string;
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly source: string;
|
|
29
|
+
readonly operation: string | null;
|
|
30
|
+
readonly message: string;
|
|
31
|
+
readonly firstSeen: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ErrorGroupsSinkOptions {
|
|
35
|
+
readonly store: ErrorGroupsStore;
|
|
36
|
+
/** The app's job: which tracker, which title, which assignee are its own (axiom 8). */
|
|
37
|
+
readonly onNewGroup?: Pick<JobHandle<ErrorGroupNotice>, 'name' | 'enqueue'> | undefined;
|
|
38
|
+
/** A report this answers `true` for is not grouped. */
|
|
39
|
+
readonly ignore?: ((report: ErrorReport) => boolean) | undefined;
|
|
40
|
+
/** Store writes per process per minute. Past it, reports are counted and written as one later. */
|
|
41
|
+
readonly perMinute?: number | undefined;
|
|
42
|
+
readonly clock?: Clock | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ErrorGroupsSink extends ErrorReporter {
|
|
46
|
+
/** Resolves when every write started so far has settled. For tests and a drain hook. */
|
|
47
|
+
settled(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ERROR_GROUPS_PER_MINUTE = 120;
|
|
51
|
+
/** The cause's head: a list row, never a page of text. */
|
|
52
|
+
const MESSAGE_HEAD = 200;
|
|
53
|
+
const STACK_MAX = 8_000;
|
|
54
|
+
/** Groups held back while over the cap. Past this many, a report is dropped and that is logged. */
|
|
55
|
+
const DEFERRED_MAX = 1_000;
|
|
56
|
+
|
|
57
|
+
/** Credentials by key, as the log line and the monitor envelope redact them: one rule. */
|
|
58
|
+
function redactedMeta(meta: ErrorReport['meta']): Readonly<Record<string, unknown>> | null {
|
|
59
|
+
if (meta === undefined) return null;
|
|
60
|
+
const out = new Map<string, unknown>();
|
|
61
|
+
for (const [key, value] of Object.entries(meta))
|
|
62
|
+
out.set(key, isRedactedKey(key) ? REDACTED : value);
|
|
63
|
+
return Object.fromEntries(out);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sampleOf(report: ErrorReport): ErrorGroupSample {
|
|
67
|
+
return {
|
|
68
|
+
at: new Date(report.at).toISOString(),
|
|
69
|
+
severity: report.severity,
|
|
70
|
+
title: report.title,
|
|
71
|
+
cause: report.cause,
|
|
72
|
+
fix: report.fix,
|
|
73
|
+
release: report.release,
|
|
74
|
+
environment: report.environment,
|
|
75
|
+
requestId: report.scope.requestId ?? null,
|
|
76
|
+
traceId: report.scope.traceId ?? null,
|
|
77
|
+
meta: redactedMeta(report.meta),
|
|
78
|
+
stack: report.stack === undefined ? null : report.stack.slice(0, STACK_MAX),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function occurrenceOf(report: ErrorReport, count: number): ErrorGroupOccurrence {
|
|
83
|
+
return {
|
|
84
|
+
fingerprint: errorFingerprint(report),
|
|
85
|
+
code: report.code,
|
|
86
|
+
source: report.source,
|
|
87
|
+
operation: report.scope.operation ?? null,
|
|
88
|
+
message: report.cause.split('\n')[0]?.slice(0, MESSAGE_HEAD) ?? '',
|
|
89
|
+
at: new Date(report.at),
|
|
90
|
+
count,
|
|
91
|
+
sample: sampleOf(report),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSink {
|
|
96
|
+
const clock = options.clock ?? systemClock;
|
|
97
|
+
const cap = finiteCount(
|
|
98
|
+
'errorGroupsSink',
|
|
99
|
+
'perMinute',
|
|
100
|
+
options.perMinute ?? ERROR_GROUPS_PER_MINUTE,
|
|
101
|
+
1,
|
|
102
|
+
);
|
|
103
|
+
const deferred = new Map<string, ErrorGroupOccurrence>();
|
|
104
|
+
const pending = new Set<Promise<void>>();
|
|
105
|
+
let window = -1;
|
|
106
|
+
let written = 0;
|
|
107
|
+
|
|
108
|
+
/** Its own failure is logged, never reported: a sink that reports itself loops on an outage. */
|
|
109
|
+
const write = (occurrence: ErrorGroupOccurrence): void => {
|
|
110
|
+
const run = outsideTransaction(() =>
|
|
111
|
+
runToCompletion(async () => {
|
|
112
|
+
const { isNew } = await options.store.record(occurrence);
|
|
113
|
+
if (!isNew || options.onNewGroup === undefined) return;
|
|
114
|
+
await options.onNewGroup.enqueue({
|
|
115
|
+
fingerprint: occurrence.fingerprint,
|
|
116
|
+
code: occurrence.code,
|
|
117
|
+
source: occurrence.source,
|
|
118
|
+
operation: occurrence.operation,
|
|
119
|
+
message: occurrence.message,
|
|
120
|
+
firstSeen: occurrence.at.toISOString(),
|
|
121
|
+
});
|
|
122
|
+
}),
|
|
123
|
+
).catch((failure: unknown) => {
|
|
124
|
+
logger.warn('error group write failed', {
|
|
125
|
+
fingerprint: occurrence.fingerprint,
|
|
126
|
+
code: occurrence.code,
|
|
127
|
+
error: renderThrowable(failure),
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
pending.add(run);
|
|
131
|
+
void run.finally(() => pending.delete(run));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A new minute writes what the last one held back, each group once, before the new report. */
|
|
135
|
+
const turn = (minute: number): void => {
|
|
136
|
+
if (minute === window) return;
|
|
137
|
+
window = minute;
|
|
138
|
+
written = 0;
|
|
139
|
+
const held = [...deferred.values()];
|
|
140
|
+
deferred.clear();
|
|
141
|
+
for (const occurrence of held) {
|
|
142
|
+
written += 1;
|
|
143
|
+
write(occurrence);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const hold = (occurrence: ErrorGroupOccurrence): void => {
|
|
148
|
+
const seen = deferred.get(occurrence.fingerprint);
|
|
149
|
+
if (seen !== undefined) {
|
|
150
|
+
deferred.set(occurrence.fingerprint, { ...occurrence, count: seen.count + occurrence.count });
|
|
151
|
+
} else if (deferred.size < DEFERRED_MAX) {
|
|
152
|
+
deferred.set(occurrence.fingerprint, occurrence);
|
|
153
|
+
} else {
|
|
154
|
+
logger.warn('error group dropped over the per-minute cap', {
|
|
155
|
+
fingerprint: occurrence.fingerprint,
|
|
156
|
+
perMinute: cap,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
report(report: ErrorReport): void {
|
|
163
|
+
try {
|
|
164
|
+
if (options.ignore?.(report) === true) return;
|
|
165
|
+
turn(Math.floor(clock.now().getTime() / 60_000));
|
|
166
|
+
const occurrence = occurrenceOf(report, 1);
|
|
167
|
+
if (written >= cap) {
|
|
168
|
+
hold(occurrence);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
written += 1;
|
|
172
|
+
write(occurrence);
|
|
173
|
+
} catch (failure) {
|
|
174
|
+
logger.warn('error group sink failed', { error: renderThrowable(failure) });
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
async settled(): Promise<void> {
|
|
178
|
+
while (pending.size > 0) await Promise.all([...pending]);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|