Skip to content

Commit c9ac0ad

Browse files
authored
Merge pull request #711 from mikewheeleer/codex/issue-665
feat: expose Prometheus metrics endpoint
2 parents 0807f2d + 01d86f9 commit c9ac0ad

5 files changed

Lines changed: 118 additions & 2 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,3 +1003,10 @@ The codebase includes comprehensive verification patterns:
10031003
- **Address Normalization**: `normalizeStarknetAddress` enforces canonical 66-character lower-case hex format and SNIP-23 checksum validation.
10041004
- **Starknet Failover**: `STARKNET_RPC_URL` supports comma-separated RPC endpoints with automatic failover.
10051005
- **Auth Session Family Revocation**: Session token rotation with `familyId` revocation protects against refresh token replay attacks.
1006+
# Metrics
1007+
1008+
`GET /metrics` exposes the existing process-local auth, billing, diagnostics,
1009+
session, and Starknet snapshots in Prometheus text format. It is mounted
1010+
outside `/api/v1` so a scraper does not need API-version routing. Protect the
1011+
endpoint at the ingress/network layer when operational metrics should not be
1012+
public.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"helmet": "^8.3.0",
3535
"nodemailer": "^9.0.3",
3636
"pg": "^8.13.1",
37+
"prom-client": "^15.1.3",
3738
"starknet": "^10.0.2",
3839
"zod": "^4.4.3"
3940
},

pnpm-lock.yaml

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

src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { accessLogMiddleware } from "./middleware/access-log.js";
3131
import { requestIdMiddleware } from "./middleware/request-id.js";
3232
import { verifyAbiCompatibility } from "./starknet/abi.js";
3333
import { provider, getEscrowAbi, getAgreementAbi } from "./starknet/client.js";
34+
import { metricsRegistry, renderMetrics } from "./observability/metrics.js";
3435

3536
export const app = express();
3637
initLogger();
@@ -84,6 +85,18 @@ app.use(
8485
);
8586
app.use(express.json({ limit: "1mb" }));
8687

88+
// Prometheus scraping is intentionally outside /api/v1. Deployments should
89+
// restrict this endpoint at the network or ingress layer when metrics are
90+
// not intended to be public.
91+
app.get("/metrics", async (_req, res, next) => {
92+
try {
93+
res.setHeader("Content-Type", metricsRegistry.contentType);
94+
res.send(await renderMetrics());
95+
} catch (error) {
96+
next(error);
97+
}
98+
});
99+
87100
app.use(dbReadinessMiddleware);
88101

89102
// Rate limiting: limiters are built via the shared factory so the

src/observability/metrics.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { Counter, Gauge, Registry } from "prom-client";
2+
import { getAuthMetricsSnapshot as getAuthMiddlewareMetricsSnapshot } from "../auth/middleware-metrics.js";
3+
import { getSessionMetricsSnapshot } from "../auth/session-metrics.js";
4+
import { getAuthMetricsSnapshot as getAuthRouteMetricsSnapshot } from "../routes/auth-metrics.js";
5+
import { getBillingMetricsSnapshot } from "../routes/billing-metrics.js";
6+
import { getDiagnosticsMetricsSnapshot } from "../routes/diagnostics-metrics.js";
7+
import { getStarknetMetricsSnapshot } from "../starknet/client-metrics.js";
8+
9+
type Snapshot = { counters: Record<string, number>; gauges?: Record<string, number> };
10+
type Metric = Counter<string> | Gauge<string>;
11+
12+
export const metricsRegistry = new Registry();
13+
const metrics = new Map<string, Metric>();
14+
15+
function metricName(name: string): string {
16+
const normalized = name.toLowerCase().replace(/[^a-z0-9_:]/g, "_");
17+
return normalized.endsWith("_total") ? normalized.slice(0, -6) : normalized;
18+
}
19+
20+
function updateSnapshot(snapshot: Snapshot): void {
21+
for (const [name, value] of Object.entries(snapshot.counters)) {
22+
const key = `counter:${name}`;
23+
let metric = metrics.get(key) as Counter<string> | undefined;
24+
if (!metric) {
25+
metric = new Counter({
26+
name: metricName(name),
27+
help: `${name} counter`,
28+
registers: [metricsRegistry],
29+
});
30+
metrics.set(key, metric);
31+
}
32+
metric.reset();
33+
metric.inc(value);
34+
}
35+
36+
for (const [name, value] of Object.entries(snapshot.gauges ?? {})) {
37+
const key = `gauge:${name}`;
38+
let metric = metrics.get(key) as Gauge<string> | undefined;
39+
if (!metric) {
40+
metric = new Gauge({
41+
name: metricName(name),
42+
help: `${name} gauge`,
43+
registers: [metricsRegistry],
44+
});
45+
metrics.set(key, metric);
46+
}
47+
metric.set(value);
48+
}
49+
}
50+
51+
/** Refreshes the shared registry from the existing snapshot-based metrics API. */
52+
export function refreshMetrics(): void {
53+
updateSnapshot(getAuthMiddlewareMetricsSnapshot());
54+
updateSnapshot(getSessionMetricsSnapshot());
55+
updateSnapshot(getAuthRouteMetricsSnapshot());
56+
updateSnapshot(getBillingMetricsSnapshot());
57+
updateSnapshot(getDiagnosticsMetricsSnapshot());
58+
updateSnapshot(getStarknetMetricsSnapshot());
59+
}
60+
61+
export async function renderMetrics(): Promise<string> {
62+
refreshMetrics();
63+
return metricsRegistry.metrics();
64+
}

0 commit comments

Comments
 (0)