@chidchanun/bcp 0.2.5 → 0.2.7
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/README.md +166 -67
- package/docs/README.md +43 -53
- package/docs/api-manifest.json +16 -3
- package/docs/api-reference.md +78 -6
- package/docs/auth-route-guards.md +58 -50
- package/docs/authorization-security.md +298 -0
- package/docs/development-logging.md +44 -1
- package/docs/docs-web-manifest.json +10 -6
- package/docs/observability.md +445 -0
- package/docs/platform-manifest.json +18 -4
- package/docs/releases/0.2.6.md +194 -0
- package/docs/releases/0.2.7.md +205 -0
- package/docs/security.md +55 -1
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +1 -0
- package/packages/client/src/auth.ts +20 -0
- package/packages/client/src/observability.ts +22 -0
- package/packages/client/src/server.ts +16 -0
- package/packages/server/src/auth-guard.ts +111 -15
- package/packages/server/src/authorization.ts +368 -0
- package/packages/server/src/observability.ts +1258 -0
- package/packages/server/src/request-security.ts +596 -0
|
@@ -0,0 +1,1258 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MiddlewarePipelineHandler,
|
|
3
|
+
} from "./middleware.js";
|
|
4
|
+
|
|
5
|
+
export type MetricLabelValue =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean;
|
|
9
|
+
|
|
10
|
+
export type MetricLabels =
|
|
11
|
+
Readonly<Record<
|
|
12
|
+
string,
|
|
13
|
+
MetricLabelValue
|
|
14
|
+
>>;
|
|
15
|
+
|
|
16
|
+
export interface MetricDefinitionOptions {
|
|
17
|
+
help?: string;
|
|
18
|
+
labelNames?: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HistogramOptions
|
|
22
|
+
extends MetricDefinitionOptions {
|
|
23
|
+
buckets?: readonly number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CounterMetric {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
inc(
|
|
29
|
+
value?: number,
|
|
30
|
+
labels?: MetricLabels
|
|
31
|
+
): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GaugeMetric {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
set(
|
|
37
|
+
value: number,
|
|
38
|
+
labels?: MetricLabels
|
|
39
|
+
): void;
|
|
40
|
+
inc(
|
|
41
|
+
value?: number,
|
|
42
|
+
labels?: MetricLabels
|
|
43
|
+
): void;
|
|
44
|
+
dec(
|
|
45
|
+
value?: number,
|
|
46
|
+
labels?: MetricLabels
|
|
47
|
+
): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HistogramMetric {
|
|
51
|
+
readonly name: string;
|
|
52
|
+
observe(
|
|
53
|
+
value: number,
|
|
54
|
+
labels?: MetricLabels
|
|
55
|
+
): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MetricsRegistry {
|
|
59
|
+
counter(
|
|
60
|
+
name: string,
|
|
61
|
+
options?: MetricDefinitionOptions
|
|
62
|
+
): CounterMetric;
|
|
63
|
+
gauge(
|
|
64
|
+
name: string,
|
|
65
|
+
options?: MetricDefinitionOptions
|
|
66
|
+
): GaugeMetric;
|
|
67
|
+
histogram(
|
|
68
|
+
name: string,
|
|
69
|
+
options?: HistogramOptions
|
|
70
|
+
): HistogramMetric;
|
|
71
|
+
metrics(): string;
|
|
72
|
+
reset(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RequestMetricsOptions {
|
|
76
|
+
prefix?: string;
|
|
77
|
+
includeMethod?: boolean;
|
|
78
|
+
includeStatus?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type HealthCheckResult =
|
|
82
|
+
| boolean
|
|
83
|
+
| {
|
|
84
|
+
ok: boolean;
|
|
85
|
+
detail?: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type HealthCheck =
|
|
89
|
+
() =>
|
|
90
|
+
| HealthCheckResult
|
|
91
|
+
| Promise<HealthCheckResult>;
|
|
92
|
+
|
|
93
|
+
export interface HealthCheckOptions {
|
|
94
|
+
timeoutMs?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface HealthCheckReportItem {
|
|
98
|
+
name: string;
|
|
99
|
+
ok: boolean;
|
|
100
|
+
durationMs: number;
|
|
101
|
+
detail?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface HealthReport {
|
|
105
|
+
ok: boolean;
|
|
106
|
+
status: "healthy" | "unhealthy";
|
|
107
|
+
checkedAt: string;
|
|
108
|
+
checks: HealthCheckReportItem[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface HealthRegistry {
|
|
112
|
+
register(
|
|
113
|
+
name: string,
|
|
114
|
+
check: HealthCheck,
|
|
115
|
+
options?: HealthCheckOptions
|
|
116
|
+
): () => void;
|
|
117
|
+
run(): Promise<HealthReport>;
|
|
118
|
+
response(): Promise<Response>;
|
|
119
|
+
clear(): void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface MetricSeries {
|
|
123
|
+
labels: Record<string, string>;
|
|
124
|
+
value: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface HistogramSeries {
|
|
128
|
+
labels: Record<string, string>;
|
|
129
|
+
count: number;
|
|
130
|
+
sum: number;
|
|
131
|
+
buckets: number[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface CounterDefinition {
|
|
135
|
+
type: "counter";
|
|
136
|
+
name: string;
|
|
137
|
+
help: string;
|
|
138
|
+
labelNames: string[];
|
|
139
|
+
series: Map<string, MetricSeries>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface GaugeDefinition {
|
|
143
|
+
type: "gauge";
|
|
144
|
+
name: string;
|
|
145
|
+
help: string;
|
|
146
|
+
labelNames: string[];
|
|
147
|
+
series: Map<string, MetricSeries>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface HistogramDefinition {
|
|
151
|
+
type: "histogram";
|
|
152
|
+
name: string;
|
|
153
|
+
help: string;
|
|
154
|
+
labelNames: string[];
|
|
155
|
+
buckets: number[];
|
|
156
|
+
series: Map<string, HistogramSeries>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type MetricDefinition =
|
|
160
|
+
| CounterDefinition
|
|
161
|
+
| GaugeDefinition
|
|
162
|
+
| HistogramDefinition;
|
|
163
|
+
|
|
164
|
+
const DEFAULT_HISTOGRAM_BUCKETS = [
|
|
165
|
+
0.005,
|
|
166
|
+
0.01,
|
|
167
|
+
0.025,
|
|
168
|
+
0.05,
|
|
169
|
+
0.1,
|
|
170
|
+
0.25,
|
|
171
|
+
0.5,
|
|
172
|
+
1,
|
|
173
|
+
2.5,
|
|
174
|
+
5,
|
|
175
|
+
10,
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
export function createMetricsRegistry(): MetricsRegistry {
|
|
179
|
+
const definitions =
|
|
180
|
+
new Map<string, MetricDefinition>();
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
counter(
|
|
184
|
+
name,
|
|
185
|
+
options = {}
|
|
186
|
+
) {
|
|
187
|
+
const definition =
|
|
188
|
+
getOrCreateSimpleDefinition(
|
|
189
|
+
definitions,
|
|
190
|
+
"counter",
|
|
191
|
+
name,
|
|
192
|
+
options
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
name: definition.name,
|
|
197
|
+
inc(
|
|
198
|
+
value = 1,
|
|
199
|
+
labels = {}
|
|
200
|
+
) {
|
|
201
|
+
assertFiniteNumber(
|
|
202
|
+
value,
|
|
203
|
+
"counter increment"
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (value < 0) {
|
|
207
|
+
throw new TypeError(
|
|
208
|
+
"BCP Observability: counters cannot be decreased."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const series =
|
|
213
|
+
getSimpleSeries(
|
|
214
|
+
definition,
|
|
215
|
+
labels
|
|
216
|
+
);
|
|
217
|
+
series.value += value;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
gauge(
|
|
222
|
+
name,
|
|
223
|
+
options = {}
|
|
224
|
+
) {
|
|
225
|
+
const definition =
|
|
226
|
+
getOrCreateSimpleDefinition(
|
|
227
|
+
definitions,
|
|
228
|
+
"gauge",
|
|
229
|
+
name,
|
|
230
|
+
options
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const read = (
|
|
234
|
+
labels: MetricLabels
|
|
235
|
+
) =>
|
|
236
|
+
getSimpleSeries(
|
|
237
|
+
definition,
|
|
238
|
+
labels
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
name: definition.name,
|
|
243
|
+
set(
|
|
244
|
+
value,
|
|
245
|
+
labels = {}
|
|
246
|
+
) {
|
|
247
|
+
assertFiniteNumber(
|
|
248
|
+
value,
|
|
249
|
+
"gauge value"
|
|
250
|
+
);
|
|
251
|
+
read(labels).value =
|
|
252
|
+
value;
|
|
253
|
+
},
|
|
254
|
+
inc(
|
|
255
|
+
value = 1,
|
|
256
|
+
labels = {}
|
|
257
|
+
) {
|
|
258
|
+
assertFiniteNumber(
|
|
259
|
+
value,
|
|
260
|
+
"gauge increment"
|
|
261
|
+
);
|
|
262
|
+
read(labels).value +=
|
|
263
|
+
value;
|
|
264
|
+
},
|
|
265
|
+
dec(
|
|
266
|
+
value = 1,
|
|
267
|
+
labels = {}
|
|
268
|
+
) {
|
|
269
|
+
assertFiniteNumber(
|
|
270
|
+
value,
|
|
271
|
+
"gauge decrement"
|
|
272
|
+
);
|
|
273
|
+
read(labels).value -=
|
|
274
|
+
value;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
},
|
|
278
|
+
histogram(
|
|
279
|
+
name,
|
|
280
|
+
options = {}
|
|
281
|
+
) {
|
|
282
|
+
const metricName =
|
|
283
|
+
normalizeMetricName(
|
|
284
|
+
name
|
|
285
|
+
);
|
|
286
|
+
const labelNames =
|
|
287
|
+
normalizeLabelNames(
|
|
288
|
+
options.labelNames
|
|
289
|
+
);
|
|
290
|
+
const buckets =
|
|
291
|
+
normalizeBuckets(
|
|
292
|
+
options.buckets
|
|
293
|
+
);
|
|
294
|
+
const existing =
|
|
295
|
+
definitions.get(
|
|
296
|
+
metricName
|
|
297
|
+
);
|
|
298
|
+
let definition:
|
|
299
|
+
HistogramDefinition;
|
|
300
|
+
|
|
301
|
+
if (existing) {
|
|
302
|
+
assertCompatibleDefinition(
|
|
303
|
+
existing,
|
|
304
|
+
"histogram",
|
|
305
|
+
labelNames
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (
|
|
309
|
+
existing.type !==
|
|
310
|
+
"histogram" ||
|
|
311
|
+
!sameNumbers(
|
|
312
|
+
existing.buckets,
|
|
313
|
+
buckets
|
|
314
|
+
)
|
|
315
|
+
) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`BCP Observability: metric ${metricName} was already registered with different histogram buckets.`
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
definition = existing;
|
|
322
|
+
} else {
|
|
323
|
+
definition = {
|
|
324
|
+
type: "histogram",
|
|
325
|
+
name: metricName,
|
|
326
|
+
help:
|
|
327
|
+
normalizeHelp(
|
|
328
|
+
options.help,
|
|
329
|
+
metricName
|
|
330
|
+
),
|
|
331
|
+
labelNames,
|
|
332
|
+
buckets,
|
|
333
|
+
series:
|
|
334
|
+
new Map(),
|
|
335
|
+
};
|
|
336
|
+
definitions.set(
|
|
337
|
+
metricName,
|
|
338
|
+
definition
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
name: definition.name,
|
|
344
|
+
observe(
|
|
345
|
+
value,
|
|
346
|
+
labels = {}
|
|
347
|
+
) {
|
|
348
|
+
assertFiniteNumber(
|
|
349
|
+
value,
|
|
350
|
+
"histogram observation"
|
|
351
|
+
);
|
|
352
|
+
const normalizedLabels =
|
|
353
|
+
normalizeLabels(
|
|
354
|
+
definition.labelNames,
|
|
355
|
+
labels
|
|
356
|
+
);
|
|
357
|
+
const key =
|
|
358
|
+
serializeLabels(
|
|
359
|
+
definition.labelNames,
|
|
360
|
+
normalizedLabels
|
|
361
|
+
);
|
|
362
|
+
let series =
|
|
363
|
+
definition.series.get(
|
|
364
|
+
key
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
if (!series) {
|
|
368
|
+
series = {
|
|
369
|
+
labels:
|
|
370
|
+
normalizedLabels,
|
|
371
|
+
count: 0,
|
|
372
|
+
sum: 0,
|
|
373
|
+
buckets:
|
|
374
|
+
definition.buckets.map(
|
|
375
|
+
() => 0
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
definition.series.set(
|
|
379
|
+
key,
|
|
380
|
+
series
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
series.count += 1;
|
|
385
|
+
series.sum += value;
|
|
386
|
+
|
|
387
|
+
for (
|
|
388
|
+
let index = 0;
|
|
389
|
+
index < definition.buckets.length;
|
|
390
|
+
index++
|
|
391
|
+
) {
|
|
392
|
+
if (
|
|
393
|
+
value <=
|
|
394
|
+
definition.buckets[index]
|
|
395
|
+
) {
|
|
396
|
+
series.buckets[index] += 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
metrics() {
|
|
403
|
+
return renderMetrics(
|
|
404
|
+
definitions
|
|
405
|
+
);
|
|
406
|
+
},
|
|
407
|
+
reset() {
|
|
408
|
+
for (
|
|
409
|
+
const definition
|
|
410
|
+
of definitions.values()
|
|
411
|
+
) {
|
|
412
|
+
definition.series.clear();
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function createRequestMetricsMiddleware(
|
|
419
|
+
registry: MetricsRegistry,
|
|
420
|
+
options:
|
|
421
|
+
RequestMetricsOptions = {}
|
|
422
|
+
): MiddlewarePipelineHandler {
|
|
423
|
+
const prefix =
|
|
424
|
+
normalizePrefix(
|
|
425
|
+
options.prefix
|
|
426
|
+
);
|
|
427
|
+
const labelNames:
|
|
428
|
+
string[] = [];
|
|
429
|
+
|
|
430
|
+
if (
|
|
431
|
+
options.includeMethod !==
|
|
432
|
+
false
|
|
433
|
+
) {
|
|
434
|
+
labelNames.push(
|
|
435
|
+
"method"
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (
|
|
440
|
+
options.includeStatus !==
|
|
441
|
+
false
|
|
442
|
+
) {
|
|
443
|
+
labelNames.push(
|
|
444
|
+
"status"
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const requests =
|
|
449
|
+
registry.counter(
|
|
450
|
+
`${prefix}http_requests_total`,
|
|
451
|
+
{
|
|
452
|
+
help:
|
|
453
|
+
"Total HTTP requests observed by BCP middleware.",
|
|
454
|
+
labelNames,
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
const duration =
|
|
458
|
+
registry.histogram(
|
|
459
|
+
`${prefix}http_request_duration_seconds`,
|
|
460
|
+
{
|
|
461
|
+
help:
|
|
462
|
+
"HTTP request duration observed by BCP middleware.",
|
|
463
|
+
labelNames,
|
|
464
|
+
}
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
return async (
|
|
468
|
+
request,
|
|
469
|
+
next
|
|
470
|
+
) => {
|
|
471
|
+
const startedAt =
|
|
472
|
+
process.hrtime.bigint();
|
|
473
|
+
let status = 500;
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const response =
|
|
477
|
+
await next();
|
|
478
|
+
status =
|
|
479
|
+
response.status;
|
|
480
|
+
return response;
|
|
481
|
+
} finally {
|
|
482
|
+
const elapsed =
|
|
483
|
+
Number(
|
|
484
|
+
process.hrtime.bigint() -
|
|
485
|
+
startedAt
|
|
486
|
+
) /
|
|
487
|
+
1_000_000_000;
|
|
488
|
+
const labels:
|
|
489
|
+
Record<string, string> = {};
|
|
490
|
+
|
|
491
|
+
if (
|
|
492
|
+
options.includeMethod !==
|
|
493
|
+
false
|
|
494
|
+
) {
|
|
495
|
+
labels.method =
|
|
496
|
+
request.method
|
|
497
|
+
.toUpperCase();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (
|
|
501
|
+
options.includeStatus !==
|
|
502
|
+
false
|
|
503
|
+
) {
|
|
504
|
+
labels.status =
|
|
505
|
+
String(status);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
requests.inc(
|
|
509
|
+
1,
|
|
510
|
+
labels
|
|
511
|
+
);
|
|
512
|
+
duration.observe(
|
|
513
|
+
elapsed,
|
|
514
|
+
labels
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function createMetricsResponse(
|
|
521
|
+
registry: MetricsRegistry
|
|
522
|
+
): Response {
|
|
523
|
+
return new Response(
|
|
524
|
+
registry.metrics(),
|
|
525
|
+
{
|
|
526
|
+
status: 200,
|
|
527
|
+
headers: {
|
|
528
|
+
"content-type":
|
|
529
|
+
"text/plain; version=0.0.4; charset=utf-8",
|
|
530
|
+
"cache-control":
|
|
531
|
+
"no-store",
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function createHealthRegistry(): HealthRegistry {
|
|
538
|
+
const checks =
|
|
539
|
+
new Map<
|
|
540
|
+
string,
|
|
541
|
+
{
|
|
542
|
+
check: HealthCheck;
|
|
543
|
+
timeoutMs: number;
|
|
544
|
+
}
|
|
545
|
+
>();
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
register(
|
|
549
|
+
name,
|
|
550
|
+
check,
|
|
551
|
+
options = {}
|
|
552
|
+
) {
|
|
553
|
+
const normalizedName =
|
|
554
|
+
normalizeHealthCheckName(
|
|
555
|
+
name
|
|
556
|
+
);
|
|
557
|
+
const timeoutMs =
|
|
558
|
+
normalizeHealthTimeout(
|
|
559
|
+
options.timeoutMs
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
if (
|
|
563
|
+
checks.has(
|
|
564
|
+
normalizedName
|
|
565
|
+
)
|
|
566
|
+
) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`BCP Observability: health check ${normalizedName} is already registered.`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
checks.set(
|
|
573
|
+
normalizedName,
|
|
574
|
+
{
|
|
575
|
+
check,
|
|
576
|
+
timeoutMs,
|
|
577
|
+
}
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
return () => {
|
|
581
|
+
checks.delete(
|
|
582
|
+
normalizedName
|
|
583
|
+
);
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
async run() {
|
|
587
|
+
const results =
|
|
588
|
+
await Promise.all(
|
|
589
|
+
Array.from(
|
|
590
|
+
checks.entries()
|
|
591
|
+
).map(
|
|
592
|
+
async ([
|
|
593
|
+
name,
|
|
594
|
+
definition,
|
|
595
|
+
]) =>
|
|
596
|
+
runHealthCheck(
|
|
597
|
+
name,
|
|
598
|
+
definition.check,
|
|
599
|
+
definition.timeoutMs
|
|
600
|
+
)
|
|
601
|
+
)
|
|
602
|
+
);
|
|
603
|
+
const ok =
|
|
604
|
+
results.every(
|
|
605
|
+
item =>
|
|
606
|
+
item.ok
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
ok,
|
|
611
|
+
status:
|
|
612
|
+
ok
|
|
613
|
+
? "healthy"
|
|
614
|
+
: "unhealthy",
|
|
615
|
+
checkedAt:
|
|
616
|
+
new Date()
|
|
617
|
+
.toISOString(),
|
|
618
|
+
checks:
|
|
619
|
+
results,
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
async response() {
|
|
623
|
+
const report =
|
|
624
|
+
await this.run();
|
|
625
|
+
|
|
626
|
+
return Response.json(
|
|
627
|
+
report,
|
|
628
|
+
{
|
|
629
|
+
status:
|
|
630
|
+
report.ok
|
|
631
|
+
? 200
|
|
632
|
+
: 503,
|
|
633
|
+
headers: {
|
|
634
|
+
"cache-control":
|
|
635
|
+
"no-store",
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
);
|
|
639
|
+
},
|
|
640
|
+
clear() {
|
|
641
|
+
checks.clear();
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function getOrCreateSimpleDefinition(
|
|
647
|
+
definitions:
|
|
648
|
+
Map<string, MetricDefinition>,
|
|
649
|
+
type: "counter" | "gauge",
|
|
650
|
+
name: string,
|
|
651
|
+
options: MetricDefinitionOptions
|
|
652
|
+
): CounterDefinition | GaugeDefinition {
|
|
653
|
+
const metricName =
|
|
654
|
+
normalizeMetricName(
|
|
655
|
+
name
|
|
656
|
+
);
|
|
657
|
+
const labelNames =
|
|
658
|
+
normalizeLabelNames(
|
|
659
|
+
options.labelNames
|
|
660
|
+
);
|
|
661
|
+
const existing =
|
|
662
|
+
definitions.get(
|
|
663
|
+
metricName
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
if (existing) {
|
|
667
|
+
assertCompatibleDefinition(
|
|
668
|
+
existing,
|
|
669
|
+
type,
|
|
670
|
+
labelNames
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
return existing as
|
|
674
|
+
CounterDefinition |
|
|
675
|
+
GaugeDefinition;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const definition:
|
|
679
|
+
CounterDefinition |
|
|
680
|
+
GaugeDefinition = {
|
|
681
|
+
type,
|
|
682
|
+
name: metricName,
|
|
683
|
+
help:
|
|
684
|
+
normalizeHelp(
|
|
685
|
+
options.help,
|
|
686
|
+
metricName
|
|
687
|
+
),
|
|
688
|
+
labelNames,
|
|
689
|
+
series:
|
|
690
|
+
new Map(),
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
definitions.set(
|
|
694
|
+
metricName,
|
|
695
|
+
definition
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
return definition;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function getSimpleSeries(
|
|
702
|
+
definition:
|
|
703
|
+
CounterDefinition |
|
|
704
|
+
GaugeDefinition,
|
|
705
|
+
labels: MetricLabels
|
|
706
|
+
): MetricSeries {
|
|
707
|
+
const normalizedLabels =
|
|
708
|
+
normalizeLabels(
|
|
709
|
+
definition.labelNames,
|
|
710
|
+
labels
|
|
711
|
+
);
|
|
712
|
+
const key =
|
|
713
|
+
serializeLabels(
|
|
714
|
+
definition.labelNames,
|
|
715
|
+
normalizedLabels
|
|
716
|
+
);
|
|
717
|
+
let series =
|
|
718
|
+
definition.series.get(
|
|
719
|
+
key
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (!series) {
|
|
723
|
+
series = {
|
|
724
|
+
labels:
|
|
725
|
+
normalizedLabels,
|
|
726
|
+
value: 0,
|
|
727
|
+
};
|
|
728
|
+
definition.series.set(
|
|
729
|
+
key,
|
|
730
|
+
series
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return series;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function renderMetrics(
|
|
738
|
+
definitions:
|
|
739
|
+
Map<string, MetricDefinition>
|
|
740
|
+
): string {
|
|
741
|
+
const lines:
|
|
742
|
+
string[] = [];
|
|
743
|
+
|
|
744
|
+
for (
|
|
745
|
+
const definition
|
|
746
|
+
of Array.from(
|
|
747
|
+
definitions.values()
|
|
748
|
+
).sort(
|
|
749
|
+
(left, right) =>
|
|
750
|
+
left.name.localeCompare(
|
|
751
|
+
right.name
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
) {
|
|
755
|
+
lines.push(
|
|
756
|
+
`# HELP ${definition.name} ${escapeHelp(definition.help)}`,
|
|
757
|
+
`# TYPE ${definition.name} ${definition.type}`
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
if (
|
|
761
|
+
definition.type ===
|
|
762
|
+
"histogram"
|
|
763
|
+
) {
|
|
764
|
+
for (
|
|
765
|
+
const series
|
|
766
|
+
of definition.series.values()
|
|
767
|
+
) {
|
|
768
|
+
for (
|
|
769
|
+
let index = 0;
|
|
770
|
+
index < definition.buckets.length;
|
|
771
|
+
index++
|
|
772
|
+
) {
|
|
773
|
+
lines.push(
|
|
774
|
+
`${definition.name}_bucket${renderLabels({
|
|
775
|
+
...series.labels,
|
|
776
|
+
le: String(
|
|
777
|
+
definition.buckets[index]
|
|
778
|
+
),
|
|
779
|
+
})} ${series.buckets[index]}`
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
lines.push(
|
|
784
|
+
`${definition.name}_bucket${renderLabels({
|
|
785
|
+
...series.labels,
|
|
786
|
+
le: "+Inf",
|
|
787
|
+
})} ${series.count}`,
|
|
788
|
+
`${definition.name}_sum${renderLabels(series.labels)} ${series.sum}`,
|
|
789
|
+
`${definition.name}_count${renderLabels(series.labels)} ${series.count}`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
} else {
|
|
793
|
+
for (
|
|
794
|
+
const series
|
|
795
|
+
of definition.series.values()
|
|
796
|
+
) {
|
|
797
|
+
lines.push(
|
|
798
|
+
`${definition.name}${renderLabels(series.labels)} ${series.value}`
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return lines.length > 0
|
|
805
|
+
? `${lines.join("\n")}\n`
|
|
806
|
+
: "";
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function runHealthCheck(
|
|
810
|
+
name: string,
|
|
811
|
+
check: HealthCheck,
|
|
812
|
+
timeoutMs: number
|
|
813
|
+
): Promise<HealthCheckReportItem> {
|
|
814
|
+
const startedAt =
|
|
815
|
+
performance.now();
|
|
816
|
+
|
|
817
|
+
try {
|
|
818
|
+
const result =
|
|
819
|
+
await withTimeout(
|
|
820
|
+
Promise.resolve()
|
|
821
|
+
.then(
|
|
822
|
+
check
|
|
823
|
+
),
|
|
824
|
+
timeoutMs
|
|
825
|
+
);
|
|
826
|
+
const normalized =
|
|
827
|
+
typeof result ===
|
|
828
|
+
"boolean"
|
|
829
|
+
? {
|
|
830
|
+
ok: result,
|
|
831
|
+
}
|
|
832
|
+
: result;
|
|
833
|
+
|
|
834
|
+
return {
|
|
835
|
+
name,
|
|
836
|
+
ok:
|
|
837
|
+
normalized.ok ===
|
|
838
|
+
true,
|
|
839
|
+
durationMs:
|
|
840
|
+
elapsedMilliseconds(
|
|
841
|
+
startedAt
|
|
842
|
+
),
|
|
843
|
+
...(normalized.detail
|
|
844
|
+
? {
|
|
845
|
+
detail:
|
|
846
|
+
normalized.detail,
|
|
847
|
+
}
|
|
848
|
+
: {}),
|
|
849
|
+
};
|
|
850
|
+
} catch (error) {
|
|
851
|
+
return {
|
|
852
|
+
name,
|
|
853
|
+
ok: false,
|
|
854
|
+
durationMs:
|
|
855
|
+
elapsedMilliseconds(
|
|
856
|
+
startedAt
|
|
857
|
+
),
|
|
858
|
+
detail:
|
|
859
|
+
error instanceof Error
|
|
860
|
+
? error.message
|
|
861
|
+
: "Health check failed.",
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function withTimeout<T>(
|
|
867
|
+
promise: Promise<T>,
|
|
868
|
+
timeoutMs: number
|
|
869
|
+
): Promise<T> {
|
|
870
|
+
return new Promise<T>(
|
|
871
|
+
(
|
|
872
|
+
resolve,
|
|
873
|
+
reject
|
|
874
|
+
) => {
|
|
875
|
+
const timeout =
|
|
876
|
+
setTimeout(
|
|
877
|
+
() =>
|
|
878
|
+
reject(
|
|
879
|
+
new Error(
|
|
880
|
+
`Health check timed out after ${timeoutMs}ms.`
|
|
881
|
+
)
|
|
882
|
+
),
|
|
883
|
+
timeoutMs
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
timeout.unref?.();
|
|
887
|
+
|
|
888
|
+
promise.then(
|
|
889
|
+
value => {
|
|
890
|
+
clearTimeout(
|
|
891
|
+
timeout
|
|
892
|
+
);
|
|
893
|
+
resolve(value);
|
|
894
|
+
},
|
|
895
|
+
error => {
|
|
896
|
+
clearTimeout(
|
|
897
|
+
timeout
|
|
898
|
+
);
|
|
899
|
+
reject(error);
|
|
900
|
+
}
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function normalizeMetricName(
|
|
907
|
+
value: string
|
|
908
|
+
): string {
|
|
909
|
+
const name =
|
|
910
|
+
value.trim();
|
|
911
|
+
|
|
912
|
+
if (
|
|
913
|
+
!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(
|
|
914
|
+
name
|
|
915
|
+
)
|
|
916
|
+
) {
|
|
917
|
+
throw new TypeError(
|
|
918
|
+
`BCP Observability: invalid metric name \"${value}\".`
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return name;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function normalizeLabelNames(
|
|
926
|
+
values:
|
|
927
|
+
readonly string[] |
|
|
928
|
+
undefined
|
|
929
|
+
): string[] {
|
|
930
|
+
const names =
|
|
931
|
+
Array.from(
|
|
932
|
+
new Set(
|
|
933
|
+
(
|
|
934
|
+
values ??
|
|
935
|
+
[]
|
|
936
|
+
).map(
|
|
937
|
+
value =>
|
|
938
|
+
value.trim()
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
for (
|
|
944
|
+
const name
|
|
945
|
+
of names
|
|
946
|
+
) {
|
|
947
|
+
if (
|
|
948
|
+
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(
|
|
949
|
+
name
|
|
950
|
+
) ||
|
|
951
|
+
name === "le"
|
|
952
|
+
) {
|
|
953
|
+
throw new TypeError(
|
|
954
|
+
`BCP Observability: invalid metric label name \"${name}\".`
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return names;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function normalizeLabels(
|
|
963
|
+
labelNames: readonly string[],
|
|
964
|
+
labels: MetricLabels
|
|
965
|
+
): Record<string, string> {
|
|
966
|
+
const received =
|
|
967
|
+
Object.keys(
|
|
968
|
+
labels
|
|
969
|
+
).sort();
|
|
970
|
+
const expected =
|
|
971
|
+
[
|
|
972
|
+
...labelNames,
|
|
973
|
+
].sort();
|
|
974
|
+
|
|
975
|
+
if (
|
|
976
|
+
received.length !==
|
|
977
|
+
expected.length ||
|
|
978
|
+
received.some(
|
|
979
|
+
(
|
|
980
|
+
name,
|
|
981
|
+
index
|
|
982
|
+
) =>
|
|
983
|
+
name !==
|
|
984
|
+
expected[index]
|
|
985
|
+
)
|
|
986
|
+
) {
|
|
987
|
+
throw new TypeError(
|
|
988
|
+
`BCP Observability: metric labels must match [${labelNames.join(", ")}].`
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return Object.fromEntries(
|
|
993
|
+
labelNames.map(
|
|
994
|
+
name => [
|
|
995
|
+
name,
|
|
996
|
+
String(
|
|
997
|
+
labels[name]
|
|
998
|
+
),
|
|
999
|
+
]
|
|
1000
|
+
)
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function serializeLabels(
|
|
1005
|
+
labelNames: readonly string[],
|
|
1006
|
+
labels: Record<string, string>
|
|
1007
|
+
): string {
|
|
1008
|
+
return labelNames
|
|
1009
|
+
.map(
|
|
1010
|
+
name =>
|
|
1011
|
+
`${name}=${labels[name]}`
|
|
1012
|
+
)
|
|
1013
|
+
.join("\u0000");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function renderLabels(
|
|
1017
|
+
labels: Record<string, string>
|
|
1018
|
+
): string {
|
|
1019
|
+
const entries =
|
|
1020
|
+
Object.entries(
|
|
1021
|
+
labels
|
|
1022
|
+
);
|
|
1023
|
+
|
|
1024
|
+
if (
|
|
1025
|
+
entries.length === 0
|
|
1026
|
+
) {
|
|
1027
|
+
return "";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return `{${entries.map(
|
|
1031
|
+
([
|
|
1032
|
+
name,
|
|
1033
|
+
value,
|
|
1034
|
+
]) =>
|
|
1035
|
+
`${name}=\"${escapeLabelValue(value)}\"`
|
|
1036
|
+
).join(",")}}`;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function normalizeBuckets(
|
|
1040
|
+
values:
|
|
1041
|
+
readonly number[] |
|
|
1042
|
+
undefined
|
|
1043
|
+
): number[] {
|
|
1044
|
+
const buckets =
|
|
1045
|
+
[
|
|
1046
|
+
...(
|
|
1047
|
+
values ??
|
|
1048
|
+
DEFAULT_HISTOGRAM_BUCKETS
|
|
1049
|
+
),
|
|
1050
|
+
].sort(
|
|
1051
|
+
(
|
|
1052
|
+
left,
|
|
1053
|
+
right
|
|
1054
|
+
) =>
|
|
1055
|
+
left - right
|
|
1056
|
+
);
|
|
1057
|
+
|
|
1058
|
+
if (
|
|
1059
|
+
buckets.length === 0 ||
|
|
1060
|
+
buckets.some(
|
|
1061
|
+
value =>
|
|
1062
|
+
!Number.isFinite(
|
|
1063
|
+
value
|
|
1064
|
+
)
|
|
1065
|
+
)
|
|
1066
|
+
) {
|
|
1067
|
+
throw new TypeError(
|
|
1068
|
+
"BCP Observability: histogram buckets must contain finite numbers."
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return Array.from(
|
|
1073
|
+
new Set(
|
|
1074
|
+
buckets
|
|
1075
|
+
)
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function assertCompatibleDefinition(
|
|
1080
|
+
existing: MetricDefinition,
|
|
1081
|
+
expectedType:
|
|
1082
|
+
MetricDefinition["type"],
|
|
1083
|
+
labelNames: readonly string[]
|
|
1084
|
+
): void {
|
|
1085
|
+
if (
|
|
1086
|
+
existing.type !==
|
|
1087
|
+
expectedType ||
|
|
1088
|
+
!sameStrings(
|
|
1089
|
+
existing.labelNames,
|
|
1090
|
+
labelNames
|
|
1091
|
+
)
|
|
1092
|
+
) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`BCP Observability: metric ${existing.name} was already registered with a different definition.`
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function normalizeHelp(
|
|
1100
|
+
value: string | undefined,
|
|
1101
|
+
fallback: string
|
|
1102
|
+
): string {
|
|
1103
|
+
return value?.trim() ||
|
|
1104
|
+
fallback;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function normalizePrefix(
|
|
1108
|
+
value: string | undefined
|
|
1109
|
+
): string {
|
|
1110
|
+
if (!value) {
|
|
1111
|
+
return "bcp_";
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const prefix =
|
|
1115
|
+
value.trim();
|
|
1116
|
+
|
|
1117
|
+
if (
|
|
1118
|
+
!/^[a-zA-Z_:][a-zA-Z0-9_:]*$/.test(
|
|
1119
|
+
prefix
|
|
1120
|
+
)
|
|
1121
|
+
) {
|
|
1122
|
+
throw new TypeError(
|
|
1123
|
+
"BCP Observability: metrics prefix is invalid."
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return prefix.endsWith("_")
|
|
1128
|
+
? prefix
|
|
1129
|
+
: `${prefix}_`;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function normalizeHealthCheckName(
|
|
1133
|
+
value: string
|
|
1134
|
+
): string {
|
|
1135
|
+
const name =
|
|
1136
|
+
value.trim();
|
|
1137
|
+
|
|
1138
|
+
if (!name) {
|
|
1139
|
+
throw new TypeError(
|
|
1140
|
+
"BCP Observability: health check name must not be empty."
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return name;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function normalizeHealthTimeout(
|
|
1148
|
+
value: number | undefined
|
|
1149
|
+
): number {
|
|
1150
|
+
const timeoutMs =
|
|
1151
|
+
value ?? 5_000;
|
|
1152
|
+
|
|
1153
|
+
if (
|
|
1154
|
+
!Number.isFinite(
|
|
1155
|
+
timeoutMs
|
|
1156
|
+
) ||
|
|
1157
|
+
timeoutMs <= 0
|
|
1158
|
+
) {
|
|
1159
|
+
throw new TypeError(
|
|
1160
|
+
"BCP Observability: health check timeout must be a positive finite number of milliseconds."
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return Math.floor(
|
|
1165
|
+
timeoutMs
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function assertFiniteNumber(
|
|
1170
|
+
value: number,
|
|
1171
|
+
label: string
|
|
1172
|
+
): void {
|
|
1173
|
+
if (
|
|
1174
|
+
!Number.isFinite(
|
|
1175
|
+
value
|
|
1176
|
+
)
|
|
1177
|
+
) {
|
|
1178
|
+
throw new TypeError(
|
|
1179
|
+
`BCP Observability: ${label} must be a finite number.`
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function sameStrings(
|
|
1185
|
+
left: readonly string[],
|
|
1186
|
+
right: readonly string[]
|
|
1187
|
+
): boolean {
|
|
1188
|
+
return left.length ===
|
|
1189
|
+
right.length &&
|
|
1190
|
+
left.every(
|
|
1191
|
+
(
|
|
1192
|
+
value,
|
|
1193
|
+
index
|
|
1194
|
+
) =>
|
|
1195
|
+
value ===
|
|
1196
|
+
right[index]
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function sameNumbers(
|
|
1201
|
+
left: readonly number[],
|
|
1202
|
+
right: readonly number[]
|
|
1203
|
+
): boolean {
|
|
1204
|
+
return left.length ===
|
|
1205
|
+
right.length &&
|
|
1206
|
+
left.every(
|
|
1207
|
+
(
|
|
1208
|
+
value,
|
|
1209
|
+
index
|
|
1210
|
+
) =>
|
|
1211
|
+
value ===
|
|
1212
|
+
right[index]
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function escapeHelp(
|
|
1217
|
+
value: string
|
|
1218
|
+
): string {
|
|
1219
|
+
return value
|
|
1220
|
+
.replaceAll(
|
|
1221
|
+
"\\",
|
|
1222
|
+
"\\\\"
|
|
1223
|
+
)
|
|
1224
|
+
.replaceAll(
|
|
1225
|
+
"\n",
|
|
1226
|
+
"\\n"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function escapeLabelValue(
|
|
1231
|
+
value: string
|
|
1232
|
+
): string {
|
|
1233
|
+
return value
|
|
1234
|
+
.replaceAll(
|
|
1235
|
+
"\\",
|
|
1236
|
+
"\\\\"
|
|
1237
|
+
)
|
|
1238
|
+
.replaceAll(
|
|
1239
|
+
"\"",
|
|
1240
|
+
"\\\""
|
|
1241
|
+
)
|
|
1242
|
+
.replaceAll(
|
|
1243
|
+
"\n",
|
|
1244
|
+
"\\n"
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function elapsedMilliseconds(
|
|
1249
|
+
startedAt: number
|
|
1250
|
+
): number {
|
|
1251
|
+
return Math.round(
|
|
1252
|
+
(
|
|
1253
|
+
performance.now() -
|
|
1254
|
+
startedAt
|
|
1255
|
+
) * 1000
|
|
1256
|
+
) /
|
|
1257
|
+
1000;
|
|
1258
|
+
}
|