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.
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&&
constprobes: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.
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,
);
},
});
constapp=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:
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:
: 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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:core.Outgoing;
readonlyroute:core.RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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.
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: [() =>
constpool: {
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.