Skip to content

Commit c5199b1

Browse files
Merge pull request #160 from Jayking40/Performance--hot-path-profiling-hooks-for-gateway-proxy
perf(gateway): add upstream latency metrics
2 parents ab1678b + 4a43424 commit c5199b1

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import {
2+
startUpstreamTimer,
3+
isProfilingEnabled,
4+
resetUpstreamMetrics,
5+
} from '../metrics.js';
6+
import client from 'prom-client';
7+
8+
// ── Helpers ──────────────────────────────────────────────────────────────────
9+
10+
/** A single value entry from prom-client (includes metricName at runtime). */
11+
interface MetricEntry {
12+
value: number;
13+
labels: Record<string, string>;
14+
metricName?: string;
15+
}
16+
17+
/** Retrieve a single metric's collected values from the default registry. */
18+
async function getMetricValues(name: string) {
19+
const metrics = await client.register.getMetricsAsJSON();
20+
const found = metrics.find((m) => m.name === name);
21+
if (!found) return undefined;
22+
return { ...found, values: found.values as MetricEntry[] };
23+
}
24+
25+
// ── Setup / teardown ─────────────────────────────────────────────────────────
26+
27+
const originalEnv = process.env.GATEWAY_PROFILING_ENABLED;
28+
29+
afterEach(() => {
30+
// Restore env var to its original value between tests
31+
if (originalEnv === undefined) {
32+
delete process.env.GATEWAY_PROFILING_ENABLED;
33+
} else {
34+
process.env.GATEWAY_PROFILING_ENABLED = originalEnv;
35+
}
36+
resetUpstreamMetrics();
37+
});
38+
39+
// ── Tests ────────────────────────────────────────────────────────────────────
40+
41+
describe('isProfilingEnabled', () => {
42+
it('returns false when GATEWAY_PROFILING_ENABLED is unset', () => {
43+
delete process.env.GATEWAY_PROFILING_ENABLED;
44+
expect(isProfilingEnabled()).toBe(false);
45+
});
46+
47+
it('returns false for arbitrary truthy strings', () => {
48+
process.env.GATEWAY_PROFILING_ENABLED = 'yes';
49+
expect(isProfilingEnabled()).toBe(false);
50+
});
51+
52+
it('returns true only when set to "true"', () => {
53+
process.env.GATEWAY_PROFILING_ENABLED = 'true';
54+
expect(isProfilingEnabled()).toBe(true);
55+
});
56+
});
57+
58+
describe('startUpstreamTimer (profiling disabled)', () => {
59+
beforeEach(() => {
60+
delete process.env.GATEWAY_PROFILING_ENABLED;
61+
});
62+
63+
it('returns a no-op timer that does not throw', () => {
64+
const timer = startUpstreamTimer('api_1', 'GET');
65+
expect(() => timer.stop(200, 'success')).not.toThrow();
66+
});
67+
68+
it('does not record any histogram observations', async () => {
69+
const timer = startUpstreamTimer('api_1', 'POST');
70+
timer.stop(200, 'success');
71+
72+
const metric = await getMetricValues('gateway_upstream_duration_seconds');
73+
// When profiling is off the metric exists but has no observed values
74+
const values = metric?.values ?? [];
75+
expect(values.filter((v) => v.labels.api_id === 'api_1')).toHaveLength(0);
76+
});
77+
});
78+
79+
describe('startUpstreamTimer (profiling enabled)', () => {
80+
beforeEach(() => {
81+
process.env.GATEWAY_PROFILING_ENABLED = 'true';
82+
resetUpstreamMetrics();
83+
});
84+
85+
it('records a histogram observation on success', async () => {
86+
const timer = startUpstreamTimer('api_abc', 'GET');
87+
// Simulate a short delay
88+
await new Promise((r) => setTimeout(r, 15));
89+
timer.stop(200, 'success');
90+
91+
const metric = await getMetricValues('gateway_upstream_duration_seconds');
92+
expect(metric).toBeDefined();
93+
94+
const countEntry = (metric?.values ?? []).find(
95+
(v) =>
96+
v.metricName === 'gateway_upstream_duration_seconds_count' &&
97+
v.labels.api_id === 'api_abc' &&
98+
v.labels.method === 'GET' &&
99+
v.labels.status_code === '200' &&
100+
v.labels.outcome === 'success',
101+
);
102+
expect(countEntry).toBeDefined();
103+
expect(countEntry!.value).toBe(1);
104+
});
105+
106+
it('increments the upstream requests counter', async () => {
107+
const timer = startUpstreamTimer('api_xyz', 'POST');
108+
timer.stop(201, 'success');
109+
110+
const metric = await getMetricValues('gateway_upstream_requests_total');
111+
expect(metric).toBeDefined();
112+
113+
const entry = (metric?.values ?? []).find(
114+
(v) =>
115+
v.labels.api_id === 'api_xyz' &&
116+
v.labels.method === 'POST' &&
117+
v.labels.status_code === '201' &&
118+
v.labels.outcome === 'success',
119+
);
120+
expect(entry).toBeDefined();
121+
expect(entry!.value).toBe(1);
122+
});
123+
124+
it('records timeout outcome correctly', async () => {
125+
const timer = startUpstreamTimer('api_slow', 'GET');
126+
timer.stop(504, 'timeout');
127+
128+
const metric = await getMetricValues('gateway_upstream_requests_total');
129+
const entry = (metric?.values ?? []).find(
130+
(v) =>
131+
v.labels.api_id === 'api_slow' &&
132+
v.labels.outcome === 'timeout' &&
133+
v.labels.status_code === '504',
134+
);
135+
expect(entry).toBeDefined();
136+
expect(entry!.value).toBe(1);
137+
});
138+
139+
it('records error outcome correctly', async () => {
140+
const timer = startUpstreamTimer('api_down', 'POST');
141+
timer.stop(502, 'error');
142+
143+
const metric = await getMetricValues('gateway_upstream_requests_total');
144+
const entry = (metric?.values ?? []).find(
145+
(v) =>
146+
v.labels.api_id === 'api_down' &&
147+
v.labels.outcome === 'error' &&
148+
v.labels.status_code === '502',
149+
);
150+
expect(entry).toBeDefined();
151+
expect(entry!.value).toBe(1);
152+
});
153+
154+
it('normalises method to uppercase', async () => {
155+
const timer = startUpstreamTimer('api_case', 'post');
156+
timer.stop(200, 'success');
157+
158+
const metric = await getMetricValues('gateway_upstream_requests_total');
159+
const entry = (metric?.values ?? []).find(
160+
(v) => v.labels.api_id === 'api_case' && v.labels.method === 'POST',
161+
);
162+
expect(entry).toBeDefined();
163+
});
164+
165+
it('accumulates multiple observations for the same label set', async () => {
166+
for (let i = 0; i < 3; i++) {
167+
const timer = startUpstreamTimer('api_multi', 'GET');
168+
timer.stop(200, 'success');
169+
}
170+
171+
const metric = await getMetricValues('gateway_upstream_requests_total');
172+
const entry = (metric?.values ?? []).find(
173+
(v) => v.labels.api_id === 'api_multi' && v.labels.outcome === 'success',
174+
);
175+
expect(entry).toBeDefined();
176+
expect(entry!.value).toBe(3);
177+
});
178+
179+
it('records a positive duration value', async () => {
180+
const timer = startUpstreamTimer('api_dur', 'GET');
181+
await new Promise((r) => setTimeout(r, 10));
182+
timer.stop(200, 'success');
183+
184+
const metric = await getMetricValues('gateway_upstream_duration_seconds');
185+
const sumEntry = (metric?.values ?? []).find(
186+
(v) =>
187+
v.metricName === 'gateway_upstream_duration_seconds_sum' &&
188+
v.labels.api_id === 'api_dur',
189+
);
190+
expect(sumEntry).toBeDefined();
191+
expect(sumEntry!.value).toBeGreaterThan(0);
192+
});
193+
});
194+
195+
describe('metric registration', () => {
196+
it('gateway_upstream_duration_seconds is registered with correct buckets', async () => {
197+
process.env.GATEWAY_PROFILING_ENABLED = 'true';
198+
const timer = startUpstreamTimer('api_bucket', 'GET');
199+
timer.stop(200, 'success');
200+
201+
const metric = await getMetricValues('gateway_upstream_duration_seconds');
202+
expect(metric).toBeDefined();
203+
expect(metric!.type).toBe('histogram');
204+
205+
// Verify bucket boundaries exist in the values
206+
const bucketValues = (metric?.values ?? []).filter(
207+
(v) => v.metricName === 'gateway_upstream_duration_seconds_bucket',
208+
);
209+
const bucketLe = bucketValues.map((v) => Number(v.labels.le)).filter((n) => isFinite(n));
210+
expect(bucketLe).toEqual(expect.arrayContaining([0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]));
211+
});
212+
213+
it('gateway_upstream_requests_total is registered as a counter', async () => {
214+
process.env.GATEWAY_PROFILING_ENABLED = 'true';
215+
const timer = startUpstreamTimer('api_type', 'GET');
216+
timer.stop(200, 'success');
217+
218+
const metric = await getMetricValues('gateway_upstream_requests_total');
219+
expect(metric).toBeDefined();
220+
expect(metric!.type).toBe('counter');
221+
});
222+
});
223+
224+
describe('resetUpstreamMetrics', () => {
225+
it('clears previously recorded observations', async () => {
226+
process.env.GATEWAY_PROFILING_ENABLED = 'true';
227+
228+
const timer = startUpstreamTimer('api_reset', 'GET');
229+
timer.stop(200, 'success');
230+
231+
resetUpstreamMetrics();
232+
233+
const metric = await getMetricValues('gateway_upstream_requests_total');
234+
const entry = (metric?.values ?? []).find(
235+
(v) => v.labels.api_id === 'api_reset',
236+
);
237+
expect(entry).toBeUndefined();
238+
});
239+
});

src/metrics.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Request, Response, NextFunction } from 'express';
22
import client from 'prom-client';
3+
import { performance } from 'node:perf_hooks';
34

45
// Initialize the Prometheus Registry and collect default Node.js metrics (CPU, RAM, Event Loop)
56
const register = new client.Registry();
@@ -23,6 +24,83 @@ const httpRequestsTotal = new client.Counter({
2324
register.registerMetric(httpRequestDuration);
2425
register.registerMetric(httpRequestsTotal);
2526

27+
// ── Gateway upstream profiling ─────────────────────────────────────────────
28+
//
29+
// Metric: gateway_upstream_duration_seconds
30+
// Type: Histogram
31+
// Labels: api_id, method, status_code, outcome
32+
// Buckets: tuned for typical upstream API latencies (10 ms → 10 s)
33+
//
34+
// Metric: gateway_upstream_requests_total
35+
// Type: Counter
36+
// Labels: api_id, method, status_code, outcome
37+
//
38+
// Both metrics are gated behind GATEWAY_PROFILING_ENABLED=true.
39+
// When disabled the timer helper is a cheap no-op.
40+
// ────────────────────────────────────────────────────────────────────────────
41+
42+
const UPSTREAM_LABEL_NAMES = ['api_id', 'method', 'status_code', 'outcome'] as const;
43+
44+
const gatewayUpstreamDuration = new client.Histogram({
45+
name: 'gateway_upstream_duration_seconds',
46+
help: 'Latency of proxied requests to upstream services in seconds',
47+
labelNames: [...UPSTREAM_LABEL_NAMES],
48+
buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
49+
});
50+
51+
const gatewayUpstreamRequestsTotal = new client.Counter({
52+
name: 'gateway_upstream_requests_total',
53+
help: 'Total proxied requests forwarded to upstream services',
54+
labelNames: [...UPSTREAM_LABEL_NAMES],
55+
});
56+
57+
register.registerMetric(gatewayUpstreamDuration);
58+
register.registerMetric(gatewayUpstreamRequestsTotal);
59+
60+
/** Check whether gateway profiling hooks are active. */
61+
export function isProfilingEnabled(): boolean {
62+
return process.env.GATEWAY_PROFILING_ENABLED === 'true';
63+
}
64+
65+
export type UpstreamOutcome = 'success' | 'timeout' | 'error';
66+
67+
interface UpstreamTimer {
68+
/** Call once the upstream response (or error) has been received. */
69+
stop(statusCode: number, outcome: UpstreamOutcome): void;
70+
}
71+
72+
const NOOP_TIMER: UpstreamTimer = { stop() {} };
73+
74+
/**
75+
* Begin timing an upstream request.
76+
*
77+
* Returns a timer whose `stop()` method records the observed latency and
78+
* increments the request counter. When profiling is disabled the returned
79+
* timer is a zero-cost no-op.
80+
*
81+
* Labels intentionally avoid PII — only the API identifier and HTTP method
82+
* are captured, never user IDs, API keys, or request paths.
83+
*/
84+
export function startUpstreamTimer(apiId: string, method: string): UpstreamTimer {
85+
if (!isProfilingEnabled()) return NOOP_TIMER;
86+
87+
const start = performance.now();
88+
89+
return {
90+
stop(statusCode: number, outcome: UpstreamOutcome) {
91+
const durationSec = (performance.now() - start) / 1000;
92+
const labels = {
93+
api_id: apiId,
94+
method: method.toUpperCase(),
95+
status_code: String(statusCode),
96+
outcome,
97+
};
98+
gatewayUpstreamDuration.observe(labels, durationSec);
99+
gatewayUpstreamRequestsTotal.inc(labels);
100+
},
101+
};
102+
}
103+
26104
/**
27105
* Global middleware to record request metrics.
28106
* Safely extracts the parameterized route to prevent PII leakage and cardinality explosions.
@@ -60,6 +138,12 @@ export const metricsMiddleware = (req: Request, res: Response, next: NextFunctio
60138
* Controller to expose the /api/metrics endpoint.
61139
* Protected by a Bearer token in production environments.
62140
*/
141+
/** Exposed for testing — reset upstream profiling metrics. */
142+
export function resetUpstreamMetrics(): void {
143+
gatewayUpstreamDuration.reset();
144+
gatewayUpstreamRequestsTotal.reset();
145+
}
146+
63147
export const metricsEndpoint = async (req: Request, res: Response) => {
64148
const isProduction = process.env.NODE_ENV === 'production';
65149
const expectedKey = process.env.METRICS_API_KEY;

src/routes/gatewayRoutes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Router, Request, Response } from 'express';
22
import { randomUUID } from 'node:crypto';
33
import { GatewayDeps } from '../types/gateway.js';
4+
import { startUpstreamTimer } from '../metrics.js';
45

56
const CREDIT_COST_PER_CALL = 1; // cost per proxied request
67

@@ -62,6 +63,7 @@ export function createGatewayRouter(deps: GatewayDeps): Router {
6263
// 4. Proxy to upstream
6364
let upstreamStatus = 502;
6465
let upstreamBody: string = '{"error":"Bad Gateway"}';
66+
const timer = startUpstreamTimer(req.params.apiId, req.method);
6567

6668
try {
6769
const upstreamRes = await fetch(`${upstreamUrl}${req.path}`, {
@@ -72,9 +74,11 @@ export function createGatewayRouter(deps: GatewayDeps): Router {
7274

7375
upstreamStatus = upstreamRes.status;
7476
upstreamBody = await upstreamRes.text();
77+
timer.stop(upstreamStatus, 'success');
7578
} catch {
7679
upstreamStatus = 502;
7780
upstreamBody = JSON.stringify({ error: 'Bad Gateway: upstream unreachable' });
81+
timer.stop(upstreamStatus, 'error');
7882
}
7983

8084
// 5. Record usage event

0 commit comments

Comments
 (0)