@chidchanun/bcp 0.2.6 → 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.
@@ -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
+ }