Skip to content

Commit b3eeb48

Browse files
committed
add http server metrics
1 parent 26472d3 commit b3eeb48

File tree

2 files changed

+86
-15
lines changed

2 files changed

+86
-15
lines changed

packages/core/src/v3/serverOnly/httpServer.ts

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
22
import { z } from "zod";
33
import { SimpleStructuredLogger } from "../utils/structuredLogger.js";
44
import { HttpReply, getJsonBody } from "../apps/http.js";
5+
import { Registry, Histogram, Counter } from "prom-client";
6+
import { tryCatch } from "../../utils.js";
57

68
const logger = new SimpleStructuredLogger("worker-http");
79

@@ -51,21 +53,72 @@ type RouteMap = Partial<{
5153
type HttpServerOptions = {
5254
port: number;
5355
host: string;
56+
metrics?: {
57+
register?: Registry;
58+
expose?: boolean;
59+
collect?: boolean;
60+
};
5461
};
5562

5663
export class HttpServer {
64+
private static httpRequestDuration?: Histogram;
65+
private static httpRequestTotal?: Counter;
66+
67+
private readonly metricsRegister?: Registry;
68+
5769
private readonly port: number;
5870
private readonly host: string;
5971
private routes: RouteMap = {};
60-
6172
public readonly server: ReturnType<typeof createServer>;
6273

6374
constructor(options: HttpServerOptions) {
6475
this.port = options.port;
6576
this.host = options.host;
77+
this.metricsRegister = options.metrics?.register;
78+
const collectMetrics = options.metrics?.collect ?? true;
79+
const exposeMetrics = options.metrics?.expose ?? false;
80+
81+
// Initialize metrics only if registry is provided and not already initialized
82+
if (this.metricsRegister && collectMetrics) {
83+
if (!HttpServer.httpRequestDuration) {
84+
HttpServer.httpRequestDuration = new Histogram({
85+
name: "http_request_duration_seconds",
86+
help: "Duration of HTTP requests in seconds",
87+
labelNames: ["method", "route", "status", "port", "host"],
88+
registers: [this.metricsRegister],
89+
});
90+
}
91+
92+
if (!HttpServer.httpRequestTotal) {
93+
HttpServer.httpRequestTotal = new Counter({
94+
name: "http_requests_total",
95+
help: "Total number of HTTP requests",
96+
labelNames: ["method", "route", "status", "port", "host"],
97+
registers: [this.metricsRegister],
98+
});
99+
}
100+
}
101+
102+
if (exposeMetrics) {
103+
// Register metrics route
104+
this.route("/metrics", "GET", {
105+
handler: async ({ reply }) => {
106+
if (!this.metricsRegister) {
107+
return reply.text("Metrics registry not found", 500);
108+
}
109+
110+
return reply.text(
111+
await this.metricsRegister.metrics(),
112+
200,
113+
this.metricsRegister.contentType
114+
);
115+
},
116+
});
117+
}
66118

67119
this.server = createServer(async (req, res) => {
68120
const reply = new HttpReply(res);
121+
const startTime = process.hrtime();
69122

70123
try {
71124
const { url, method } = req;
@@ -98,13 +151,6 @@ export class HttpServer {
98151

99152
const routeDefinition = this.routes[route]?.[httpMethod.data];
100153

101-
// logger.debug("Matched route", {
102-
// url,
103-
// method,
104-
// route,
105-
// routeDefinition,
106-
// });
107-
108154
if (!routeDefinition) {
109155
logger.error("Invalid method", { url, method, parsedMethod: httpMethod.data });
110156
return reply.empty(405);
@@ -141,25 +187,28 @@ export class HttpServer {
141187
return reply.json({ ok: false, error: "Invalid body" }, false, 400);
142188
}
143189

144-
try {
145-
await handler({
190+
const [error] = await tryCatch(
191+
handler({
146192
reply,
147193
req,
148194
res,
149195
params: parsedParams.data,
150196
queryParams: parsedQueryParams.data,
151197
body: parsedBody.data,
152-
});
153-
} catch (handlerError) {
154-
logger.error("Route handler error", { error: handlerError });
198+
})
199+
);
200+
201+
if (error) {
202+
logger.error("Route handler error", { error });
155203
return reply.empty(500);
156204
}
157205
} catch (error) {
158206
logger.error("Failed to handle request", { error });
159207
return reply.empty(500);
208+
} finally {
209+
this.collectMetrics(req, res, startTime);
210+
return;
160211
}
161-
162-
return;
163212
});
164213

165214
this.server.on("clientError", (_, socket) => {
@@ -197,6 +246,25 @@ export class HttpServer {
197246
});
198247
}
199248

249+
private collectMetrics(req: IncomingMessage, res: ServerResponse, startTime: [number, number]) {
250+
if (!this.metricsRegister || !HttpServer.httpRequestDuration || !HttpServer.httpRequestTotal) {
251+
return;
252+
}
253+
254+
const [seconds, nanoseconds] = process.hrtime(startTime);
255+
const duration = seconds + nanoseconds / 1e9;
256+
257+
const route = this.findRoute(req.url ?? "") ?? "unknown";
258+
const method = req.method ?? "unknown";
259+
const status = res.statusCode.toString();
260+
261+
HttpServer.httpRequestDuration.observe(
262+
{ method, route, status, port: this.port, host: this.host },
263+
duration
264+
);
265+
HttpServer.httpRequestTotal.inc({ method, route, status, port: this.port, host: this.host });
266+
}
267+
200268
private optionalSchema<
201269
TSchema extends z.ZodFirstPartySchemaTypes | undefined,
202270
TData extends TSchema extends z.ZodFirstPartySchemaTypes ? z.TypeOf<TSchema> : TData,

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)