From 85da069afe5eac7b2eb220327598251db5d8f006 Mon Sep 17 00:00:00 2001 From: Errordog2 Date: Sat, 20 Jun 2026 06:30:12 +0800 Subject: [PATCH] feat: add Prometheus metrics endpoint --- Docs/vercel-deploy.md | 25 +++ Server/src/app.ts | 5 +- Server/src/controllers/auth.controller.ts | 3 + .../src/controllers/contracts.controller.ts | 2 + Server/src/controllers/events.controller.ts | 3 + Server/src/controllers/metrics.controller.ts | 15 ++ .../__tests__/metrics.middleware.test.ts | 28 +++ Server/src/middleware/metrics.middleware.ts | 22 ++ .../__tests__/metrics.service.test.ts | 50 +++++ Server/src/services/metrics.service.ts | 200 ++++++++++++++++++ Server/src/services/stellar.service.ts | 7 + 11 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 Server/src/controllers/metrics.controller.ts create mode 100644 Server/src/middleware/__tests__/metrics.middleware.test.ts create mode 100644 Server/src/middleware/metrics.middleware.ts create mode 100644 Server/src/services/__tests__/metrics.service.test.ts create mode 100644 Server/src/services/metrics.service.ts diff --git a/Docs/vercel-deploy.md b/Docs/vercel-deploy.md index cffa9f2..768ed62 100644 --- a/Docs/vercel-deploy.md +++ b/Docs/vercel-deploy.md @@ -45,3 +45,28 @@ curl https://zcore-api.vercel.app/health curl https://zcore-api.vercel.app/health/ready curl https://dapp-zcore.vercel.app ``` + +## Prometheus scrape config + +`GET /metrics` exposes Prometheus text format for API request counts, +accepted credit events, Horizon failures, wallet-signature verification +failures, and Prisma query duration observations. Labels are bounded to +method, route template, status, platform, event type, operation, and auth route; +wallet addresses and transaction hashes are not emitted as labels. + +Set `METRICS_SECRET` on the `zcore-api` Vercel project to require bearer-token +authentication for the metrics endpoint. If it is set, scrapers must include +the token: + +```yaml +scrape_configs: + - job_name: zcore-api + metrics_path: /metrics + scheme: https + static_configs: + - targets: + - zcore-api.vercel.app + authorization: + type: Bearer + credentials: ${METRICS_SECRET} +``` diff --git a/Server/src/app.ts b/Server/src/app.ts index 82de92c..15118ca 100644 --- a/Server/src/app.ts +++ b/Server/src/app.ts @@ -4,8 +4,10 @@ import cors from "cors"; import swaggerUi from "swagger-ui-express"; import routes from "./routes"; import { errorHandler } from "./middleware/error.middleware"; +import { metricsMiddleware } from "./middleware/metrics.middleware"; import { swaggerSpec } from "./config/swagger"; import { healthCheck, livenessCheck, readinessCheck } from "./controllers/health.controller"; +import { metricsHandler } from "./controllers/metrics.controller"; const app = express(); @@ -15,6 +17,7 @@ app.use(express.json()); app.get("/health", healthCheck); app.get("/health/live", livenessCheck); app.get("/health/ready", readinessCheck); +app.get("/metrics", metricsHandler); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); app.get("/api-docs.json", (_req, res) => { @@ -22,7 +25,7 @@ app.get("/api-docs.json", (_req, res) => { res.send(swaggerSpec); }); -app.use("/api", routes); +app.use("/api", metricsMiddleware, routes); app.use(errorHandler); export default app; diff --git a/Server/src/controllers/auth.controller.ts b/Server/src/controllers/auth.controller.ts index 2216a15..32f9ebd 100644 --- a/Server/src/controllers/auth.controller.ts +++ b/Server/src/controllers/auth.controller.ts @@ -8,6 +8,7 @@ import { createChallenge, verifyWalletSignature, } from "../services/auth-challenge.service"; +import { recordAuthVerifyFailure } from "../services/metrics.service"; import { LoginRequest, RegisterRequest } from "../types"; /** @@ -304,6 +305,7 @@ export const loginWithSignature = async ( }; if (!(await verifyWalletSignature(walletAddress, message, signature))) { + recordAuthVerifyFailure("/api/auth/login/signed"); return res.status(401).json({ success: false, error: "Invalid wallet signature", @@ -344,6 +346,7 @@ export const registerWithSignature = async ( }; if (!(await verifyWalletSignature(walletAddress, message, signature))) { + recordAuthVerifyFailure("/api/auth/register/signed"); return res.status(401).json({ success: false, error: "Invalid wallet signature", diff --git a/Server/src/controllers/contracts.controller.ts b/Server/src/controllers/contracts.controller.ts index ebf362a..bc73ac9 100644 --- a/Server/src/controllers/contracts.controller.ts +++ b/Server/src/controllers/contracts.controller.ts @@ -7,6 +7,7 @@ import { tierCodeToLabel, } from "../services/soroban.service"; import { verifyWalletSignature } from "../services/auth-challenge.service"; +import { recordAuthVerifyFailure } from "../services/metrics.service"; /** * @swagger @@ -99,6 +100,7 @@ export const attestScore = async ( } if (!(await verifyWalletSignature(wallet, message, signature))) { + recordAuthVerifyFailure("/api/user/:wallet/attest"); return res.status(401).json({ success: false, error: "Invalid wallet signature", diff --git a/Server/src/controllers/events.controller.ts b/Server/src/controllers/events.controller.ts index 84cd874..81faa7a 100644 --- a/Server/src/controllers/events.controller.ts +++ b/Server/src/controllers/events.controller.ts @@ -11,6 +11,7 @@ import { fetchStellarWalletData, verifyTransaction, } from "../services/stellar.service"; +import { recordCreditEvent } from "../services/metrics.service"; import { Platform, User } from "@prisma/client"; const MIN_WALLET_AGE_DAYS = 30; @@ -43,6 +44,7 @@ const recordZeroScoreEvent = async ( verifiedAt: new Date(), }, }); + recordCreditEvent(platform.name, payload.eventType); return res.status(200).json({ success: true, @@ -294,6 +296,7 @@ export const reportCreditEvent = async ( data: { score: newScore, profileTier: newTier }, }), ]); + recordCreditEvent(platform.name, payload.eventType); return res.status(200).json({ success: true, diff --git a/Server/src/controllers/metrics.controller.ts b/Server/src/controllers/metrics.controller.ts new file mode 100644 index 0000000..f9ddc4a --- /dev/null +++ b/Server/src/controllers/metrics.controller.ts @@ -0,0 +1,15 @@ +import { Request, Response } from "express" +import { renderMetrics } from "../services/metrics.service" + +export function metricsHandler(req: Request, res: Response) { + const secret = process.env.METRICS_SECRET + if (secret && req.get("authorization") !== `Bearer ${secret}`) { + return res.status(401).json({ + success: false, + error: "Unauthorized metrics request", + }) + } + + res.setHeader("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + return res.status(200).send(`${renderMetrics()}\n`) +} diff --git a/Server/src/middleware/__tests__/metrics.middleware.test.ts b/Server/src/middleware/__tests__/metrics.middleware.test.ts new file mode 100644 index 0000000..d3199f6 --- /dev/null +++ b/Server/src/middleware/__tests__/metrics.middleware.test.ts @@ -0,0 +1,28 @@ +import { EventEmitter } from "events" +import { beforeEach, describe, expect, it } from "vitest" +import { metricsMiddleware } from "../metrics.middleware" +import { renderMetrics, resetMetricsForTests } from "../../services/metrics.service" + +describe("metricsMiddleware", () => { + beforeEach(() => { + resetMetricsForTests() + }) + + it("increments one HTTP request counter when a response finishes", () => { + const req = { + method: "POST", + baseUrl: "/api/events", + route: { path: "/report" }, + } as any + const res = new EventEmitter() as any + res.statusCode = 201 + const next = () => undefined + + metricsMiddleware(req, res, next) + res.emit("finish") + + expect(renderMetrics()).toContain( + 'zcore_http_requests_total{method="POST",route="/api/events/report",status="201"} 1' + ) + }) +}) diff --git a/Server/src/middleware/metrics.middleware.ts b/Server/src/middleware/metrics.middleware.ts new file mode 100644 index 0000000..73b398c --- /dev/null +++ b/Server/src/middleware/metrics.middleware.ts @@ -0,0 +1,22 @@ +import { NextFunction, Request, Response } from "express" +import { recordHttpRequest } from "../services/metrics.service" + +export function metricsMiddleware( + req: Request, + res: Response, + next: NextFunction +) { + res.on("finish", () => { + recordHttpRequest(req.method, getRouteLabel(req), res.statusCode) + }) + + next() +} + +function getRouteLabel(req: Request): string { + if (req.route?.path) { + return `${req.baseUrl}${req.route.path}` + } + + return req.baseUrl || "/api/unmatched" +} diff --git a/Server/src/services/__tests__/metrics.service.test.ts b/Server/src/services/__tests__/metrics.service.test.ts new file mode 100644 index 0000000..9753072 --- /dev/null +++ b/Server/src/services/__tests__/metrics.service.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, beforeEach } from "vitest" +import { + observeDbQueryDuration, + recordAuthVerifyFailure, + recordCreditEvent, + recordHorizonError, + recordHttpRequest, + renderMetrics, + resetMetricsForTests, +} from "../metrics.service" + +describe("metrics service", () => { + beforeEach(() => { + resetMetricsForTests() + }) + + it("renders Prometheus counters for bounded labels", () => { + recordHttpRequest("get", "/api/user/:wallet/profile", 200) + recordCreditEvent("trustless_work", "loan_repaid") + recordHorizonError("verify_transaction") + recordAuthVerifyFailure("/api/auth/login/signed") + + const metrics = renderMetrics() + + expect(metrics).toContain("# TYPE zcore_http_requests_total counter") + expect(metrics).toContain( + 'zcore_http_requests_total{method="GET",route="/api/user/:wallet/profile",status="200"} 1' + ) + expect(metrics).toContain( + 'zcore_events_reported_total{eventType="loan_repaid",platform="trustless_work"} 1' + ) + expect(metrics).toContain( + 'zcore_horizon_errors_total{operation="verify_transaction"} 1' + ) + expect(metrics).toContain( + 'zcore_auth_verify_failures_total{route="_api_auth_login_signed"} 1' + ) + }) + + it("renders Prisma query duration histogram buckets", () => { + observeDbQueryDuration(0.03) + + const metrics = renderMetrics() + + expect(metrics).toContain("# TYPE zcore_db_query_duration_seconds histogram") + expect(metrics).toContain('zcore_db_query_duration_seconds_bucket{le="0.05"} 1') + expect(metrics).toContain('zcore_db_query_duration_seconds_bucket{le="+Inf"} 1') + expect(metrics).toContain("zcore_db_query_duration_seconds_count 1") + }) +}) diff --git a/Server/src/services/metrics.service.ts b/Server/src/services/metrics.service.ts new file mode 100644 index 0000000..ef6eabb --- /dev/null +++ b/Server/src/services/metrics.service.ts @@ -0,0 +1,200 @@ +type LabelValue = string | number +type Labels = Record + +interface CounterSeries { + help: string + type: "counter" + values: Map +} + +interface HistogramSeries { + help: string + type: "histogram" + buckets: number[] + values: Map< + string, + { + count: number + sum: number + buckets: number[] + } + > +} + +const counters = { + httpRequests: createCounter( + "zcore_http_requests_total", + "HTTP requests by method, route, and status." + ), + eventsReported: createCounter( + "zcore_events_reported_total", + "Credit events accepted by platform and event type." + ), + horizonErrors: createCounter( + "zcore_horizon_errors_total", + "Stellar Horizon request failures by operation." + ), + authVerifyFailures: createCounter( + "zcore_auth_verify_failures_total", + "SEP-53 wallet signature verification failures by route." + ), +} + +const dbQueryDuration = createHistogram( + "zcore_db_query_duration_seconds", + "Observed Prisma query duration in seconds.", + [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5] +) + +export function recordHttpRequest( + method: string, + route: string, + statusCode: number +) { + increment(counters.httpRequests, { + method: method.toUpperCase(), + route, + status: statusCode, + }) +} + +export function recordCreditEvent(platform: string, eventType: string) { + increment(counters.eventsReported, { + platform: sanitizeLabelValue(platform), + eventType: sanitizeLabelValue(eventType), + }) +} + +export function recordHorizonError(operation: string) { + increment(counters.horizonErrors, { + operation: sanitizeLabelValue(operation), + }) +} + +export function recordAuthVerifyFailure(route: string) { + increment(counters.authVerifyFailures, { + route: sanitizeLabelValue(route), + }) +} + +export function observeDbQueryDuration(seconds: number) { + observe(dbQueryDuration, {}, seconds) +} + +export function renderMetrics(): string { + return [ + renderCounter("zcore_http_requests_total", counters.httpRequests), + renderCounter("zcore_events_reported_total", counters.eventsReported), + renderCounter("zcore_horizon_errors_total", counters.horizonErrors), + renderCounter( + "zcore_auth_verify_failures_total", + counters.authVerifyFailures + ), + renderHistogram("zcore_db_query_duration_seconds", dbQueryDuration), + ] + .filter(Boolean) + .join("\n") +} + +export function resetMetricsForTests() { + Object.values(counters).forEach((counter) => counter.values.clear()) + dbQueryDuration.values.clear() +} + +function createCounter(name: string, help: string): CounterSeries { + void name + return { + help, + type: "counter", + values: new Map(), + } +} + +function createHistogram( + name: string, + help: string, + buckets: number[] +): HistogramSeries { + void name + return { + help, + type: "histogram", + buckets, + values: new Map(), + } +} + +function increment(counter: CounterSeries, labels: Labels) { + const key = serializeLabels(labels) + counter.values.set(key, (counter.values.get(key) ?? 0) + 1) +} + +function observe(histogram: HistogramSeries, labels: Labels, value: number) { + const key = serializeLabels(labels) + const existing = + histogram.values.get(key) ?? + { + count: 0, + sum: 0, + buckets: histogram.buckets.map(() => 0), + } + + existing.count += 1 + existing.sum += value + histogram.buckets.forEach((bucket, index) => { + if (value <= bucket) existing.buckets[index] += 1 + }) + + histogram.values.set(key, existing) +} + +function renderCounter(name: string, counter: CounterSeries): string { + return [ + `# HELP ${name} ${counter.help}`, + `# TYPE ${name} ${counter.type}`, + ...[...counter.values.entries()].map( + ([key, value]) => `${name}${key} ${value}` + ), + ].join("\n") +} + +function renderHistogram(name: string, histogram: HistogramSeries): string { + const lines = [`# HELP ${name} ${histogram.help}`, `# TYPE ${name} histogram`] + + for (const [key, value] of histogram.values.entries()) { + histogram.buckets.forEach((bucket, index) => { + lines.push( + `${name}_bucket${appendLabel(key, "le", bucket)} ${value.buckets[index]}` + ) + }) + lines.push(`${name}_bucket${appendLabel(key, "le", "+Inf")} ${value.count}`) + lines.push(`${name}_sum${key} ${value.sum}`) + lines.push(`${name}_count${key} ${value.count}`) + } + + return lines.join("\n") +} + +function serializeLabels(labels: Labels): string { + const entries = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)) + if (entries.length === 0) return "" + + return `{${entries + .map(([name, value]) => `${name}="${escapeLabelValue(String(value))}"`) + .join(",")}}` +} + +function appendLabel(serializedLabels: string, name: string, value: LabelValue) { + const suffix = `${name}="${escapeLabelValue(String(value))}"` + if (!serializedLabels) return `{${suffix}}` + + return `${serializedLabels.slice(0, -1)},${suffix}}` +} + +function escapeLabelValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"') +} + +function sanitizeLabelValue(value: string): string { + return value.trim().replace(/[^a-zA-Z0-9_:-]/g, "_") || "unknown" +} diff --git a/Server/src/services/stellar.service.ts b/Server/src/services/stellar.service.ts index 7b5f0a0..1ff16b8 100644 --- a/Server/src/services/stellar.service.ts +++ b/Server/src/services/stellar.service.ts @@ -1,3 +1,5 @@ +import { recordHorizonError } from "./metrics.service"; + interface StellarTransaction { id: string; created_at: string; @@ -83,6 +85,7 @@ export const fetchStellarWalletData = async ( ); if (!accountResponse.ok) { + recordHorizonError("fetch_account"); if (accountResponse.status === 404) { throw new Error("Wallet not found on Stellar network"); } @@ -95,6 +98,7 @@ export const fetchStellarWalletData = async ( `${HORIZON_URL}/accounts/${walletAddress}/transactions?order=asc&limit=1` ); if (!firstTxResponse.ok) { + recordHorizonError("fetch_first_transaction"); throw new Error( `Error fetching first transaction: ${firstTxResponse.status}` ); @@ -106,6 +110,7 @@ export const fetchStellarWalletData = async ( `${HORIZON_URL}/accounts/${walletAddress}/transactions?limit=200&order=desc` ); if (!allTxResponse.ok) { + recordHorizonError("fetch_transactions"); throw new Error(`Error fetching transactions: ${allTxResponse.status}`); } const allTxData = (await allTxResponse.json()) as StellarAccountResponse; @@ -173,6 +178,7 @@ export const verifyTransaction = async ( const response = await fetch(`${HORIZON_URL}/transactions/${txHash}`); if (!response.ok) { + recordHorizonError("verify_transaction"); if (response.status === 404) { return { valid: false, @@ -200,6 +206,7 @@ export const verifyTransaction = async ( createdAt: tx.created_at, }; } catch { + recordHorizonError("verify_transaction"); return { valid: false, successful: false,