Skip to content

Commit a3a17dd

Browse files
pjhamptonteresama
andauthored
🔭 feat(o11y): Add Prometheus Metrics Endpoint and HTTP Instrumentation (#26)
* feat(o11y): add prometheus metrics endpoint and HTTP instrumentation * chore: add authenticated endpoint --------- Co-authored-by: teresama <teresa.blancoabad@clickhouse.com>
1 parent f4e299b commit a3a17dd

5 files changed

Lines changed: 69 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
SESSION_SECRET=
66

77
# ── Optional ─────────────────────────────────────────────────
8+
# Bearer token required to scrape the /metrics (Prometheus) endpoint.
9+
# The endpoint returns 401 if this is unset or the token does not match.
10+
# ADMIN_PANEL_METRICS_SECRET=
11+
812
# Browser-facing URL of the LibreChat API server (used for OAuth redirects).
913
# Defaults to http://localhost:3080
1014
# VITE_API_BASE_URL=http://localhost:3080

bun.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"js-yaml": "^4.1.1",
4444
"librechat-data-provider": "^0.8.407",
4545
"lucide-react": "^0.545.0",
46+
"prom-client": "^15.1.3",
4647
"react": "^19.2.0",
4748
"react-dom": "^19.2.0",
4849
"react-i18next": "^16.5.4",

server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Glob } from 'bun';
22
import { join } from 'node:path';
3+
import { metricsResponse, httpRequestsTotal, httpRequestDurationSeconds } from './src/server/metrics';
34

45
const CLIENT_DIR = join(import.meta.dir, 'dist', 'client');
56
const SERVER_ENTRY = new URL('./dist/server/server.js', import.meta.url);
@@ -35,6 +36,10 @@ type Handler = { default: { fetch: (req: Request) => Promise<Response> } };
3536

3637
const { default: handler } = (await import(SERVER_ENTRY.href)) as Handler;
3738

39+
if (!process.env.ADMIN_PANEL_METRICS_SECRET) {
40+
console.warn('[metrics] ADMIN_PANEL_METRICS_SECRET is not set — /metrics will return 401 for all requests');
41+
}
42+
3843
async function buildStaticRoutes(): Promise<Record<string, () => Response>> {
3944
const routes: Record<string, () => Response> = {};
4045
for await (const path of new Glob('**/*').scan(CLIENT_DIR)) {
@@ -50,8 +55,14 @@ Bun.serve({
5055
port: Number(process.env.PORT ?? 3000),
5156
routes: {
5257
...(await buildStaticRoutes()),
58+
'/metrics': (req) => metricsResponse(req),
5359
'/*': async (req) => {
60+
const url = new URL(req.url);
61+
const end = httpRequestDurationSeconds.startTimer({ method: req.method, path: url.pathname });
5462
const res = await handler.fetch(req);
63+
const statusCode = String(res.status);
64+
httpRequestsTotal.inc({ method: req.method, path: url.pathname, status_code: statusCode });
65+
end({ status_code: statusCode });
5566
const patched = new Response(res.body, res);
5667
for (const [k, v] of Object.entries(NO_CACHE)) {
5768
patched.headers.set(k, v);

src/server/metrics.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { timingSafeEqual } from 'crypto';
2+
import client, { register } from 'prom-client';
3+
4+
client.collectDefaultMetrics();
5+
6+
export const httpRequestsTotal = new client.Counter({
7+
name: 'admin_http_requests_total',
8+
help: 'Total number of HTTP requests',
9+
labelNames: ['method', 'path', 'status_code'] as const,
10+
});
11+
12+
export const httpRequestDurationSeconds = new client.Histogram({
13+
name: 'admin_http_request_duration_seconds',
14+
help: 'Duration of HTTP requests in seconds',
15+
labelNames: ['method', 'path', 'status_code'] as const,
16+
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
17+
});
18+
19+
export async function metricsResponse(req: Request): Promise<Response> {
20+
const secret = process.env.ADMIN_PANEL_METRICS_SECRET;
21+
const auth = req.headers.get('authorization');
22+
if (!secret || !auth) return new Response(null, { status: 401 });
23+
24+
const token = auth.replace(/^bearer\s+/i, '');
25+
const encode = (s: string) => new TextEncoder().encode(s);
26+
const expected = encode(secret);
27+
const actual = encode(token);
28+
const maxLen = Math.max(expected.byteLength, actual.byteLength);
29+
const paddedExpected = new Uint8Array(maxLen);
30+
const paddedActual = new Uint8Array(maxLen);
31+
paddedExpected.set(expected);
32+
paddedActual.set(actual);
33+
const lengthMatch = expected.byteLength === actual.byteLength;
34+
if (!timingSafeEqual(paddedExpected, paddedActual) || !lengthMatch) {
35+
return new Response(null, { status: 401 });
36+
}
37+
38+
try {
39+
const data = await register.metrics();
40+
return new Response(data, { headers: { 'Content-Type': register.contentType } });
41+
} catch {
42+
return new Response(null, { status: 500 });
43+
}
44+
}

0 commit comments

Comments
 (0)