Skip to content

Metrics

This guide counts and times every request for Prometheus with prom-client, and serves the registry on a port the API’s clients cannot reach. There is no metrics package: accessLog() from @tetsujs/request-log already produces a record for every request, and which registry and buckets suit a service is the application’s choice.

accessLog() takes a write function, and the record it is handed can go to a logger, a registry, or both:

import { createApp } from "@tetsujs/core";
import { accessLog } from "@tetsujs/request-log";
import { collectDefaultMetrics, Histogram } from "prom-client";
collectDefaultMetrics();
const
const duration: Histogram<"status" | "method" | "route">
duration
= new Histogram({
name: string
name
: "http_request_duration_seconds",
help: string
help
: "How long a request took, from arrival to response",
labelNames?: ("status" | "method" | "route")[] | readonly ("status" | "method" | "route")[] | undefined
labelNames
: ["method", "route", "status"],
});
const
const probes: Set<string>
probes
= new Set(["/livez", "/readyz"]);
const measured = accessLog({
write?: ((record: AccessRecord) => void) | undefined

Where the line goes. console.log by default.

Takes the record rather than a string so a structured logger can be handed the fields as they are.

write
: (
record: AccessRecord
record
) => {
if (
record: AccessRecord
record
.
route?: string | undefined

The route that answered, as it was declared — /users/:id where path is /users/42.

Absent when nothing matched, which is the honest answer for a 404 and the one that keeps it countable on its own rather than folded in with the endpoints that exist. Both fields are here because they answer different questions: path is what the client asked for, and on a 404 it is the only interesting thing in the line; route is what the application did, and it is the one a dashboard can group by without growing a series per identifier.

route
!== undefined &&
const probes: Set<string>
probes
.has(
record: AccessRecord
record
.
route?: string

The route that answered, as it was declared — /users/:id where path is /users/42.

Absent when nothing matched, which is the honest answer for a 404 and the one that keeps it countable on its own rather than folded in with the endpoints that exist. Both fields are here because they answer different questions: path is what the client asked for, and on a 404 it is the only interesting thing in the line; route is what the application did, and it is the one a dashboard can group by without growing a series per identifier.

route
)) return;
const duration: Histogram<"status" | "method" | "route">
duration
.observe(
{
method?: string | number | undefined
method
:
record: AccessRecord
record
.
method: string
method
,
route?: string | number | undefined
route
:
record: AccessRecord
record
.
route?: string | undefined

The route that answered, as it was declared — /users/:id where path is /users/42.

Absent when nothing matched, which is the honest answer for a 404 and the one that keeps it countable on its own rather than folded in with the endpoints that exist. Both fields are here because they answer different questions: path is what the client asked for, and on a 404 it is the only interesting thing in the line; route is what the application did, and it is the one a dashboard can group by without growing a series per identifier.

route
?? "unmatched",
status?: string | number | undefined
status
:
record: AccessRecord
record
.
status: number
status
},
record: AccessRecord
record
.
durationMs: number

How long the request took inside the pipeline, in milliseconds, rounded to the microsecond.

From ctx.startedAt, which the core reads before any hook, to afterResponse, which starts as the response goes to Bun: writing it to the socket is not in here, and neither is anything that happened before the pipeline started.

durationMs
/ 1000,
);
},
});
const app = createApp({
hooks: { afterResponse: [measured] },
routes: usersController(),
});

The histogram’s _count series is the number of requests, so no separate counter is needed: the request rate is rate(http_request_duration_seconds_count[5m]), and the error rate is the same filtered by status.

The health probes are skipped: they arrive every few seconds from every balancer and would outweigh real traffic. To write the access log too, call the logger in the same write.

record.route is the route as declared, /users/:id. record.path is what the client asked for, /users/42, and as a label it creates a new series for every id and every path a scanner tries. Prometheus pays for every series, so a label built from the path grows without bound.

A request no route answered has no route: a 404, a 405, or a CORS preflight that cors() answered. The recipe counts them all under unmatched, so a spike stays visible without a series per path.

Your own hooks and handlers can read the same value as ctx.route.path. See Context fields.

Every bucket is a series for every combination of labels. prom-client’s defaults run from 5 ms to 10 s in eleven steps. Pick buckets around the latencies you want to tell apart, and few enough to keep the count down:

const
const duration: Histogram<"method" | "route" | "status">
duration
= new Histogram({
name: string
name
: "http_request_duration_seconds",
help: string
help
: "How long a request took, from arrival to response",
labelNames?: ("method" | "route" | "status")[] | readonly ("method" | "route" | "status")[] | undefined
labelNames
: ["method", "route", "status"],
buckets?: number[] | undefined
buckets
: [0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5],
});

Thirty routes with four statuses each and seven buckets is already close to a thousand series per instance. status is fine as a label, since a route answers with few statuses; think twice before adding a label that comes from the request.

durationMs runs from ctx.startedAt, read by the core before any hook, to the moment the response is handed to Bun. It covers hooks, validation, the handler and serialization. It does not cover writing the response to the socket, TLS, or time spent in a proxy; the balancer’s metrics cover those. For a streamed response, it ends when the stream is handed over, not at the last event.

A request whose client left early is recorded with aborted: true and the status the server answered. To count those as nginx does, label with record.aborted ? 499 : record.status.

Serve the registry from an application of its own, on a port the balancer does not route to. Prometheus can read it, the API’s clients cannot, and it stays out of the API’s OpenAPI document, access log and rate limits:

import { controller, createApp, route } from "@tetsujs/core";
import { onShutdownSignals } from "@tetsujs/lifecycle";
import { register } from "prom-client";
const metricsController = controller("Metrics", () => ({
metrics: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/metrics"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/metrics",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: core.Outgoing;
readonly route: core.RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<Response>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: async () =>
new Response(await register.metrics(), {
headers?: HeadersInit | undefined
headers
: { "content-type": register.
contentType: "text/plain; version=0.0.4; charset=utf-8"

Gets the Content-Type of the metrics for use in the response headers.

contentType
},
}),
}),
}));
const
const api: Bun.Server<unknown>
api
= Bun.serve({
...createApp({ hooks: { afterResponse: [measured] }, routes }),
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 3000,
});
const
const internal: Bun.Server<unknown>
internal
= Bun.serve({
...createApp({ routes: metricsController() }),
port?: string | number | undefined

The port the server listens on

@default ― process.env.PORT || "3000"

port
: 9464,
});
onShutdownSignals([
const api: Bun.Server<unknown>
api
,
const internal: Bun.Server<unknown>
internal
], {
close?: readonly Closer[] | undefined

Resources to release, in order, after the server has stopped.

After, never before: a request still in flight may reach for the pool that closing it early would have taken away.

close
: [() =>
const pool: {
end(): Promise<void>;
}
pool
.end()] });

The metrics application has no accessLog(), so a scrape is neither logged nor measured. One onShutdownSignals stops both servers.

collectDefaultMetrics() adds the process’s memory, CPU, event loop lag and garbage collection. The series are named nodejs_* even under Bun, because prom-client reads them through the Node APIs Bun implements.

Where a second port is not an option, mount the same route on the API with docs: { hidden: true } to keep it out of the OpenAPI document, and add /metrics to the probes set the write function skips. Mount a rate limit on the groups it protects rather than on the application, so the scraper is not counted against it. Hiding the route from the document does not restrict access: anyone who can reach the port can read the metrics. Block the path at the proxy, or guard the route with a hook that checks a token Prometheus sends.

Count a business event — an order placed, a payment refused — where it happens, with a Counter from the same registry. It shows up on the same /metrics page. The same rule applies to its labels: a small, closed set of values, never an id.