deepline 0.3.105 → 0.3.107
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/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/observability/queue-health.ts +678 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +424 -70
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
- package/dist/bundling-sources/shared_libs/observability/scheduled-work-v2.ts +380 -0
- package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +718 -0
- package/dist/cli/index.js +24 -8
- package/dist/cli/index.mjs +24 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +3 -0
- package/dist/release.d.mts +1 -1
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -1
- package/dist/release.mjs +1 -1
- package/package.json +1 -1
|
@@ -200,7 +200,7 @@ export const SDK_RELEASE = {
|
|
|
200
200
|
// getters keep their established compatibility behavior.
|
|
201
201
|
// 0.3.1 deprecated the legacy `deepline enrich` command in favor of Plays.
|
|
202
202
|
// 0.3.90 is the first deliberately versioned SDK release for API v3.
|
|
203
|
-
version: '0.3.
|
|
203
|
+
version: '0.3.107',
|
|
204
204
|
updateSummary:
|
|
205
205
|
'Play grep now returns concise results with the matched fields and terms. Automatic CLI updates are enabled by default; use `deepline settings autoupdate off` to opt out, `deepline settings autoupdate on` to re-enable updates, or `deepline settings autoupdate pin <version>` to hold an exact release.',
|
|
206
206
|
packageCapabilities: {
|
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/** Scheduler-neutral queue/workset health contract. */
|
|
2
|
+
|
|
3
|
+
import { measuredValue, type Measured } from './scheduled-work-v2';
|
|
4
|
+
export type { Measured } from './scheduled-work-v2';
|
|
5
|
+
export {
|
|
6
|
+
exact,
|
|
7
|
+
lowerBound,
|
|
8
|
+
measuredValue,
|
|
9
|
+
notApplicable,
|
|
10
|
+
unavailable,
|
|
11
|
+
} from './scheduled-work-v2';
|
|
12
|
+
|
|
13
|
+
export const QUEUE_HEALTH_CONTRACT_VERSION = 2 as const;
|
|
14
|
+
export type QueueKind = 'fifo' | 'leased_workset' | 'cursor' | 'provider_queue';
|
|
15
|
+
|
|
16
|
+
export type QueueDefinition = {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly owner: string;
|
|
19
|
+
readonly purpose: string;
|
|
20
|
+
readonly unit: string;
|
|
21
|
+
readonly kind: QueueKind;
|
|
22
|
+
readonly ageSlo: { readonly warnMs: number; readonly failMs: number };
|
|
23
|
+
/** How old a snapshot may be before it is no longer evidence of health. */
|
|
24
|
+
readonly staleAfterMs?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type QueueCatalogEntry = QueueDefinition & {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** Human-readable policy rendered beside warning signals. */
|
|
30
|
+
readonly warningDefinition: string;
|
|
31
|
+
/** Human-readable policy rendered beside failure signals. */
|
|
32
|
+
readonly failureDefinition: string;
|
|
33
|
+
/** A dashboard must render every one of these fields, even when N/A. */
|
|
34
|
+
readonly requiredMetrics: readonly [
|
|
35
|
+
'depth.ready',
|
|
36
|
+
'depth.delayed',
|
|
37
|
+
'depth.inFlight',
|
|
38
|
+
'depth.blocked',
|
|
39
|
+
'depth.deadLettered',
|
|
40
|
+
'oldestAgeMs.ready',
|
|
41
|
+
'oldestAgeMs.delayed',
|
|
42
|
+
'oldestAgeMs.inFlight',
|
|
43
|
+
'oldestAgeMs.blocked',
|
|
44
|
+
'oldestAgeMs.deadLettered',
|
|
45
|
+
'flow.admitted',
|
|
46
|
+
'flow.started',
|
|
47
|
+
'flow.completed',
|
|
48
|
+
'flow.failed',
|
|
49
|
+
'flow.retried',
|
|
50
|
+
'flow.poisoned',
|
|
51
|
+
];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const requiredQueueMetrics: QueueCatalogEntry['requiredMetrics'] = [
|
|
55
|
+
'depth.ready',
|
|
56
|
+
'depth.delayed',
|
|
57
|
+
'depth.inFlight',
|
|
58
|
+
'depth.blocked',
|
|
59
|
+
'depth.deadLettered',
|
|
60
|
+
'oldestAgeMs.ready',
|
|
61
|
+
'oldestAgeMs.delayed',
|
|
62
|
+
'oldestAgeMs.inFlight',
|
|
63
|
+
'oldestAgeMs.blocked',
|
|
64
|
+
'oldestAgeMs.deadLettered',
|
|
65
|
+
'flow.admitted',
|
|
66
|
+
'flow.started',
|
|
67
|
+
'flow.completed',
|
|
68
|
+
'flow.failed',
|
|
69
|
+
'flow.retried',
|
|
70
|
+
'flow.poisoned',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
function queue(
|
|
74
|
+
id: string,
|
|
75
|
+
name: string,
|
|
76
|
+
kind: QueueKind,
|
|
77
|
+
owner: string,
|
|
78
|
+
purpose: string,
|
|
79
|
+
warnMs: number,
|
|
80
|
+
failMs: number,
|
|
81
|
+
): QueueCatalogEntry {
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
name,
|
|
85
|
+
kind,
|
|
86
|
+
owner,
|
|
87
|
+
purpose,
|
|
88
|
+
unit: 'items',
|
|
89
|
+
ageSlo: { warnMs, failMs },
|
|
90
|
+
warningDefinition: `Oldest work is at least ${warnMs} ms old, or a bounded observer can only prove a lower bound.`,
|
|
91
|
+
failureDefinition: `Blocked or dead-lettered work exists, oldest work is at least ${failMs} ms old, or the observation is invalid or unavailable.`,
|
|
92
|
+
requiredMetrics: requiredQueueMetrics,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The durable work inventory used by the operations dashboard. This is a
|
|
98
|
+
* catalog, not a claim that every adapter already exists: an unobserved row is
|
|
99
|
+
* intentionally unhealthy until its Adapter publishes an observation.
|
|
100
|
+
*/
|
|
101
|
+
export const OPERATIONS_QUEUE_CATALOG = [
|
|
102
|
+
queue(
|
|
103
|
+
'play-runtime-tasks',
|
|
104
|
+
'Play runtime tasks',
|
|
105
|
+
'fifo',
|
|
106
|
+
'runtime',
|
|
107
|
+
'Run admitted Play tasks to terminal state',
|
|
108
|
+
60_000,
|
|
109
|
+
180_000,
|
|
110
|
+
),
|
|
111
|
+
queue(
|
|
112
|
+
'run-ledger-projector-outbox',
|
|
113
|
+
'Run Ledger projector outbox',
|
|
114
|
+
'leased_workset',
|
|
115
|
+
'runtime',
|
|
116
|
+
'Deliver causal run events to control-plane projections',
|
|
117
|
+
60_000,
|
|
118
|
+
180_000,
|
|
119
|
+
),
|
|
120
|
+
queue(
|
|
121
|
+
'play-webhook-receipts',
|
|
122
|
+
'Play webhook receipts',
|
|
123
|
+
'leased_workset',
|
|
124
|
+
'runtime',
|
|
125
|
+
'Admit authenticated webhook receipts exactly once',
|
|
126
|
+
300_000,
|
|
127
|
+
900_000,
|
|
128
|
+
),
|
|
129
|
+
queue(
|
|
130
|
+
'workflow-cron-bindings',
|
|
131
|
+
'Workflow cron bindings',
|
|
132
|
+
'leased_workset',
|
|
133
|
+
'workflows',
|
|
134
|
+
'Turn due bindings into terminal workflow runs',
|
|
135
|
+
120_000,
|
|
136
|
+
3_600_000,
|
|
137
|
+
),
|
|
138
|
+
queue(
|
|
139
|
+
'play-cron-bindings',
|
|
140
|
+
'Play cron bindings',
|
|
141
|
+
'leased_workset',
|
|
142
|
+
'workflows',
|
|
143
|
+
'Admit due Play triggers and resulting runs',
|
|
144
|
+
120_000,
|
|
145
|
+
300_000,
|
|
146
|
+
),
|
|
147
|
+
queue(
|
|
148
|
+
'ingestion-jobs',
|
|
149
|
+
'Ingestion jobs',
|
|
150
|
+
'provider_queue',
|
|
151
|
+
'ingestion',
|
|
152
|
+
'Launch and complete tenant ingestion jobs',
|
|
153
|
+
300_000,
|
|
154
|
+
600_000,
|
|
155
|
+
),
|
|
156
|
+
queue(
|
|
157
|
+
'monitor-fleet-convergence',
|
|
158
|
+
'Monitor fleet convergence',
|
|
159
|
+
'leased_workset',
|
|
160
|
+
'monitors',
|
|
161
|
+
'Converge desired monitor definitions to provider truth',
|
|
162
|
+
120_000,
|
|
163
|
+
300_000,
|
|
164
|
+
),
|
|
165
|
+
queue(
|
|
166
|
+
'monitor-deployment-jobs',
|
|
167
|
+
'Monitor deployment jobs',
|
|
168
|
+
'leased_workset',
|
|
169
|
+
'monitors',
|
|
170
|
+
'Settle and verify monitor deployment jobs',
|
|
171
|
+
180_000,
|
|
172
|
+
600_000,
|
|
173
|
+
),
|
|
174
|
+
queue(
|
|
175
|
+
'monitor-batch-operations',
|
|
176
|
+
'Monitor batch operations',
|
|
177
|
+
'cursor',
|
|
178
|
+
'monitors',
|
|
179
|
+
'Apply and terminalize provider batch operation pages',
|
|
180
|
+
600_000,
|
|
181
|
+
1_800_000,
|
|
182
|
+
),
|
|
183
|
+
queue(
|
|
184
|
+
'provider-monitor-reconciliation',
|
|
185
|
+
'Provider-monitor reconciliation',
|
|
186
|
+
'cursor',
|
|
187
|
+
'monitors',
|
|
188
|
+
'Scan and align provider monitor inventory',
|
|
189
|
+
1_800_000,
|
|
190
|
+
3_600_000,
|
|
191
|
+
),
|
|
192
|
+
queue(
|
|
193
|
+
'signal-radar-inbound-dlq',
|
|
194
|
+
'Signal-radar inbound / DLQ',
|
|
195
|
+
'provider_queue',
|
|
196
|
+
'monitors',
|
|
197
|
+
'Persist inbound provider events and isolate poison events',
|
|
198
|
+
300_000,
|
|
199
|
+
900_000,
|
|
200
|
+
),
|
|
201
|
+
queue(
|
|
202
|
+
'charge-reconciliation-backlog',
|
|
203
|
+
'Charge reconciliation backlog',
|
|
204
|
+
'cursor',
|
|
205
|
+
'billing',
|
|
206
|
+
'Settle or release reservations from provider truth',
|
|
207
|
+
1_800_000,
|
|
208
|
+
7_200_000,
|
|
209
|
+
),
|
|
210
|
+
queue(
|
|
211
|
+
'workspace-fast-settlement',
|
|
212
|
+
'Per-workspace fast settlement',
|
|
213
|
+
'fifo',
|
|
214
|
+
'billing',
|
|
215
|
+
'Complete isolated workspace settlements promptly',
|
|
216
|
+
30_000,
|
|
217
|
+
60_000,
|
|
218
|
+
),
|
|
219
|
+
queue(
|
|
220
|
+
'metronome-usage-delivery',
|
|
221
|
+
'Metronome usage delivery',
|
|
222
|
+
'leased_workset',
|
|
223
|
+
'billing',
|
|
224
|
+
'Deliver commercial usage to Metronome projections',
|
|
225
|
+
120_000,
|
|
226
|
+
600_000,
|
|
227
|
+
),
|
|
228
|
+
queue(
|
|
229
|
+
'billing-operations',
|
|
230
|
+
'Billing operations',
|
|
231
|
+
'leased_workset',
|
|
232
|
+
'billing',
|
|
233
|
+
'Reach authoritative applied or aborted outcomes',
|
|
234
|
+
300_000,
|
|
235
|
+
900_000,
|
|
236
|
+
),
|
|
237
|
+
queue(
|
|
238
|
+
'stripe-financial-inbox',
|
|
239
|
+
'Stripe financial inbox',
|
|
240
|
+
'leased_workset',
|
|
241
|
+
'billing',
|
|
242
|
+
'Process and acknowledge Stripe financial events',
|
|
243
|
+
300_000,
|
|
244
|
+
900_000,
|
|
245
|
+
),
|
|
246
|
+
queue(
|
|
247
|
+
'async-usage-recovery',
|
|
248
|
+
'Async usage recovery',
|
|
249
|
+
'leased_workset',
|
|
250
|
+
'billing',
|
|
251
|
+
'Reconcile provider jobs into settlement',
|
|
252
|
+
600_000,
|
|
253
|
+
3_600_000,
|
|
254
|
+
),
|
|
255
|
+
queue(
|
|
256
|
+
'notification-deliveries',
|
|
257
|
+
'Notification deliveries',
|
|
258
|
+
'leased_workset',
|
|
259
|
+
'notifications',
|
|
260
|
+
'Send eligible product notifications',
|
|
261
|
+
120_000,
|
|
262
|
+
300_000,
|
|
263
|
+
),
|
|
264
|
+
queue(
|
|
265
|
+
'usage-rollup-fold',
|
|
266
|
+
'Usage rollup fold',
|
|
267
|
+
'cursor',
|
|
268
|
+
'billing',
|
|
269
|
+
'Fold usage events into projections',
|
|
270
|
+
120_000,
|
|
271
|
+
300_000,
|
|
272
|
+
),
|
|
273
|
+
queue(
|
|
274
|
+
'play-deployment-jobs',
|
|
275
|
+
'Play deployment jobs',
|
|
276
|
+
'leased_workset',
|
|
277
|
+
'runtime',
|
|
278
|
+
'Build and activate Play deployments',
|
|
279
|
+
300_000,
|
|
280
|
+
900_000,
|
|
281
|
+
),
|
|
282
|
+
queue(
|
|
283
|
+
'github-runner-wait',
|
|
284
|
+
'GitHub runner wait',
|
|
285
|
+
'provider_queue',
|
|
286
|
+
'ci',
|
|
287
|
+
'Start and complete queued GitHub jobs',
|
|
288
|
+
300_000,
|
|
289
|
+
900_000,
|
|
290
|
+
),
|
|
291
|
+
queue(
|
|
292
|
+
'sandbox-cleanup-jobs',
|
|
293
|
+
'Sandbox cleanup jobs',
|
|
294
|
+
'leased_workset',
|
|
295
|
+
'runtime',
|
|
296
|
+
'Complete durable sandbox deletion obligations',
|
|
297
|
+
300_000,
|
|
298
|
+
900_000,
|
|
299
|
+
),
|
|
300
|
+
] as const satisfies readonly QueueCatalogEntry[];
|
|
301
|
+
|
|
302
|
+
export type OperationsQueueId = (typeof OPERATIONS_QUEUE_CATALOG)[number]['id'];
|
|
303
|
+
|
|
304
|
+
export function validateQueueCatalog(
|
|
305
|
+
catalog: readonly QueueCatalogEntry[],
|
|
306
|
+
): void {
|
|
307
|
+
const ids = new Set<string>();
|
|
308
|
+
for (const entry of catalog) {
|
|
309
|
+
validateQueueDefinition(entry);
|
|
310
|
+
if (ids.has(entry.id)) throw new Error(`duplicate queue id: ${entry.id}`);
|
|
311
|
+
ids.add(entry.id);
|
|
312
|
+
if (entry.requiredMetrics.length !== requiredQueueMetrics.length) {
|
|
313
|
+
throw new Error(`${entry.id}: required metric declaration is incomplete`);
|
|
314
|
+
}
|
|
315
|
+
if (!entry.warningDefinition.trim() || !entry.failureDefinition.trim()) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`${entry.id}: warning and failure definitions are required`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type QueueDepth = {
|
|
324
|
+
readonly ready: Measured<number>;
|
|
325
|
+
readonly delayed: Measured<number>;
|
|
326
|
+
readonly inFlight: Measured<number>;
|
|
327
|
+
readonly blocked: Measured<number>;
|
|
328
|
+
readonly deadLettered: Measured<number>;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export type QueueOldestAge = {
|
|
332
|
+
readonly ready: Measured<number>;
|
|
333
|
+
readonly delayed: Measured<number>;
|
|
334
|
+
readonly inFlight: Measured<number>;
|
|
335
|
+
readonly blocked: Measured<number>;
|
|
336
|
+
readonly deadLettered: Measured<number>;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export type QueueFlow = {
|
|
340
|
+
readonly admitted: Measured<number>;
|
|
341
|
+
readonly started: Measured<number>;
|
|
342
|
+
readonly completed: Measured<number>;
|
|
343
|
+
readonly failed: Measured<number>;
|
|
344
|
+
readonly retried: Measured<number>;
|
|
345
|
+
readonly poisoned: Measured<number>;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export type QueueObservation = {
|
|
349
|
+
readonly observedAt: number;
|
|
350
|
+
readonly depth: QueueDepth;
|
|
351
|
+
readonly oldestAgeMs: QueueOldestAge;
|
|
352
|
+
readonly flow: QueueFlow;
|
|
353
|
+
readonly adapter: string;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
function combineMeasurements(
|
|
357
|
+
observations: readonly QueueObservation[],
|
|
358
|
+
select: (observation: QueueObservation) => Measured<number>,
|
|
359
|
+
mode: 'sum' | 'max',
|
|
360
|
+
): Measured<number> {
|
|
361
|
+
const measurements = observations.map(select);
|
|
362
|
+
if (measurements.some((measurement) => measurement.kind === 'unavailable')) {
|
|
363
|
+
return {
|
|
364
|
+
kind: 'unavailable',
|
|
365
|
+
reason: 'At least one constituent queue measurement is unavailable.',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const values = measurements.flatMap((measurement) =>
|
|
369
|
+
measurement.kind === 'exact' || measurement.kind === 'lower_bound'
|
|
370
|
+
? [measurement.value]
|
|
371
|
+
: [],
|
|
372
|
+
);
|
|
373
|
+
if (values.length === 0) {
|
|
374
|
+
return {
|
|
375
|
+
kind: 'not_applicable',
|
|
376
|
+
reason: 'No constituent queue exposes this measurement.',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const value =
|
|
380
|
+
mode === 'sum'
|
|
381
|
+
? values.reduce((total, current) => total + current, 0)
|
|
382
|
+
: Math.max(...values);
|
|
383
|
+
return measurements.some((measurement) => measurement.kind === 'lower_bound')
|
|
384
|
+
? { kind: 'lower_bound', value }
|
|
385
|
+
: { kind: 'exact', value };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Combine sharded/provider-native queues into one catalog queue contract. */
|
|
389
|
+
export function combineQueueObservations(input: {
|
|
390
|
+
observations: readonly QueueObservation[];
|
|
391
|
+
observedAt: number;
|
|
392
|
+
adapter: string;
|
|
393
|
+
}): QueueObservation {
|
|
394
|
+
if (input.observations.length === 0) {
|
|
395
|
+
throw new Error('At least one queue observation is required.');
|
|
396
|
+
}
|
|
397
|
+
const sum = (select: (observation: QueueObservation) => Measured<number>) =>
|
|
398
|
+
combineMeasurements(input.observations, select, 'sum');
|
|
399
|
+
const max = (select: (observation: QueueObservation) => Measured<number>) =>
|
|
400
|
+
combineMeasurements(input.observations, select, 'max');
|
|
401
|
+
return {
|
|
402
|
+
observedAt: input.observedAt,
|
|
403
|
+
adapter: input.adapter,
|
|
404
|
+
depth: {
|
|
405
|
+
ready: sum((row) => row.depth.ready),
|
|
406
|
+
delayed: sum((row) => row.depth.delayed),
|
|
407
|
+
inFlight: sum((row) => row.depth.inFlight),
|
|
408
|
+
blocked: sum((row) => row.depth.blocked),
|
|
409
|
+
deadLettered: sum((row) => row.depth.deadLettered),
|
|
410
|
+
},
|
|
411
|
+
oldestAgeMs: {
|
|
412
|
+
ready: max((row) => row.oldestAgeMs.ready),
|
|
413
|
+
delayed: max((row) => row.oldestAgeMs.delayed),
|
|
414
|
+
inFlight: max((row) => row.oldestAgeMs.inFlight),
|
|
415
|
+
blocked: max((row) => row.oldestAgeMs.blocked),
|
|
416
|
+
deadLettered: max((row) => row.oldestAgeMs.deadLettered),
|
|
417
|
+
},
|
|
418
|
+
flow: {
|
|
419
|
+
admitted: sum((row) => row.flow.admitted),
|
|
420
|
+
started: sum((row) => row.flow.started),
|
|
421
|
+
completed: sum((row) => row.flow.completed),
|
|
422
|
+
failed: sum((row) => row.flow.failed),
|
|
423
|
+
retried: sum((row) => row.flow.retried),
|
|
424
|
+
poisoned: sum((row) => row.flow.poisoned),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export type QueueHealthStatus = 'healthy' | 'warning' | 'failed' | 'unobserved';
|
|
430
|
+
export type QueueHealthReason =
|
|
431
|
+
| 'empty'
|
|
432
|
+
| 'draining'
|
|
433
|
+
| 'age_warning'
|
|
434
|
+
| 'age_breached'
|
|
435
|
+
| 'blocked_work'
|
|
436
|
+
| 'dead_lettered_work'
|
|
437
|
+
| 'partial_measurements'
|
|
438
|
+
| 'stale_observation'
|
|
439
|
+
| 'missing_measurements'
|
|
440
|
+
| 'invalid_observation';
|
|
441
|
+
|
|
442
|
+
export type QueueHealth = {
|
|
443
|
+
readonly status: QueueHealthStatus;
|
|
444
|
+
readonly reason: QueueHealthReason;
|
|
445
|
+
readonly worstAgeMs?: number;
|
|
446
|
+
readonly worstAgeRatio?: number;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
export const QUEUE_HEALTH_TELEMETRY_EVENT = 'operations.queue.health' as const;
|
|
450
|
+
export const QUEUE_HEALTH_TELEMETRY_SCHEMA_VERSION = 1 as const;
|
|
451
|
+
|
|
452
|
+
export type QueueHealthSignal = {
|
|
453
|
+
readonly schemaVersion: typeof QUEUE_HEALTH_TELEMETRY_SCHEMA_VERSION;
|
|
454
|
+
readonly event: typeof QUEUE_HEALTH_TELEMETRY_EVENT;
|
|
455
|
+
readonly level: 'info' | 'warn' | 'error';
|
|
456
|
+
readonly signal: 'problem' | 'recovery';
|
|
457
|
+
readonly queueId: string;
|
|
458
|
+
readonly queueName: string;
|
|
459
|
+
readonly owner: string;
|
|
460
|
+
readonly purpose: string;
|
|
461
|
+
readonly kind: QueueKind;
|
|
462
|
+
readonly unit: string;
|
|
463
|
+
readonly environment: string;
|
|
464
|
+
readonly adapter: string;
|
|
465
|
+
readonly observedAt: number;
|
|
466
|
+
readonly health: QueueHealth;
|
|
467
|
+
readonly previousHealth?: QueueHealth;
|
|
468
|
+
readonly warningDefinition: string;
|
|
469
|
+
readonly failureDefinition: string;
|
|
470
|
+
readonly ageWarnMs: number;
|
|
471
|
+
readonly ageFailMs: number;
|
|
472
|
+
readonly depth: QueueDepth;
|
|
473
|
+
readonly oldestAgeMs: QueueOldestAge;
|
|
474
|
+
readonly flow: QueueFlow;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Project a durable observation into the one cross-runtime queue signal.
|
|
479
|
+
* Healthy steady state is deliberately silent. Actionable backlog problems
|
|
480
|
+
* repeat on every observation so a log-based monitor cannot resolve while the
|
|
481
|
+
* queue remains unhealthy; persistent adapter absence is emitted once because
|
|
482
|
+
* the dashboard already keeps that configuration state visible. The first
|
|
483
|
+
* healthy observation after a problem emits recovery.
|
|
484
|
+
*/
|
|
485
|
+
export function deriveQueueHealthSignal(input: {
|
|
486
|
+
definition: QueueCatalogEntry;
|
|
487
|
+
environment: string;
|
|
488
|
+
observation: QueueObservation;
|
|
489
|
+
previousObservation?: QueueObservation;
|
|
490
|
+
now?: number;
|
|
491
|
+
}): QueueHealthSignal | null {
|
|
492
|
+
const now = input.now ?? input.observation.observedAt;
|
|
493
|
+
const health = deriveQueueHealth(input.definition, input.observation, now);
|
|
494
|
+
const previousHealth = input.previousObservation
|
|
495
|
+
? deriveQueueHealth(input.definition, input.previousObservation, now)
|
|
496
|
+
: undefined;
|
|
497
|
+
const recovering =
|
|
498
|
+
health.status === 'healthy' &&
|
|
499
|
+
previousHealth !== undefined &&
|
|
500
|
+
previousHealth.status !== 'healthy';
|
|
501
|
+
if (health.status === 'healthy' && !recovering) return null;
|
|
502
|
+
// A permanently unavailable adapter is a catalog/configuration finding, not
|
|
503
|
+
// a one-minute incident. Emit it once; the dashboard keeps the state visible
|
|
504
|
+
// and a later observation with real evidence emits recovery.
|
|
505
|
+
if (
|
|
506
|
+
health.status === 'unobserved' &&
|
|
507
|
+
previousHealth?.status === 'unobserved' &&
|
|
508
|
+
previousHealth.reason === health.reason
|
|
509
|
+
) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
schemaVersion: QUEUE_HEALTH_TELEMETRY_SCHEMA_VERSION,
|
|
514
|
+
event: QUEUE_HEALTH_TELEMETRY_EVENT,
|
|
515
|
+
level:
|
|
516
|
+
health.status === 'healthy'
|
|
517
|
+
? 'info'
|
|
518
|
+
: health.status === 'warning'
|
|
519
|
+
? 'warn'
|
|
520
|
+
: 'error',
|
|
521
|
+
signal: recovering ? 'recovery' : 'problem',
|
|
522
|
+
queueId: input.definition.id,
|
|
523
|
+
queueName: input.definition.name,
|
|
524
|
+
owner: input.definition.owner,
|
|
525
|
+
purpose: input.definition.purpose,
|
|
526
|
+
kind: input.definition.kind,
|
|
527
|
+
unit: input.definition.unit,
|
|
528
|
+
environment: input.environment,
|
|
529
|
+
adapter: input.observation.adapter,
|
|
530
|
+
observedAt: input.observation.observedAt,
|
|
531
|
+
health,
|
|
532
|
+
...(previousHealth === undefined ? {} : { previousHealth }),
|
|
533
|
+
warningDefinition: input.definition.warningDefinition,
|
|
534
|
+
failureDefinition: input.definition.failureDefinition,
|
|
535
|
+
ageWarnMs: input.definition.ageSlo.warnMs,
|
|
536
|
+
ageFailMs: input.definition.ageSlo.failMs,
|
|
537
|
+
depth: input.observation.depth,
|
|
538
|
+
oldestAgeMs: input.observation.oldestAgeMs,
|
|
539
|
+
flow: input.observation.flow,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function validateQueueDefinition(definition: QueueDefinition): void {
|
|
544
|
+
for (const [name, value] of Object.entries(definition)) {
|
|
545
|
+
if (['ageSlo', 'staleAfterMs', 'requiredMetrics', 'name'].includes(name))
|
|
546
|
+
continue;
|
|
547
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`queue ${definition.id || '<unknown>'}: ${name} is required`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (
|
|
554
|
+
!Number.isFinite(definition.ageSlo.warnMs) ||
|
|
555
|
+
!Number.isFinite(definition.ageSlo.failMs) ||
|
|
556
|
+
definition.ageSlo.warnMs <= 0 ||
|
|
557
|
+
definition.ageSlo.failMs <= definition.ageSlo.warnMs
|
|
558
|
+
)
|
|
559
|
+
throw new Error(
|
|
560
|
+
`${definition.id}: ageSlo must be positive and fail > warn`,
|
|
561
|
+
);
|
|
562
|
+
if (definition.staleAfterMs !== undefined && definition.staleAfterMs <= 0) {
|
|
563
|
+
throw new Error(`${definition.id}: staleAfterMs must be positive`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function validateQueueObservation(
|
|
568
|
+
definition: QueueDefinition,
|
|
569
|
+
observation: QueueObservation,
|
|
570
|
+
): void {
|
|
571
|
+
if (!Number.isFinite(observation.observedAt) || observation.observedAt <= 0) {
|
|
572
|
+
throw new Error(`${definition.id}: observedAt is required`);
|
|
573
|
+
}
|
|
574
|
+
if (!observation.adapter.trim())
|
|
575
|
+
throw new Error(`${definition.id}: adapter is required`);
|
|
576
|
+
const measurements = [
|
|
577
|
+
...Object.values(observation.depth),
|
|
578
|
+
...Object.values(observation.oldestAgeMs),
|
|
579
|
+
...Object.values(observation.flow),
|
|
580
|
+
];
|
|
581
|
+
for (const measurement of measurements) {
|
|
582
|
+
if (
|
|
583
|
+
(measurement.kind === 'exact' || measurement.kind === 'lower_bound') &&
|
|
584
|
+
(!Number.isFinite(measurement.value) || measurement.value < 0)
|
|
585
|
+
)
|
|
586
|
+
throw new Error(
|
|
587
|
+
`${definition.id}: measured values must be finite and non-negative`,
|
|
588
|
+
);
|
|
589
|
+
if (
|
|
590
|
+
(measurement.kind === 'unavailable' ||
|
|
591
|
+
measurement.kind === 'not_applicable') &&
|
|
592
|
+
!measurement.reason.trim()
|
|
593
|
+
)
|
|
594
|
+
throw new Error(`${definition.id}: unavailable values require a reason`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function deriveQueueHealth(
|
|
599
|
+
definition: QueueDefinition,
|
|
600
|
+
observation: QueueObservation | undefined,
|
|
601
|
+
now = Date.now(),
|
|
602
|
+
): QueueHealth {
|
|
603
|
+
if (!observation)
|
|
604
|
+
return { status: 'unobserved', reason: 'stale_observation' };
|
|
605
|
+
try {
|
|
606
|
+
validateQueueObservation(definition, observation);
|
|
607
|
+
} catch {
|
|
608
|
+
return { status: 'failed', reason: 'invalid_observation' };
|
|
609
|
+
}
|
|
610
|
+
const staleAfter = definition.staleAfterMs ?? definition.ageSlo.failMs;
|
|
611
|
+
if (now - observation.observedAt > staleAfter) {
|
|
612
|
+
return { status: 'unobserved', reason: 'stale_observation' };
|
|
613
|
+
}
|
|
614
|
+
const depths = Object.values(observation.depth)
|
|
615
|
+
.map(measuredValue)
|
|
616
|
+
.filter((value): value is number => value !== undefined);
|
|
617
|
+
const ages = Object.values(observation.oldestAgeMs)
|
|
618
|
+
.map(measuredValue)
|
|
619
|
+
.filter((value): value is number => value !== undefined);
|
|
620
|
+
if (depths.length === 0 && ages.length === 0) {
|
|
621
|
+
return { status: 'unobserved', reason: 'missing_measurements' };
|
|
622
|
+
}
|
|
623
|
+
const hasDepth = depths.some((value) => value > 0);
|
|
624
|
+
const deadLettered = measuredValue(observation.depth.deadLettered) ?? 0;
|
|
625
|
+
const blocked = measuredValue(observation.depth.blocked) ?? 0;
|
|
626
|
+
const worstAgeMs = ages.length > 0 ? Math.max(...ages) : undefined;
|
|
627
|
+
const ratio =
|
|
628
|
+
worstAgeMs === undefined
|
|
629
|
+
? undefined
|
|
630
|
+
: worstAgeMs / definition.ageSlo.warnMs;
|
|
631
|
+
if (deadLettered > 0)
|
|
632
|
+
return {
|
|
633
|
+
status: 'failed',
|
|
634
|
+
reason: 'dead_lettered_work',
|
|
635
|
+
worstAgeMs,
|
|
636
|
+
worstAgeRatio: ratio,
|
|
637
|
+
};
|
|
638
|
+
if (blocked > 0)
|
|
639
|
+
return {
|
|
640
|
+
status: 'failed',
|
|
641
|
+
reason: 'blocked_work',
|
|
642
|
+
worstAgeMs,
|
|
643
|
+
worstAgeRatio: ratio,
|
|
644
|
+
};
|
|
645
|
+
if (worstAgeMs !== undefined && worstAgeMs >= definition.ageSlo.failMs) {
|
|
646
|
+
return {
|
|
647
|
+
status: 'failed',
|
|
648
|
+
reason: 'age_breached',
|
|
649
|
+
worstAgeMs,
|
|
650
|
+
worstAgeRatio: ratio,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
if (worstAgeMs !== undefined && worstAgeMs >= definition.ageSlo.warnMs) {
|
|
654
|
+
return {
|
|
655
|
+
status: 'warning',
|
|
656
|
+
reason: 'age_warning',
|
|
657
|
+
worstAgeMs,
|
|
658
|
+
worstAgeRatio: ratio,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
if (
|
|
662
|
+
[
|
|
663
|
+
...Object.values(observation.depth),
|
|
664
|
+
...Object.values(observation.oldestAgeMs),
|
|
665
|
+
].some((measurement) => measurement.kind === 'lower_bound')
|
|
666
|
+
) {
|
|
667
|
+
return {
|
|
668
|
+
status: 'warning',
|
|
669
|
+
reason: 'partial_measurements',
|
|
670
|
+
...(worstAgeMs === undefined ? {} : { worstAgeMs, worstAgeRatio: ratio }),
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
status: 'healthy',
|
|
675
|
+
reason: hasDepth ? 'draining' : 'empty',
|
|
676
|
+
...(worstAgeMs === undefined ? {} : { worstAgeMs, worstAgeRatio: ratio }),
|
|
677
|
+
};
|
|
678
|
+
}
|