forked from CalloraOrg/Callora-Backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmetrics.ts
More file actions
938 lines (798 loc) · 35.6 KB
/
Copy pathmetrics.ts
File metadata and controls
938 lines (798 loc) · 35.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
import { Request, Response, NextFunction } from 'express';
import client from 'prom-client';
import { performance } from 'node:perf_hooks';
import { UnauthorizedError } from './errors/index.js';
// Initialize the Prometheus Registry and collect default Node.js metrics (CPU, RAM, Event Loop)
export const register = new client.Registry();
client.collectDefaultMetrics({ register });
const rateLimiterStoreOutages = new client.Counter({
name: 'rate_limiter_store_outages_total',
help: 'Number of distributed rate-limiter store outages observed',
labelNames: ['outage_mode'],
});
const rateLimiterStoreDegraded = new client.Gauge({
name: 'rate_limiter_store_degraded',
help: 'Whether the distributed rate-limiter store is currently degraded',
});
register.registerMetric(rateLimiterStoreOutages);
register.registerMetric(rateLimiterStoreDegraded);
export function recordRateLimiterStoreOutage(outageMode: 'fail-closed' | 'fallback'): void {
rateLimiterStoreOutages.inc({ outage_mode: outageMode });
rateLimiterStoreDegraded.set(1);
}
export function recordRateLimiterStoreRecovery(): void {
rateLimiterStoreDegraded.set(0);
}
// ── Route groups ──────────────────────────────────────────────────────────────
//
// A `route_group` label is added to every HTTP metric so dashboards can slice
// latency by logical service area without exploding cardinality.
//
// Rules (evaluated in order, first match wins):
// health → /api/health
// metrics → /api/metrics
// billing → /api/billing/**
// vault → /api/vault/**
// auth → /api/auth/** | /api/keys/**
// apis → /api/apis/** | /api/developers/** | /api/usage
// admin → /api/admin/**
// other → everything else (404s, unknown paths)
//
// Security note: route_group is derived from the *parameterised* route pattern
// (req.route.path) or a sanitised fallback — never from raw user-supplied path
// segments — so it cannot be used to inject arbitrary label values.
// ─────────────────────────────────────────────────────────────────────────────
export type RouteGroup =
| 'health'
| 'metrics'
| 'billing'
| 'vault'
| 'auth'
| 'apis'
| 'admin'
| 'other';
/**
* Derive a stable, low-cardinality route group from a normalised route string.
* The input should already be the parameterised pattern (e.g. `/api/apis/:id`),
* not a raw URL, to avoid PII leakage.
*/
export function resolveRouteGroup(route: string): RouteGroup {
if (route === '/api/health' || route === '/api/health/') return 'health';
if (route === '/api/metrics' || route === '/api/metrics/') return 'metrics';
if (route.startsWith('/api/billing')) return 'billing';
if (route.startsWith('/api/vault')) return 'vault';
if (route.startsWith('/api/auth') || route.startsWith('/api/keys')) return 'auth';
if (
route.startsWith('/api/apis') ||
route.startsWith('/api/developers') ||
route.startsWith('/api/usage')
) return 'apis';
if (route.startsWith('/api/admin')) return 'admin';
return 'other';
}
// ── HTTP request histogram ────────────────────────────────────────────────────
//
// Buckets are intentionally tighter than the upstream histogram because these
// measure the full in-process request cycle, not external network calls.
// The `route_group` label enables per-area SLO dashboards without the
// cardinality cost of per-path histograms.
// ─────────────────────────────────────────────────────────────────────────────
const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code', 'route_group'],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});
// ── HTTP request duration summary ─────────────────────────────────────────────
//
// Client-side computed quantiles with a `quantile` label exposing p50 / p95 / p99
// directly in the Prometheus scrape output. Complements the histogram above:
// - Histogram → server-side aggregation via histogram_quantile() in PromQL
// - Summary → direct percentile labels for dashboards that want precomputed
// p50/p95/p99 per (method, route, status_code, route_group)
//
// Security / cardinality notes:
// - `maxAgeSeconds` and `ageBuckets` cap memory and prevent unbounded growth
// of the internal quantile estimator per label combination.
// - Label set is identical to the histogram (method / route / status_code /
// route_group), which is already bounded by route-normalisation rules.
// ─────────────────────────────────────────────────────────────────────────────
const httpRequestDurationSummary = new client.Summary({
name: 'http_request_duration_summary_seconds',
help: 'Duration of HTTP requests in seconds with precomputed p50 / p95 / p99 percentiles per route',
labelNames: ['method', 'route', 'status_code', 'route_group'],
percentiles: [0.5, 0.95, 0.99],
maxAgeSeconds: 5 * 60,
ageBuckets: 5,
});
// ── Per-route request-timing histogram (FWC26 Stellar Wave) ────────────────────
//
// Dedicated per-route request-duration histogram with a focused label set
// (route / method / status_code) for route-level SLO dashboards.
//
// Metric: http_route_duration_seconds
// Type: Histogram
// Labels: route, method, status_code
// Buckets: 1 ms → 10 s (tuned for full in-process request cycles)
//
// Metric: http_route_duration_summary_seconds
// Type: Summary
// Labels: route, method, status_code (+ implicit `quantile` label)
// Quantiles: 0.50 (p50), 0.95 (p95), 0.99 (p99)
//
// The Summary emits p50 / p95 / p99 with a `quantile` label directly in the
// Prometheus scrape output — consumers can read these without running
// histogram_quantile(). The Histogram remains available for server-side
// aggregation and arbitrary-percentile queries via PromQL.
//
// Security / cardinality:
// - `route` label is sourced from the same normalised route template used
// everywhere else (normalizeRouteForMetrics), so UUIDs / numeric IDs /
// pathological paths are collapsed to a bounded cardinality.
// - Summary `maxAgeSeconds` / `ageBuckets` cap per-label memory usage.
// ─────────────────────────────────────────────────────────────────────────────
const httpRouteDuration = new client.Histogram({
name: 'http_route_duration_seconds',
help: 'Per-route request duration histogram in seconds (FWC26 Stellar Wave)',
labelNames: ['route', 'method', 'status_code'],
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});
const httpRouteDurationSummary = new client.Summary({
name: 'http_route_duration_summary_seconds',
help: 'Per-route request duration with precomputed p50 / p95 / p99 quantile labels (FWC26 Stellar Wave)',
labelNames: ['route', 'method', 'status_code'],
percentiles: [0.5, 0.95, 0.99],
maxAgeSeconds: 5 * 60,
ageBuckets: 5,
});
// ── HTTP request counter ──────────────────────────────────────────────────────
const httpRequestsTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code', 'route_group'],
});
register.registerMetric(httpRequestDuration);
register.registerMetric(httpRequestDurationSummary);
register.registerMetric(httpRouteDuration);
register.registerMetric(httpRouteDurationSummary);
register.registerMetric(httpRequestsTotal);
// ── Per-route metric helpers (FWC26 Stellar Wave) ───────────────────────────
//
// Expose record helpers so ad-hoc code paths (workers, internal routes,
// middleware sub-functions) can record per-route timing without going
// through the full Express middleware stack.
// ────────────────────────────────────────────────────────────────────────────
/**
* Manually record a per-route duration observation onto the histogram and
* the summary. Called automatically by `metricsMiddleware` for all
* Express-handled requests; exposed directly for non-middleware code paths.
*
* @param route – Normalised route template (e.g. `/api/apis/:id`)
* @param method – HTTP verb (GET, POST, …)
* @param statusCode – Response status code
* @param durationMs – Elapsed time in milliseconds
*/
export function recordPerRouteDuration(
route: string,
method: string,
statusCode: number,
durationMs: number,
): void {
const labels = {
route,
method: method.toUpperCase(),
status_code: String(statusCode),
};
const durationSec = durationMs / 1000;
httpRouteDuration.observe(labels, durationSec);
httpRouteDurationSummary.observe(labels, durationSec);
}
/** Metric name constants for consumers that build PromQL queries
* programmatically (avoids hard-coding strings in dashboards/tests). */
export const PER_ROUTE_METRIC_NAMES = {
histogram: 'http_route_duration_seconds',
summary: 'http_route_duration_summary_seconds',
} as const;
// ── Gateway upstream profiling ─────────────────────────────────────────────
//
// Metric: gateway_upstream_duration_seconds
// Type: Histogram
// Labels: api_id, method, status_code, outcome
// Buckets: tuned for typical upstream API latencies (10 ms → 10 s)
//
// Metric: gateway_upstream_requests_total
// Type: Counter
// Labels: api_id, method, status_code, outcome
//
// Both metrics are gated behind GATEWAY_PROFILING_ENABLED=true.
// When disabled the timer helper is a cheap no-op.
// ────────────────────────────────────────────────────────────────────────────
const UPSTREAM_LABEL_NAMES = ['api_id', 'method', 'status_code', 'outcome'] as const;
const gatewayUpstreamDuration = new client.Histogram({
name: 'gateway_upstream_duration_seconds',
help: 'Latency of proxied requests to upstream services in seconds',
labelNames: [...UPSTREAM_LABEL_NAMES],
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});
const gatewayUpstreamRequestsTotal = new client.Counter({
name: 'gateway_upstream_requests_total',
help: 'Total proxied requests forwarded to upstream services',
labelNames: [...UPSTREAM_LABEL_NAMES],
});
const gatewayUpstreamBreakerState = new client.Gauge({
name: 'gateway_upstream_breaker_state',
help: 'State of the upstream circuit breaker (0=CLOSED, 1=OPEN, 2=HALF_OPEN)',
labelNames: ['api_id'],
});
register.registerMetric(gatewayUpstreamDuration);
register.registerMetric(gatewayUpstreamRequestsTotal);
register.registerMetric(gatewayUpstreamBreakerState);
/** Check whether gateway profiling hooks are active. */
export function isProfilingEnabled(): boolean {
return process.env.GATEWAY_PROFILING_ENABLED === 'true';
}
export type UpstreamOutcome = 'success' | 'timeout' | 'error';
interface UpstreamTimer {
/** Call once the upstream response (or error) has been received. */
stop(statusCode: number, outcome: UpstreamOutcome): void;
}
const NOOP_TIMER: UpstreamTimer = { stop() {} };
/**
* Begin timing an upstream request.
*
* Returns a timer whose `stop()` method records the observed latency and
* increments the request counter. When profiling is disabled the returned
* timer is a zero-cost no-op.
*
* Labels intentionally avoid PII — only the API identifier and HTTP method
* are captured, never user IDs, API keys, or request paths.
*/
export function startUpstreamTimer(apiId: string, method: string): UpstreamTimer {
if (!isProfilingEnabled()) return NOOP_TIMER;
const start = performance.now();
return {
stop(statusCode: number, outcome: UpstreamOutcome) {
const durationSec = (performance.now() - start) / 1000;
const labels = {
api_id: apiId,
method: method.toUpperCase(),
status_code: String(statusCode),
outcome,
};
gatewayUpstreamDuration.observe(labels, durationSec);
gatewayUpstreamRequestsTotal.inc(labels);
},
};
}
/** Sentinel value for routes that couldn't be recognized and normalized. */
const UNKNOWN_ROUTE_SENTINEL = '_unknown';
/**
* Normalize a route to a safe, low-cardinality template pattern.
*
* Rules:
* 1. If matched via Express routing (req.route.path), use that pattern
* (e.g., /v1/call/:apiId instead of /v1/call/abc123)
* 2. If unmatched (404), sanitize numeric IDs and UUIDs by replacing
* them with :id and :uuid placeholders
* 3. For deeply nested or suspicious paths, return the sentinel label
*
* This ensures metrics cardinality stays bounded regardless of URL
* parameter values, bot activity, or path-scanning attacks.
*/
export function normalizeRouteForMetrics(
matched: string | undefined,
baseUrl: string | undefined,
unmatched: string,
): string {
// Prefer matched route pattern from Express routing
if (matched) {
return (baseUrl || '') + matched;
}
// Sanitize unmatched paths: replace UUIDs and numeric IDs with placeholders
let sanitized = unmatched
.replace(/\/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}(?=\/|$)/g, '/:uuid')
.replace(/\/\d+(?=\/|$)/g, '/:id');
// Additional safety: if the path is still very long or has too many segments,
// cap it to prevent any pathological cases
const segments = sanitized.split('/').filter((s) => s.length > 0);
if (segments.length > 20) {
return UNKNOWN_ROUTE_SENTINEL;
}
return (baseUrl || '') + sanitized;
}
/**
* Global middleware to record per-request latency and count metrics.
*
* Labels:
* method – HTTP verb (GET, POST, …)
* route – Parameterised route template (/api/apis/:id) or normalized
* fallback for unmatched paths; uses sentinel for pathological routes
* status_code – HTTP response status as a string
* route_group – Logical service area (health, billing, vault, …)
*
* Security / cardinality notes:
* - Routes with matched patterns use the template (e.g., /v1/call/:apiId)
* - Unmatched paths (404s) are normalized by collapsing UUIDs and numeric IDs
* - Pathological routes (too many segments) are capped under a sentinel label
* - This prevents cardinality explosion from dynamic path segments, bots, or attacks
*/
export const metricsMiddleware = (req: Request, res: Response, next: NextFunction): void => {
const endHistogramTimer = httpRequestDuration.startTimer();
const endSummaryTimer = httpRequestDurationSummary.startTimer();
const endRouteHistogramTimer = httpRouteDuration.startTimer();
const endRouteSummaryTimer = httpRouteDurationSummary.startTimer();
res.on('finish', () => {
// Normalize the route to a safe cardinality label
const routePattern = normalizeRouteForMetrics(
req.route?.path,
req.baseUrl,
req.path,
);
const routeGroup = resolveRouteGroup(routePattern);
const statusCode = res.statusCode.toString();
const method = req.method;
const labels = {
method,
route: routePattern,
status_code: statusCode,
route_group: routeGroup,
};
const routeLabels = {
route: routePattern,
method,
status_code: statusCode,
};
httpRequestsTotal.inc(labels);
endHistogramTimer(labels);
endSummaryTimer(labels);
endRouteHistogramTimer(routeLabels);
endRouteSummaryTimer(routeLabels);
});
next();
};
/**
* GET /api/metrics
*
* Exposes Prometheus text-format metrics.
* In production, requires a valid `Authorization: Bearer <METRICS_API_KEY>` header.
*
* Security note: the endpoint is auth-gated in production to prevent
* internal operational data from leaking to unauthenticated callers.
*/
export const metricsEndpoint = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
const isProduction = process.env.NODE_ENV === 'production';
const expectedKey = process.env.METRICS_API_KEY;
if (isProduction && expectedKey) {
const authHeader = req.headers.authorization;
if (authHeader !== `Bearer ${expectedKey}`) {
next(new UnauthorizedError());
return;
}
}
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
};
/**
* Get aggregated P50 and P95 latency percentiles for a given API slug.
*
* Aggregates across all label combinations (method, status_code, outcome)
* for that api_id. Returns null for both if no observations exist.
*
* This function only exposes aggregated summary statistics — never raw
* histogram buckets, tenant identifiers, or request paths.
*/
/**
* Extract individual metric values from the registry JSON for a given metric name.
* Matches the pattern used in existing tests (metricsLatency.test.ts).
*/
interface MetricEntry {
value: number;
labels: Record<string, string>;
metricName?: string;
}
async function getUpstreamMetricValues(): Promise<MetricEntry[]> {
const metrics = await register.getMetricsAsJSON();
const found = metrics.find((m: { name: string }) => m.name === 'gateway_upstream_duration_seconds');
return (found?.values ?? []) as MetricEntry[];
}
export async function getUpstreamHealth(apiSlug: string): Promise<{
p50: number | null;
p95: number | null;
}> {
const values = await getUpstreamMetricValues();
// Filter values matching this api_id
const matchingValues = values.filter((v) => v.labels?.api_id === apiSlug);
if (matchingValues.length === 0) {
return { p50: null, p95: null };
}
// Aggregate bucket counts across all label combinations
const bucketCounts = new Map<number, number>();
let totalCount = 0;
for (const v of matchingValues) {
if (v.metricName?.endsWith('_bucket')) {
const le = v.labels?.le;
if (le && le !== '+Inf') {
const bound = parseFloat(le);
if (!isNaN(bound)) {
bucketCounts.set(bound, (bucketCounts.get(bound) ?? 0) + v.value);
}
}
} else if (v.metricName?.endsWith('_count')) {
totalCount += v.value;
}
}
if (totalCount === 0) {
return { p50: null, p95: null };
}
// Sort bucket boundaries
const sortedBounds = [...bucketCounts.keys()].sort((a, b) => a - b);
// Build cumulative counts
let cumulativeCount = 0;
const cumulativeBuckets: Array<{ bound: number; cumulative: number }> = [];
for (const bound of sortedBounds) {
cumulativeCount += bucketCounts.get(bound) ?? 0;
cumulativeBuckets.push({ bound, cumulative: cumulativeCount });
}
const p50 = computePercentile(cumulativeBuckets, totalCount, 0.5);
const p95 = computePercentile(cumulativeBuckets, totalCount, 0.95);
return {
p50: p50 !== null ? Math.round(p50 * 1000) / 1000 : null,
p95: p95 !== null ? Math.round(p95 * 1000) / 1000 : null,
};
}
/**
* Compute a percentile value from cumulative histogram buckets using
* linear interpolation within the containing bucket.
*/
function computePercentile(
cumulativeBuckets: Array<{ bound: number; cumulative: number }>,
totalCount: number,
percentile: number,
): number | null {
if (totalCount === 0) return null;
const target = totalCount * percentile;
let prevBound = 0;
let prevCumulative = 0;
for (const bucket of cumulativeBuckets) {
if (bucket.cumulative >= target) {
const bucketWidth = bucket.bound - prevBound;
const countInBucket = bucket.cumulative - prevCumulative;
if (countInBucket <= 0) return bucket.bound;
const offsetInBucket = (target - prevCumulative) / countInBucket;
return prevBound + offsetInBucket * bucketWidth;
}
prevBound = bucket.bound;
prevCumulative = bucket.cumulative;
}
// Beyond all buckets — return the last known bound
return cumulativeBuckets.length > 0
? cumulativeBuckets[cumulativeBuckets.length - 1].bound
: null;
}
/** Exposed for testing — reset upstream profiling metrics. */
export function resetUpstreamMetrics(): void {
gatewayUpstreamDuration.reset();
gatewayUpstreamRequestsTotal.reset();
}
/** Exposed for testing — reset all HTTP metrics. */
export function resetHttpMetrics(): void {
httpRequestDuration.reset();
httpRequestDurationSummary.reset();
httpRouteDuration.reset();
httpRouteDurationSummary.reset();
httpRequestsTotal.reset();
}
// ── Listings cache hit/miss counters ─────────────────────────────────────────
//
// Metric: apis_listing_cache_hits_total
// Type: Counter
// Labels: (none — single series, low cardinality)
// Purpose: Count how many GET /api/apis responses were served from cache.
//
// Metric: apis_listing_cache_misses_total
// Type: Counter
// Labels: (none)
// Purpose: Count how many GET /api/apis responses required a DB read.
//
// Both counters are reset together with the other HTTP metrics in tests.
// ─────────────────────────────────────────────────────────────────────────────
const apisListingCacheHits = new client.Counter({
name: 'apis_listing_cache_hits_total',
help: 'Total number of GET /api/apis responses served from the in-process cache',
});
const apisListingCacheMisses = new client.Counter({
name: 'apis_listing_cache_misses_total',
help: 'Total number of GET /api/apis responses that required a database read (cache miss)',
});
register.registerMetric(apisListingCacheHits);
register.registerMetric(apisListingCacheMisses);
/** Increment the cache-hit counter. Called by the APIs listing route. */
export function recordCacheHit(): void {
apisListingCacheHits.inc();
}
/** Increment the cache-miss counter. Called by the APIs listing route. */
export function recordCacheMiss(): void {
apisListingCacheMisses.inc();
}
// ── Gateway API key lookup counter ────────────────────────────────────────────
//
// Metric: gateway_api_key_lookup_total
// Type: Counter
// Labels: outcome — hit | miss | revoked | expired
// Purpose: Track API key lookup outcomes in gateway auth middleware.
// ─────────────────────────────────────────────────────────────────────────────
const gatewayApiKeyLookupTotal = new client.Counter({
name: 'gateway_api_key_lookup_total',
help: 'Total API key lookups in gateway auth middleware',
labelNames: ['outcome'] as const,
});
register.registerMetric(gatewayApiKeyLookupTotal);
export type ApiKeyLookupOutcome = 'hit' | 'miss' | 'revoked' | 'expired';
export function recordApiKeyLookup(outcome: ApiKeyLookupOutcome): void {
gatewayApiKeyLookupTotal.inc({ outcome });
}
/** Reset gateway API key lookup metrics. Used in tests to isolate metric state. */
export function resetApiKeyLookupMetrics(): void {
gatewayApiKeyLookupTotal.reset();
}
// ── Proxy premature-abort counter ─────────────────────────────────────────────
//
// Metric: proxy_premature_aborts_total
// Type: Counter
// Labels: (none)
// Purpose: Count proxy responses that were aborted before the client received
// the full body (i.e. the TCP connection closed before the HTTP
// response finished). A non-zero value here indicates callers that
// were billed for calls they never fully received — investigate
// together with the upstream duration histogram.
// ─────────────────────────────────────────────────────────────────────────────
const proxyPrematureAbortsTotal = new client.Counter({
name: 'proxy_premature_aborts_total',
help: 'Total number of proxy responses where the client connection closed before the response finished (premature abort)',
});
const idempotencyStoreRows = new client.Gauge({
name: 'idempotency_store_rows',
help: 'Current number of rows in the idempotency_store table',
});
const endpointThroughputSaturationRatio = new client.Gauge({
name: 'gateway_endpoint_throughput_saturation_ratio',
help: 'Observed throughput divided by advertised limit for a gateway endpoint over the trailing 96h window',
labelNames: ['api_id', 'endpoint_id', 'endpoint_path'] as const,
});
register.registerMetric(proxyPrematureAbortsTotal);
register.registerMetric(idempotencyStoreRows);
register.registerMetric(endpointThroughputSaturationRatio);
/** Increment the premature-abort counter. Called by proxyRoutes when a response
* emits `close` without a preceding `finish` event. */
export function recordProxyPrematureAbort(): void {
proxyPrematureAbortsTotal.inc();
}
/** Update the current number of active idempotency rows for monitoring. */
export function setIdempotencyStoreRows(value: number): void {
idempotencyStoreRows.set(value);
}
interface ThroughputSaturationSample {
apiId: string;
endpointId: string;
endpointPath: string;
advertisedLimitPerMinute: number;
observedAt: number;
}
interface ThroughputSaturationSeries {
samples: ThroughputSaturationSample[];
}
const throughputSaturationSamples = new Map<string, ThroughputSaturationSeries>();
const SATURATION_WINDOW_MS = 96 * 60 * 60 * 1000;
function getThroughputSaturationKey(sample: ThroughputSaturationSample): string {
return `${sample.apiId}:${sample.endpointId}:${sample.endpointPath}`;
}
export function recordEndpointThroughputSaturation(sample: ThroughputSaturationSample): void {
if (!Number.isFinite(sample.advertisedLimitPerMinute) || sample.advertisedLimitPerMinute <= 0) {
return;
}
const key = getThroughputSaturationKey(sample);
const series = throughputSaturationSamples.get(key) ?? { samples: [] };
const cutoff = sample.observedAt - SATURATION_WINDOW_MS;
const retained = series.samples.filter((value) => value.observedAt >= cutoff);
retained.push(sample);
throughputSaturationSamples.set(key, { samples: retained });
const throughputPerMinute = retained.length;
const ratio = throughputPerMinute / (sample.advertisedLimitPerMinute * (SATURATION_WINDOW_MS / 60_000));
endpointThroughputSaturationRatio.set(
{ api_id: sample.apiId, endpoint_id: sample.endpointId, endpoint_path: sample.endpointPath },
ratio,
);
}
export function resetThroughputSaturationMetrics(): void {
throughputSaturationSamples.clear();
endpointThroughputSaturationRatio.reset();
}
/** Exposed for testing — reset all metrics including upstream and HTTP. */
export function setGatewayUpstreamBreakerState(apiId: string, state: number): void {
gatewayUpstreamBreakerState.set({ api_id: apiId }, state);
}
/** Exposed for testing - reset all metrics including upstream and HTTP. */
export function resetAllMetrics(): void {
resetUpstreamMetrics();
resetHttpMetrics();
apisListingCacheHits.reset();
apisListingCacheMisses.reset();
proxyPrematureAbortsTotal.reset();
idempotencyStoreRows.reset();
gatewayUpstreamBreakerState.reset();
resetSlowQueryAlerterMetrics();
resetUsageAnomalyDetectorMetrics();
resetReplicaMetrics();
resetApiKeyLookupMetrics();
resetThroughputSaturationMetrics();
}
// ── Replica routing metrics ───────────────────────────────────────────────────
//
// Metric: db_replica_queries_total
// Type: Counter
// Purpose: Count read queries successfully served by a replica.
//
// Metric: db_primary_queries_total
// Type: Counter
// Purpose: Count all queries routed to the primary (writes + no-replica reads
// + fallbacks after replica failure).
//
// Metric: db_replica_fallbacks_total
// Type: Counter
// Purpose: Count replica queries that failed and were retried on the primary.
// A rising value warrants investigation of replica health.
//
// Metric: db_replica_failures_total
// Type: Counter
// Purpose: Count individual replica connection/query errors (before fallback).
// ─────────────────────────────────────────────────────────────────────────────
const dbReplicaQueriesTotal = new client.Counter({
name: 'db_replica_queries_total',
help: 'Total number of read queries served by a PostgreSQL replica',
});
const dbPrimaryQueriesTotal = new client.Counter({
name: 'db_primary_queries_total',
help: 'Total number of queries routed to the primary PostgreSQL database (writes, fallbacks, and no-replica reads)',
});
const dbReplicaFallbacksTotal = new client.Counter({
name: 'db_replica_fallbacks_total',
help: 'Total number of replica queries that failed and were retried against the primary database',
});
const dbReplicaFailuresTotal = new client.Counter({
name: 'db_replica_failures_total',
help: 'Total number of individual replica connection or query errors',
});
register.registerMetric(dbReplicaQueriesTotal);
register.registerMetric(dbPrimaryQueriesTotal);
register.registerMetric(dbReplicaFallbacksTotal);
register.registerMetric(dbReplicaFailuresTotal);
/** Increment the replica query counter. Called by ReplicaPool on successful replica reads. */
export function recordReplicaQuery(): void {
dbReplicaQueriesTotal.inc();
}
/** Increment the primary query counter. Called by ReplicaPool on primary reads and all writes. */
export function recordPrimaryQuery(): void {
dbPrimaryQueriesTotal.inc();
}
/** Increment the fallback counter. Called by ReplicaPool when a replica error causes a primary retry. */
export function recordReplicaFallback(): void {
dbReplicaFallbacksTotal.inc();
}
/** Increment the replica failure counter. Called by ReplicaPool on each replica-level error. */
export function recordReplicaFailure(): void {
dbReplicaFailuresTotal.inc();
}
// ── Slow Query Alerter metrics ────────────────────────────────────────────────
//
// Metric: slow_query_alerter_runs_total
// Type: Counter
// Labels: (none)
// Purpose: Total number of poll cycles the slow query alerter has completed.
//
// Metric: slow_query_alerter_alerts_total
// Type: Counter
// Labels: (none)
// Purpose: Total number of webhook alerts fired.
//
// Metric: slow_query_alerter_queries_above_threshold
// Type: Gauge
// Labels: (none)
// Purpose: Number of queries exceeding the threshold in the most recent poll.
// ─────────────────────────────────────────────────────────────────────────────
const slowQueryAlerterRunsTotal = new client.Counter({
name: 'slow_query_alerter_runs_total',
help: 'Total number of slow query alerter poll cycles',
});
const slowQueryAlerterAlertsTotal = new client.Counter({
name: 'slow_query_alerter_alerts_total',
help: 'Total number of slow query alerts fired',
});
const slowQueryAlerterQueriesAboveThreshold = new client.Gauge({
name: 'slow_query_alerter_queries_above_threshold',
help: 'Number of queries exceeding the threshold in the most recent poll',
});
register.registerMetric(slowQueryAlerterRunsTotal);
register.registerMetric(slowQueryAlerterAlertsTotal);
register.registerMetric(slowQueryAlerterQueriesAboveThreshold);
export function recordSlowQueryAlerterRun(): void {
slowQueryAlerterRunsTotal.inc();
}
export function recordSlowQueryAlerterAlert(): void {
slowQueryAlerterAlertsTotal.inc();
}
export function recordSlowQueryAlerterQueriesAboveThreshold(count: number): void {
slowQueryAlerterQueriesAboveThreshold.set(count);
}
/** Reset slow query alerter metrics. Used in tests to isolate metric state. */
export function resetSlowQueryAlerterMetrics(): void {
slowQueryAlerterRunsTotal.reset();
slowQueryAlerterAlertsTotal.reset();
slowQueryAlerterQueriesAboveThreshold.reset();
}
// ── Usage anomaly detector metrics ────────────────────────────────────────────
const usageAnomalyDetectorRunsTotal = new client.Counter({
name: 'usage_anomaly_detector_runs_total',
help: 'Total number of usage anomaly detector scan cycles',
});
const usageAnomalyDetectorAnomaliesTotal = new client.Counter({
name: 'usage_anomaly_detector_anomalies_total',
help: 'Total number of usage anomalies emitted',
});
register.registerMetric(usageAnomalyDetectorRunsTotal);
register.registerMetric(usageAnomalyDetectorAnomaliesTotal);
export function recordUsageAnomalyDetectorRun(): void {
usageAnomalyDetectorRunsTotal.inc();
}
export function recordUsageAnomalyDetectorAnomaly(): void {
usageAnomalyDetectorAnomaliesTotal.inc();
}
export function resetUsageAnomalyDetectorMetrics(): void {
usageAnomalyDetectorRunsTotal.reset();
usageAnomalyDetectorAnomaliesTotal.reset();
}
/** Reset all replica routing metrics. Used in tests to isolate metric state. */
export function resetReplicaMetrics(): void {
dbReplicaQueriesTotal.reset();
dbPrimaryQueriesTotal.reset();
dbReplicaFallbacksTotal.reset();
dbReplicaFailuresTotal.reset();
}
// ── SLO alert metrics ───────────────────────────────────────────────────────
const sloAlerterRunsTotal = new client.Counter({
name: 'slo_alerter_runs_total',
help: 'Number of SLO alerter poll cycles',
});
const sloAlertsTotal = new client.Counter({
name: 'slo_alerts_total',
help: 'Number of SLO alerts fired',
labelNames: ['route', 'kind'] as const,
});
const sloActiveBurns = new client.Gauge({
name: 'slo_active_burns',
help: 'Number of currently active SLO burns',
});
const sloRecorderSamplesTotal = new client.Counter({
name: 'slo_recorder_samples_total',
help: 'Number of recorder samples processed',
labelNames: ['route'] as const,
});
register.registerMetric(sloAlerterRunsTotal);
register.registerMetric(sloAlertsTotal);
register.registerMetric(sloActiveBurns);
register.registerMetric(sloRecorderSamplesTotal);
export function recordSloAlerterRun(): void {
sloAlerterRunsTotal.inc();
}
export function recordSloAlert(route: string, kind: string): void {
sloAlertsTotal.labels(route, kind).inc();
}
export function setSloAlertActiveBurns(count: number): void {
sloActiveBurns.set(count);
}
export function recordSloRecorderSample(route: string): void {
sloRecorderSamplesTotal.labels(route).inc();
}