pipework 0.173.0 → 0.174.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/CHANGELOG.md +12 -0
- package/REFERENCE.md +22 -0
- package/dist/REFERENCE.md +322 -13
- package/dist/infra/alerts/namespace.d.ts +12 -1
- package/dist/infra/alerts/namespace.d.ts.map +1 -1
- package/dist/infra/alerts/namespace.js +12 -1
- package/dist/infra/alerts/namespace.js.map +1 -1
- package/dist/infra/alerts/sources.d.ts +64 -0
- package/dist/infra/alerts/sources.d.ts.map +1 -1
- package/dist/infra/alerts/sources.js +246 -161
- package/dist/infra/alerts/sources.js.map +1 -1
- package/dist/infra/alerts/subsystem-sources.d.ts +42 -0
- package/dist/infra/alerts/subsystem-sources.d.ts.map +1 -0
- package/dist/infra/alerts/subsystem-sources.js +152 -0
- package/dist/infra/alerts/subsystem-sources.js.map +1 -0
- package/dist/infra/webhook/outbound.d.ts +2 -0
- package/dist/infra/webhook/outbound.d.ts.map +1 -1
- package/dist/infra/webhook/outbound.js +3 -1
- package/dist/infra/webhook/outbound.js.map +1 -1
- package/package.json +1 -1
- package/dist/infra/email/sigv4.d.ts +0 -24
- package/dist/infra/email/sigv4.d.ts.map +0 -1
- package/dist/infra/email/sigv4.js +0 -55
- package/dist/infra/email/sigv4.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"namespace.js","sourceRoot":"","sources":["../../../src/infra/alerts/namespace.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAC1C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACvD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,aAAa,EAAE,YAAY,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,SAAS,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAChM,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAEjG,oHAAoH;AACpH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,gCAAgC;IAChC,MAAM,EAAE,YAAY;IACpB,0HAA0H;IAC1H,IAAI,EAAE,UAAU;IAChB,wLAAwL;IACxL,YAAY;IACZ,oHAAoH;IACpH,aAAa,EAAE,iBAAiB;IAChC,2DAA2D;IAC3D,OAAO,EAAE,WAAW;IACpB,+DAA+D;IAC/D,MAAM,EAAE,UAAU;IAClB,kJAAkJ;IAClJ,SAAS;IACT,MAAM,EAAE;QACN,4EAA4E;QAC5E,UAAU,EAAE,gBAAgB;QAC5B,iFAAiF;QACjF,WAAW,EAAE,iBAAiB;QAC9B,mGAAmG;QACnG,OAAO,EAAE,aAAa;QACtB,yGAAyG;QACzG,cAAc,EAAE,oBAAoB;QACpC,yDAAyD;QACzD,MAAM,EAAE,YAAY;QACpB,6IAA6I;QAC7I,SAAS,EAAE,eAAe;QAC1B,2HAA2H;QAC3H,KAAK,EAAE,WAAW;QAClB,iIAAiI;QACjI,OAAO,EAAE,aAAa;QACtB,0IAA0I;QAC1I,cAAc,EAAE,oBAAoB;QACpC,yJAAyJ;QACzJ,aAAa,EAAE,mBAAmB;QAClC,gIAAgI;QAChI,OAAO,EAAE,aAAa;QACtB,gNAAgN;QAChN,WAAW,EAAE,iBAAiB;KAC/B;CACO,CAAA"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HealthCheck } from '../../core/lifecycle/types.js';
|
|
2
|
+
import type { SQL } from '../../data/db/index.js';
|
|
2
3
|
import type { Manifold } from '../../core/pipework.js';
|
|
3
4
|
import type { AlertSeverity, JsonValue } from './handle.js';
|
|
4
5
|
/** A condition a source finds true right now. */
|
|
@@ -29,12 +30,74 @@ interface TableOption {
|
|
|
29
30
|
readonly table?: string;
|
|
30
31
|
readonly database?: string;
|
|
31
32
|
}
|
|
33
|
+
type Row = Record<string, unknown>;
|
|
34
|
+
interface SourceBasics {
|
|
35
|
+
readonly severity?: AlertSeverity;
|
|
36
|
+
readonly owner?: string;
|
|
37
|
+
readonly runbook?: string;
|
|
38
|
+
readonly database?: string;
|
|
39
|
+
}
|
|
40
|
+
/** One source from several: it evaluates each in turn and owns what any of them owns. */
|
|
41
|
+
export declare function composeSources(name: string, parts: readonly AlertSource[]): AlertSource;
|
|
42
|
+
export interface QuerySourceOptions extends SourceBasics {
|
|
43
|
+
/** Key prefix: conditions are keyed `${name}:${id}`. Non-empty, no ':'. */
|
|
44
|
+
readonly name: string;
|
|
45
|
+
/** Returns one row per subject that is in trouble right now. */
|
|
46
|
+
readonly query: SQL;
|
|
47
|
+
readonly id: (row: Row) => string;
|
|
48
|
+
readonly message: (row: Row) => string;
|
|
49
|
+
readonly context?: (row: Row) => Record<string, JsonValue>;
|
|
50
|
+
readonly subject?: (row: Row) => AlertCondition['subject'];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* For a condition that is true right now: runs `query` (as the system, any tenant) on every tick and raises
|
|
54
|
+
* `${name}:${id}` for each row. An open alert under `${name}:` that a tick no longer returns is resolved.
|
|
55
|
+
*/
|
|
56
|
+
export declare function querySource(options: QuerySourceOptions): AlertSource;
|
|
57
|
+
export interface NewRowsGroup {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly count: number;
|
|
60
|
+
readonly last: Row;
|
|
61
|
+
readonly sum: number;
|
|
62
|
+
}
|
|
63
|
+
export interface NewRowsSourceOptions extends SourceBasics {
|
|
64
|
+
/** Key prefix: conditions are keyed `${name}:${id}`; also the cursor's state key unless `stateKey` is given. */
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** The table holding the recorded failures (an identifier, not schema-qualified). */
|
|
67
|
+
readonly table: string;
|
|
68
|
+
/** The timestamp column a row is stamped with when the failure is recorded. */
|
|
69
|
+
readonly at: string;
|
|
70
|
+
/** Extra filter; the table is aliased `t`. */
|
|
71
|
+
readonly where?: SQL;
|
|
72
|
+
/** A column of the table, or an SQL expression over alias `t`; one condition per distinct value. */
|
|
73
|
+
readonly groupBy: string | SQL;
|
|
74
|
+
readonly message: (group: NewRowsGroup) => string;
|
|
75
|
+
readonly context?: (group: NewRowsGroup) => Record<string, JsonValue>;
|
|
76
|
+
readonly subject?: (group: NewRowsGroup) => AlertCondition['subject'];
|
|
77
|
+
/** A numeric column to total per group, handed to the callbacks as `sum` (0 without it). */
|
|
78
|
+
readonly sum?: string;
|
|
79
|
+
/** Rows are read once they are older than this many ms (default 30_000); see `newRowsSource`. */
|
|
80
|
+
readonly settleMs?: number;
|
|
81
|
+
/** The cursor's key in `pipework_alert_source_state`; default `name`. */
|
|
82
|
+
readonly stateKey?: string;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* For a condition that is "new since the last tick": raises `${name}:${id}` for each group of rows in `table`
|
|
86
|
+
* stamped in `at` after the cursor. The cursor lives in `pipework_alert_source_state`, shared by every process;
|
|
87
|
+
* the first evaluation only stores it, so history is not replayed. Each evaluation reads rows with
|
|
88
|
+
* `cursor < at <= clock_timestamp() - settleMs` and moves the cursor to that upper bound, so a row stamped with
|
|
89
|
+
* its transaction's start time that commits within `settleMs` is still read. `settleMs` must exceed the longest
|
|
90
|
+
* transaction that writes the table. It has no `owns`: the condition clears by the handle's quiet period
|
|
91
|
+
* (`resolveAfterQuietMs` on `alerts.create`).
|
|
92
|
+
*/
|
|
93
|
+
export declare function newRowsSource(options: NewRowsSourceOptions): AlertSource;
|
|
32
94
|
/**
|
|
33
95
|
* Raises `job-dead-letter:<jobType>` for jobs that exhausted their attempts since the last
|
|
34
96
|
* evaluation (any process: the position is kept in `pipework_alert_source_state`; the first
|
|
35
97
|
* evaluation starts from now, so history is not replayed). The condition clears only by the
|
|
36
98
|
* handle's quiet period, so set `resolveAfterQuietMs` on `alerts.create`. `types` limits it to those job
|
|
37
99
|
* types and `excludeTypes` leaves them out (not both, neither empty); each filter keeps its own position.
|
|
100
|
+
* Jobs are read `settleMs` (default 30_000) after they dead-letter, so one that commits late is not missed.
|
|
38
101
|
* Conditions carry `owner`, `runbook` and a context of `count` and `lastError`.
|
|
39
102
|
*/
|
|
40
103
|
export declare function deadLetterSource(options?: TableOption & {
|
|
@@ -43,6 +106,7 @@ export declare function deadLetterSource(options?: TableOption & {
|
|
|
43
106
|
excludeTypes?: readonly string[];
|
|
44
107
|
owner?: string;
|
|
45
108
|
runbook?: string;
|
|
109
|
+
settleMs?: number;
|
|
46
110
|
}): AlertSource;
|
|
47
111
|
/** Raises `job-long-running:<jobType>` while a job of that type has been claimed for longer than `olderThanMs`; resolves when none is. */
|
|
48
112
|
export declare function longRunningSource(options: TableOption & {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/sources.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,+BAA+B,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAA;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAI3D,iDAAiD;AACjD,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAA;IAChC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5E,gHAAgH;IAChH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAA;CACvD;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,IAAI,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,QAAQ,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC,CAAA;CACtC;AAED,UAAU,WAAW;IAAG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE;
|
|
1
|
+
{"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../../../src/infra/alerts/sources.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,10 +4,133 @@ import { pipeSystem } from '../../data/db/db.js';
|
|
|
4
4
|
import { assertValidIdentifier, quoteIdentifier } from '../../data/db/identifiers.js';
|
|
5
5
|
import { ALERTS_TICK_LOOP, LOOP_BEATS_QUERY, staleLoops } from './beat.js';
|
|
6
6
|
import { getContext } from '../../request/context/store.js';
|
|
7
|
+
function assertSourceName(name, who) {
|
|
8
|
+
if (typeof name !== 'string' || name === '' || name.includes(':')) {
|
|
9
|
+
throw new Error(`[pipework] ${who}: name must be a non-empty string without ':', got ${JSON.stringify(name)}.`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function ownerFields(options) {
|
|
13
|
+
return {
|
|
14
|
+
...(options.owner !== undefined ? { owner: options.owner } : {}),
|
|
15
|
+
...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** One source from several: it evaluates each in turn and owns what any of them owns. */
|
|
19
|
+
export function composeSources(name, parts) {
|
|
20
|
+
const owning = parts.filter(p => p.owns !== undefined);
|
|
21
|
+
return {
|
|
22
|
+
name,
|
|
23
|
+
...(owning.length > 0 ? { owns: (key) => owning.some(p => p.owns(key)) } : {}),
|
|
24
|
+
async evaluate() {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const part of parts)
|
|
27
|
+
out.push(...await part.evaluate());
|
|
28
|
+
return out;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* For a condition that is true right now: runs `query` (as the system, any tenant) on every tick and raises
|
|
34
|
+
* `${name}:${id}` for each row. An open alert under `${name}:` that a tick no longer returns is resolved.
|
|
35
|
+
*/
|
|
36
|
+
export function querySource(options) {
|
|
37
|
+
assertSourceName(options.name, 'alerts.source.query');
|
|
38
|
+
const prefix = `${options.name}:`;
|
|
39
|
+
const severity = options.severity ?? 'error';
|
|
40
|
+
return {
|
|
41
|
+
name: options.name,
|
|
42
|
+
owns: key => key.startsWith(prefix),
|
|
43
|
+
async evaluate() {
|
|
44
|
+
const rows = await pipeSystem(options.database).execute(options.query);
|
|
45
|
+
return rows.map((r) => {
|
|
46
|
+
const subject = options.subject?.(r);
|
|
47
|
+
const context = options.context?.(r);
|
|
48
|
+
return {
|
|
49
|
+
key: `${prefix}${options.id(r)}`,
|
|
50
|
+
severity,
|
|
51
|
+
message: options.message(r),
|
|
52
|
+
...(subject !== undefined ? { subject } : {}),
|
|
53
|
+
...ownerFields(options),
|
|
54
|
+
...(context !== undefined ? { context } : {}),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* For a condition that is "new since the last tick": raises `${name}:${id}` for each group of rows in `table`
|
|
62
|
+
* stamped in `at` after the cursor. The cursor lives in `pipework_alert_source_state`, shared by every process;
|
|
63
|
+
* the first evaluation only stores it, so history is not replayed. Each evaluation reads rows with
|
|
64
|
+
* `cursor < at <= clock_timestamp() - settleMs` and moves the cursor to that upper bound, so a row stamped with
|
|
65
|
+
* its transaction's start time that commits within `settleMs` is still read. `settleMs` must exceed the longest
|
|
66
|
+
* transaction that writes the table. It has no `owns`: the condition clears by the handle's quiet period
|
|
67
|
+
* (`resolveAfterQuietMs` on `alerts.create`).
|
|
68
|
+
*/
|
|
69
|
+
export function newRowsSource(options) {
|
|
70
|
+
assertSourceName(options.name, 'alerts.source.newRows');
|
|
71
|
+
const settleMs = options.settleMs ?? 30_000;
|
|
72
|
+
if (typeof settleMs !== 'number' || !Number.isFinite(settleMs) || settleMs < 0) {
|
|
73
|
+
throw new Error(`[pipework] alerts.source.newRows settleMs must be a non-negative number of milliseconds, got ${String(settleMs)}.`);
|
|
74
|
+
}
|
|
75
|
+
const table = quoteIdentifier(options.table, 'newRows table');
|
|
76
|
+
const at = `t.${quoteIdentifier(options.at, 'newRows at column')}`;
|
|
77
|
+
const group = typeof options.groupBy === 'string' ? sql.raw(`t.${quoteIdentifier(options.groupBy, 'newRows groupBy column')}`) : options.groupBy;
|
|
78
|
+
const total = options.sum === undefined ? sql `0` : sql.raw(`sum(t.${quoteIdentifier(options.sum, 'newRows sum column')})`);
|
|
79
|
+
const where = options.where === undefined ? sql `` : sql `AND (${options.where})`;
|
|
80
|
+
const stateKey = options.stateKey ?? options.name;
|
|
81
|
+
const severity = options.severity ?? 'error';
|
|
82
|
+
const prefix = `${options.name}:`;
|
|
83
|
+
return {
|
|
84
|
+
name: options.name,
|
|
85
|
+
async evaluate() {
|
|
86
|
+
const db = pipeSystem(options.database);
|
|
87
|
+
const first = await db.execute(sql `
|
|
88
|
+
INSERT INTO pipework_alert_source_state (name, cursor_at)
|
|
89
|
+
VALUES (${stateKey}, clock_timestamp() - ${settleMs} * interval '1 millisecond')
|
|
90
|
+
ON CONFLICT (name) DO NOTHING RETURNING name`);
|
|
91
|
+
if (first.length > 0)
|
|
92
|
+
return [];
|
|
93
|
+
const rows = await db.execute(sql `
|
|
94
|
+
WITH cur AS (
|
|
95
|
+
SELECT cursor_at, clock_timestamp() - ${settleMs} * interval '1 millisecond' AS upto
|
|
96
|
+
FROM pipework_alert_source_state WHERE name = ${stateKey} FOR UPDATE),
|
|
97
|
+
totals AS (
|
|
98
|
+
SELECT (${group})::text AS gid, count(*)::int AS n, (${total})::float8 AS total
|
|
99
|
+
FROM ${sql.raw(table)} t, cur
|
|
100
|
+
WHERE ${sql.raw(at)} > cur.cursor_at AND ${sql.raw(at)} <= cur.upto ${where}
|
|
101
|
+
GROUP BY 1),
|
|
102
|
+
newest AS (
|
|
103
|
+
SELECT DISTINCT ON (1) (${group})::text AS gid, to_jsonb(t) AS last
|
|
104
|
+
FROM ${sql.raw(table)} t, cur
|
|
105
|
+
WHERE ${sql.raw(at)} > cur.cursor_at AND ${sql.raw(at)} <= cur.upto ${where}
|
|
106
|
+
ORDER BY 1, ${sql.raw(at)} DESC),
|
|
107
|
+
found AS (
|
|
108
|
+
SELECT totals.gid AS id, n, total, last FROM totals JOIN newest ON newest.gid IS NOT DISTINCT FROM totals.gid),
|
|
109
|
+
moved AS (
|
|
110
|
+
UPDATE pipework_alert_source_state s SET cursor_at = GREATEST(s.cursor_at, cur.upto)
|
|
111
|
+
FROM cur WHERE s.name = ${stateKey} RETURNING 1)
|
|
112
|
+
SELECT id, n, total, last, (SELECT count(*) FROM moved) AS moved FROM found ORDER BY id`);
|
|
113
|
+
return rows.map((r) => {
|
|
114
|
+
const last = (typeof r['last'] === 'string' ? JSON.parse(r['last']) : r['last']);
|
|
115
|
+
const g = { id: r['id'] === null ? '(none)' : String(r['id']), count: Number(r['n']), last, sum: Number(r['total']) };
|
|
116
|
+
const subject = options.subject?.(g);
|
|
117
|
+
const context = options.context?.(g);
|
|
118
|
+
return {
|
|
119
|
+
key: `${prefix}${g.id}`,
|
|
120
|
+
severity,
|
|
121
|
+
message: options.message(g),
|
|
122
|
+
...(subject !== undefined ? { subject } : {}),
|
|
123
|
+
...ownerFields(options),
|
|
124
|
+
...(context !== undefined ? { context } : {}),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
7
130
|
function jobsTable(table) {
|
|
8
131
|
const name = table ?? 'pipework_jobs';
|
|
9
132
|
assertValidIdentifier(name, 'queue table');
|
|
10
|
-
return
|
|
133
|
+
return name;
|
|
11
134
|
}
|
|
12
135
|
/**
|
|
13
136
|
* Raises `job-dead-letter:<jobType>` for jobs that exhausted their attempts since the last
|
|
@@ -15,6 +138,7 @@ function jobsTable(table) {
|
|
|
15
138
|
* evaluation starts from now, so history is not replayed). The condition clears only by the
|
|
16
139
|
* handle's quiet period, so set `resolveAfterQuietMs` on `alerts.create`. `types` limits it to those job
|
|
17
140
|
* types and `excludeTypes` leaves them out (not both, neither empty); each filter keeps its own position.
|
|
141
|
+
* Jobs are read `settleMs` (default 30_000) after they dead-letter, so one that commits late is not missed.
|
|
18
142
|
* Conditions carry `owner`, `runbook` and a context of `count` and `lastError`.
|
|
19
143
|
*/
|
|
20
144
|
export function deadLetterSource(options = {}) {
|
|
@@ -27,7 +151,6 @@ export function deadLetterSource(options = {}) {
|
|
|
27
151
|
}
|
|
28
152
|
}
|
|
29
153
|
const jobs = jobsTable(options.table);
|
|
30
|
-
const severity = options.severity ?? 'error';
|
|
31
154
|
const name = 'job-dead-letter';
|
|
32
155
|
const mode = options.types !== undefined ? 'only' : options.excludeTypes !== undefined ? 'except' : null;
|
|
33
156
|
const listed = [...new Set(options.types ?? options.excludeTypes ?? [])].sort();
|
|
@@ -35,65 +158,47 @@ export function deadLetterSource(options = {}) {
|
|
|
35
158
|
const stateKey = mode === null ? name
|
|
36
159
|
: `${name}:${mode}:${joined.length > 200 ? createHash('sha256').update(joined).digest('hex') : joined}`;
|
|
37
160
|
const listJson = JSON.stringify(listed);
|
|
38
|
-
const typeFilter = mode === 'only' ? sql `AND
|
|
39
|
-
: mode === 'except' ? sql `AND
|
|
161
|
+
const typeFilter = mode === 'only' ? sql `AND t.job_type IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
|
|
162
|
+
: mode === 'except' ? sql `AND t.job_type NOT IN (SELECT jsonb_array_elements_text(${listJson}::jsonb))`
|
|
40
163
|
: sql ``;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const first = await db.execute(sql `
|
|
46
|
-
INSERT INTO pipework_alert_source_state (name, cursor_at) VALUES (${stateKey}, clock_timestamp())
|
|
47
|
-
ON CONFLICT (name) DO NOTHING RETURNING name`);
|
|
48
|
-
if (first.length > 0)
|
|
49
|
-
return [];
|
|
50
|
-
const rows = await db.execute(sql `
|
|
51
|
-
WITH cur AS (SELECT cursor_at FROM pipework_alert_source_state WHERE name = ${stateKey} FOR UPDATE),
|
|
52
|
-
found AS (
|
|
53
|
-
SELECT j.job_type, count(*)::int AS n, max(j.completed_at) AS hi,
|
|
54
|
-
(array_agg(j.error_payload->>'message' ORDER BY j.completed_at DESC))[1] AS last_error
|
|
55
|
-
FROM ${sql.raw(jobs)} j, cur
|
|
56
|
-
WHERE j.status = 'dead_letter' AND j.completed_at > cur.cursor_at ${typeFilter}
|
|
57
|
-
GROUP BY j.job_type),
|
|
58
|
-
moved AS (
|
|
59
|
-
UPDATE pipework_alert_source_state s SET cursor_at = GREATEST(s.cursor_at, (SELECT max(hi) FROM found))
|
|
60
|
-
WHERE s.name = ${stateKey} AND EXISTS (SELECT 1 FROM found) RETURNING 1)
|
|
61
|
-
SELECT job_type, n, last_error, (SELECT count(*) FROM moved) AS moved FROM found ORDER BY job_type`);
|
|
62
|
-
return rows.map(r => ({
|
|
63
|
-
key: `job-dead-letter:${String(r['job_type'])}`,
|
|
64
|
-
severity,
|
|
65
|
-
message: `${String(r['n'])} ${String(r['job_type'])} job(s) dead-lettered`
|
|
66
|
-
+ (r['last_error'] !== null && r['last_error'] !== undefined ? `; last error: ${String(r['last_error'])}` : ''),
|
|
67
|
-
subject: { entity: 'job', entityId: String(r['job_type']) },
|
|
68
|
-
...(options.owner !== undefined ? { owner: options.owner } : {}),
|
|
69
|
-
...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
|
|
70
|
-
context: { count: Number(r['n']), lastError: r['last_error'] === null || r['last_error'] === undefined ? null : String(r['last_error']) },
|
|
71
|
-
}));
|
|
72
|
-
},
|
|
164
|
+
const lastError = (g) => {
|
|
165
|
+
const payload = g.last['error_payload'];
|
|
166
|
+
const message = typeof payload === 'object' && payload !== null ? payload['message'] : undefined;
|
|
167
|
+
return message === null || message === undefined ? null : String(message);
|
|
73
168
|
};
|
|
169
|
+
return newRowsSource({
|
|
170
|
+
name,
|
|
171
|
+
stateKey,
|
|
172
|
+
table: jobs,
|
|
173
|
+
at: 'completed_at',
|
|
174
|
+
where: sql `t.status = 'dead_letter' ${typeFilter}`,
|
|
175
|
+
groupBy: 'job_type',
|
|
176
|
+
...(options.settleMs !== undefined ? { settleMs: options.settleMs } : {}),
|
|
177
|
+
...(options.severity !== undefined ? { severity: options.severity } : {}),
|
|
178
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
179
|
+
...ownerFields(options),
|
|
180
|
+
message: g => `${String(g.count)} ${g.id} job(s) dead-lettered` + (lastError(g) !== null ? `; last error: ${lastError(g)}` : ''),
|
|
181
|
+
subject: g => ({ entity: 'job', entityId: g.id }),
|
|
182
|
+
context: g => ({ count: g.count, lastError: lastError(g) }),
|
|
183
|
+
});
|
|
74
184
|
}
|
|
75
185
|
/** Raises `job-long-running:<jobType>` while a job of that type has been claimed for longer than `olderThanMs`; resolves when none is. */
|
|
76
186
|
export function longRunningSource(options) {
|
|
77
|
-
const jobs = jobsTable(options.table);
|
|
78
|
-
|
|
79
|
-
return {
|
|
187
|
+
const jobs = quoteIdentifier(jobsTable(options.table), 'queue table');
|
|
188
|
+
return querySource({
|
|
80
189
|
name: 'job-long-running',
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
subject: { entity: 'job', entityId: String(r['job_type']) },
|
|
94
|
-
}));
|
|
95
|
-
},
|
|
96
|
-
};
|
|
190
|
+
severity: options.severity ?? 'warning',
|
|
191
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
192
|
+
query: sql `
|
|
193
|
+
SELECT job_type, count(*)::int AS n, (extract(epoch FROM clock_timestamp() - min(claimed_at)) * 1000)::bigint AS oldest_ms
|
|
194
|
+
FROM ${sql.raw(jobs)}
|
|
195
|
+
WHERE status IN ('claimed', 'running')
|
|
196
|
+
AND claimed_at < clock_timestamp() - ${options.olderThanMs} * interval '1 millisecond'
|
|
197
|
+
GROUP BY job_type ORDER BY job_type`,
|
|
198
|
+
id: r => String(r['job_type']),
|
|
199
|
+
message: r => `${String(r['n'])} ${String(r['job_type'])} job(s) running for more than ${String(options.olderThanMs)} ms (oldest ${String(r['oldest_ms'])} ms)`,
|
|
200
|
+
subject: r => ({ entity: 'job', entityId: String(r['job_type']) }),
|
|
201
|
+
});
|
|
97
202
|
}
|
|
98
203
|
/**
|
|
99
204
|
* Raises `queue-backlog:<jobType>` while more than `maxPending` jobs of a type are ready to claim, or the
|
|
@@ -104,40 +209,31 @@ export function backlogSource(options) {
|
|
|
104
209
|
if (options.maxPending === undefined && options.maxOldestAgeMs === undefined) {
|
|
105
210
|
throw new Error('[pipework] alerts.source.backlog needs maxPending, maxOldestAgeMs or both.');
|
|
106
211
|
}
|
|
107
|
-
const jobs = jobsTable(options.table);
|
|
108
|
-
const severity = options.severity ?? 'warning';
|
|
212
|
+
const jobs = quoteIdentifier(jobsTable(options.table), 'queue table');
|
|
109
213
|
const maxPending = options.maxPending ?? null;
|
|
110
214
|
const maxAge = options.maxOldestAgeMs ?? null;
|
|
111
|
-
return {
|
|
215
|
+
return querySource({
|
|
112
216
|
name: 'queue-backlog',
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
subject: { entity: 'job', entityId: String(r['job_type']) },
|
|
134
|
-
...(options.owner !== undefined ? { owner: options.owner } : {}),
|
|
135
|
-
...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
|
|
136
|
-
context: { ready: Number(r['n']), running: Number(r['running']), oldestReadyWaitMs: oldest },
|
|
137
|
-
};
|
|
138
|
-
});
|
|
139
|
-
},
|
|
140
|
-
};
|
|
217
|
+
severity: options.severity ?? 'warning',
|
|
218
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
219
|
+
...ownerFields(options),
|
|
220
|
+
query: sql `
|
|
221
|
+
SELECT job_type, n, running, oldest_ms FROM (
|
|
222
|
+
SELECT job_type,
|
|
223
|
+
(count(*) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp())))::int AS n,
|
|
224
|
+
(count(*) FILTER (WHERE status IN ('claimed', 'running')))::int AS running,
|
|
225
|
+
(extract(epoch FROM clock_timestamp() - min(queued_at) FILTER (WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= clock_timestamp()))) * 1000)::bigint AS oldest_ms
|
|
226
|
+
FROM ${sql.raw(jobs)}
|
|
227
|
+
WHERE status IN ('pending', 'claimed', 'running')
|
|
228
|
+
GROUP BY job_type) p
|
|
229
|
+
WHERE (${maxPending}::int IS NOT NULL AND n > ${maxPending}::int)
|
|
230
|
+
OR (${maxAge}::bigint IS NOT NULL AND oldest_ms > ${maxAge}::bigint)
|
|
231
|
+
ORDER BY job_type`,
|
|
232
|
+
id: r => String(r['job_type']),
|
|
233
|
+
message: r => `${String(r['n'])} ${String(r['job_type'])} job(s) waiting (${String(r['running'])} running), the oldest for ${String(Number(r['oldest_ms']))} ms`,
|
|
234
|
+
subject: r => ({ entity: 'job', entityId: String(r['job_type']) }),
|
|
235
|
+
context: r => ({ ready: Number(r['n']), running: Number(r['running']), oldestReadyWaitMs: Number(r['oldest_ms']) }),
|
|
236
|
+
});
|
|
141
237
|
}
|
|
142
238
|
/** Raises `health:<name>` while a check reports unhealthy or throws; resolves when it is healthy again. Degraded is not a fault. */
|
|
143
239
|
export function healthSource(checks, options = {}) {
|
|
@@ -177,47 +273,36 @@ export function unitFaultSource(options) {
|
|
|
177
273
|
if (options.units !== undefined && options.units.length === 0) {
|
|
178
274
|
throw new Error('[pipework] alerts.source.unitFault units must not be empty; omit it to watch every unit.');
|
|
179
275
|
}
|
|
180
|
-
const severity = options.severity ?? 'error';
|
|
181
276
|
const units = options.units === undefined ? null : JSON.stringify(options.units);
|
|
182
|
-
|
|
277
|
+
const text = (v) => v === null || v === undefined ? null : String(v);
|
|
278
|
+
return querySource({
|
|
183
279
|
name: 'unit-fault',
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
count,
|
|
211
|
-
lastResult,
|
|
212
|
-
lastExitCode: text(r['last_exit_code']),
|
|
213
|
-
lastExitStatus: text(r['last_exit_status']),
|
|
214
|
-
firstAt: new Date(r['first_at']).toISOString(),
|
|
215
|
-
lastAt: new Date(r['last_at']).toISOString(),
|
|
216
|
-
},
|
|
217
|
-
};
|
|
218
|
-
});
|
|
219
|
-
},
|
|
220
|
-
};
|
|
280
|
+
severity: options.severity ?? 'error',
|
|
281
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
282
|
+
...ownerFields(options),
|
|
283
|
+
query: sql `
|
|
284
|
+
SELECT unit, count(*)::int AS n,
|
|
285
|
+
(array_agg(result ORDER BY at DESC))[1] AS last_result,
|
|
286
|
+
(array_agg(exit_code ORDER BY at DESC))[1] AS last_exit_code,
|
|
287
|
+
(array_agg(exit_status ORDER BY at DESC))[1] AS last_exit_status,
|
|
288
|
+
min(at) AS first_at, max(at) AS last_at,
|
|
289
|
+
ceil(extract(epoch FROM clock_timestamp() - min(at)) / 60)::int AS minutes
|
|
290
|
+
FROM pipework_unit_faults
|
|
291
|
+
WHERE at > clock_timestamp() - ${options.window} * interval '1 millisecond'
|
|
292
|
+
AND (${units}::jsonb IS NULL OR unit IN (SELECT jsonb_array_elements_text(${units}::jsonb)))
|
|
293
|
+
GROUP BY unit ORDER BY unit`,
|
|
294
|
+
id: r => String(r['unit']),
|
|
295
|
+
message: r => `${String(r['unit'])}: ${String(r['last_result'])} x${String(Number(r['n']))} in ${String(Math.max(1, Number(r['minutes'])))} min`,
|
|
296
|
+
subject: r => ({ entity: 'unit', entityId: String(r['unit']) }),
|
|
297
|
+
context: r => ({
|
|
298
|
+
count: Number(r['n']),
|
|
299
|
+
lastResult: String(r['last_result']),
|
|
300
|
+
lastExitCode: text(r['last_exit_code']),
|
|
301
|
+
lastExitStatus: text(r['last_exit_status']),
|
|
302
|
+
firstAt: new Date(r['first_at']).toISOString(),
|
|
303
|
+
lastAt: new Date(r['last_at']).toISOString(),
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
221
306
|
}
|
|
222
307
|
const pad2 = (n) => String(n).padStart(2, '0');
|
|
223
308
|
function untilText(reset, now) {
|
|
@@ -240,47 +325,47 @@ export function quotaExhaustedSource(options = {}) {
|
|
|
240
325
|
if (Array.isArray(keys) && keys.length === 0) {
|
|
241
326
|
throw new Error('[pipework] alerts.source.quotaExhausted keys must not be empty; omit it to watch every key.');
|
|
242
327
|
}
|
|
243
|
-
const severity = options.severity ?? 'error';
|
|
244
328
|
const keyFilter = Array.isArray(keys)
|
|
245
329
|
? sql `AND key IN (SELECT jsonb_array_elements_text(${JSON.stringify([...new Set(keys)])}::jsonb))`
|
|
246
330
|
: sql ``;
|
|
247
|
-
|
|
331
|
+
const base = querySource({
|
|
248
332
|
name: 'quota-exhausted',
|
|
249
|
-
|
|
333
|
+
severity: options.severity ?? 'error',
|
|
334
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
335
|
+
...ownerFields(options),
|
|
336
|
+
query: sql `
|
|
337
|
+
SELECT key, remaining, "limit", reset_at,
|
|
338
|
+
(remaining > ${floor} AND refused_at IS NOT NULL AND refused_at >= updated_at) AS refused_only,
|
|
339
|
+
(refused_at IS NOT NULL AND refused_at >= updated_at) AS refused
|
|
340
|
+
FROM outbound_quota
|
|
341
|
+
WHERE reset_at > now() ${keyFilter}
|
|
342
|
+
AND (remaining <= ${floor} OR (refused_at IS NOT NULL AND refused_at >= updated_at))
|
|
343
|
+
ORDER BY key`,
|
|
344
|
+
id: r => String(r['key']),
|
|
345
|
+
message: r => {
|
|
346
|
+
const remaining = Number(r['remaining']);
|
|
347
|
+
const limit = r['limit'] === null ? null : Number(r['limit']);
|
|
348
|
+
const head = limit === null ? `${String(r['key'])} ${String(remaining)} remaining` : `${String(r['key'])} ${String(remaining)}/${String(limit)}`;
|
|
349
|
+
return `${head} until ${untilText(new Date(r['reset_at']), new Date())}${r['refused_only'] === true ? '; a call was refused' : ''}`;
|
|
350
|
+
},
|
|
351
|
+
subject: r => ({ entity: 'quota', entityId: String(r['key']) }),
|
|
352
|
+
context: r => ({
|
|
353
|
+
key: String(r['key']),
|
|
354
|
+
remaining: Number(r['remaining']),
|
|
355
|
+
limit: r['limit'] === null ? null : Number(r['limit']),
|
|
356
|
+
resetAt: new Date(r['reset_at']).toISOString(),
|
|
357
|
+
refused: r['refused'] === true,
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
if (!(keys instanceof RegExp))
|
|
361
|
+
return base;
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
250
364
|
async evaluate() {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
FROM outbound_quota
|
|
256
|
-
WHERE reset_at > now() ${keyFilter}
|
|
257
|
-
AND (remaining <= ${floor} OR (refused_at IS NOT NULL AND refused_at >= updated_at))
|
|
258
|
-
ORDER BY key`);
|
|
259
|
-
const now = new Date();
|
|
260
|
-
const out = [];
|
|
261
|
-
for (const r of rows) {
|
|
262
|
-
const key = String(r['key']);
|
|
263
|
-
if (keys instanceof RegExp) {
|
|
264
|
-
keys.lastIndex = 0;
|
|
265
|
-
if (!keys.test(key))
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
const remaining = Number(r['remaining']);
|
|
269
|
-
const limit = r['limit'] === null ? null : Number(r['limit']);
|
|
270
|
-
const resetAt = new Date(r['reset_at']);
|
|
271
|
-
const refusedOnly = r['refused_only'] === true;
|
|
272
|
-
const head = limit === null ? `${key} ${String(remaining)} remaining` : `${key} ${String(remaining)}/${String(limit)}`;
|
|
273
|
-
out.push({
|
|
274
|
-
key: `quota-exhausted:${key}`,
|
|
275
|
-
severity,
|
|
276
|
-
message: `${head} until ${untilText(resetAt, now)}${refusedOnly ? '; a call was refused' : ''}`,
|
|
277
|
-
subject: { entity: 'quota', entityId: key },
|
|
278
|
-
...(options.owner !== undefined ? { owner: options.owner } : {}),
|
|
279
|
-
...(options.runbook !== undefined ? { runbook: options.runbook } : {}),
|
|
280
|
-
context: { key, remaining, limit, resetAt: resetAt.toISOString(), refused: r['refused'] === true },
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
return out;
|
|
365
|
+
return (await base.evaluate()).filter(c => {
|
|
366
|
+
keys.lastIndex = 0;
|
|
367
|
+
return keys.test(c.key.slice('quota-exhausted:'.length));
|
|
368
|
+
});
|
|
284
369
|
},
|
|
285
370
|
};
|
|
286
371
|
}
|