Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions Docs/vercel-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
```
5 changes: 4 additions & 1 deletion Server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -15,14 +17,15 @@ 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) => {
res.setHeader("Content-Type", "application/json");
res.send(swaggerSpec);
});

app.use("/api", routes);
app.use("/api", metricsMiddleware, routes);
app.use(errorHandler);

export default app;
3 changes: 3 additions & 0 deletions Server/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createChallenge,
verifyWalletSignature,
} from "../services/auth-challenge.service";
import { recordAuthVerifyFailure } from "../services/metrics.service";
import { LoginRequest, RegisterRequest } from "../types";

/**
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions Server/src/controllers/contracts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
tierCodeToLabel,
} from "../services/soroban.service";
import { verifyWalletSignature } from "../services/auth-challenge.service";
import { recordAuthVerifyFailure } from "../services/metrics.service";

/**
* @swagger
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions Server/src/controllers/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,6 +44,7 @@ const recordZeroScoreEvent = async (
verifiedAt: new Date(),
},
});
recordCreditEvent(platform.name, payload.eventType);

return res.status(200).json({
success: true,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions Server/src/controllers/metrics.controller.ts
Original file line number Diff line number Diff line change
@@ -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`)
}
28 changes: 28 additions & 0 deletions Server/src/middleware/__tests__/metrics.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
22 changes: 22 additions & 0 deletions Server/src/middleware/metrics.middleware.ts
Original file line number Diff line number Diff line change
@@ -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"
}
50 changes: 50 additions & 0 deletions Server/src/services/__tests__/metrics.service.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
Loading