/** * RED metrics for nifra as an opt-in `.use(metrics())` plugin, exposed in Prometheus text format at * `/metrics`. Dependency-free and in-process: an atomic counter/gauge/histogram registry with a * Prometheus renderer, plus automatic per-request rate/errors/duration series labeled by the matched * route TEMPLATE (not the raw path, so `/users/:id` is one series, not one per id). * * import { metrics } from "@nifrajs/otel/metrics" * const app = server().use(metrics()).get("/users/:id", handler) * // GET /metrics → nifra_http_requests_total{method="GET",route="/users/:id",status="200"} 5 … * * `c` is not touched; apps that want custom app metrics create their own registry and pass it in * (`metrics({ registry })`), then register series on it - they render at the same `/metrics`. */ import { type Method, Router } from "@nifrajs/core/router" import { defineRouterPlugin, type IdentityPlugin, type RouteDescriptor } from "@nifrajs/core/server" const pathnameOf = (url: string): string => { try { return new URL(url).pathname } catch { return url } } type Labels = Readonly> const NAME_RE = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/ function assertName(name: string): void { if (!NAME_RE.test(name)) throw new Error(`invalid metric name: ${JSON.stringify(name)}`) } /** Stable key for a label set (sorted), so `{a,b}` and `{b,a}` map to the same series. */ function labelKey(labels: Labels): string { const keys = Object.keys(labels).sort() return keys.map((k) => `${k}${labels[k]}`).join("") } function escapeLabelValue(value: string): string { return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"') } function renderLabels(labels: Labels, extra?: Readonly>): string { const merged = extra === undefined ? labels : { ...labels, ...extra } const keys = Object.keys(merged).sort() if (keys.length === 0) return "" return `{${keys.map((k) => `${k}="${escapeLabelValue(merged[k] as string)}"`).join(",")}}` } interface CounterSeries { readonly labels: Labels value: number } /** A monotonically increasing counter (requests, errors). */ export class Counter { private readonly series = new Map() constructor( readonly name: string, readonly help: string, ) { assertName(name) } inc(labels: Labels = {}, by = 1): void { const key = labelKey(labels) const existing = this.series.get(key) if (existing === undefined) this.series.set(key, { labels, value: by }) else existing.value += by } render(): string { const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} counter`] for (const s of this.series.values()) lines.push(`${this.name}${renderLabels(s.labels)} ${s.value}`) return lines.join("\n") } } /** A value that can go up and down (in-flight requests, queue depth). */ export class Gauge { private readonly series = new Map() constructor( readonly name: string, readonly help: string, ) { assertName(name) } private at(labels: Labels): CounterSeries { const key = labelKey(labels) let s = this.series.get(key) if (s === undefined) { s = { labels, value: 0 } this.series.set(key, s) } return s } inc(labels: Labels = {}, by = 1): void { this.at(labels).value += by } dec(labels: Labels = {}, by = 1): void { this.at(labels).value -= by } set(labels: Labels, value: number): void { this.at(labels).value = value } render(): string { const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} gauge`] for (const s of this.series.values()) lines.push(`${this.name}${renderLabels(s.labels)} ${s.value}`) return lines.join("\n") } } interface HistogramSeries { readonly labels: Labels readonly counts: number[] // cumulative count ≤ each bucket sum: number count: number } /** Latency-style distribution over fixed buckets (seconds). Renders Prometheus cumulative buckets. */ export class Histogram { private readonly series = new Map() private readonly buckets: number[] constructor( readonly name: string, readonly help: string, buckets: readonly number[], ) { assertName(name) this.buckets = [...buckets].sort((a, b) => a - b) } observe(value: number, labels: Labels = {}): void { const key = labelKey(labels) let s = this.series.get(key) if (s === undefined) { s = { labels, counts: new Array(this.buckets.length).fill(0), sum: 0, count: 0 } this.series.set(key, s) } for (let i = 0; i < this.buckets.length; i++) { if (value <= (this.buckets[i] as number)) s.counts[i] = (s.counts[i] as number) + 1 } s.sum += value s.count += 1 } render(): string { const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} histogram`] for (const s of this.series.values()) { for (let i = 0; i < this.buckets.length; i++) { lines.push( `${this.name}_bucket${renderLabels(s.labels, { le: String(this.buckets[i]) })} ${s.counts[i]}`, ) } lines.push(`${this.name}_bucket${renderLabels(s.labels, { le: "+Inf" })} ${s.count}`) lines.push(`${this.name}_sum${renderLabels(s.labels)} ${s.sum}`) lines.push(`${this.name}_count${renderLabels(s.labels)} ${s.count}`) } return lines.join("\n") } } /** A collection of metrics that renders one Prometheus exposition document. */ export class MetricsRegistry { private readonly counters = new Map() private readonly gauges = new Map() private readonly histograms = new Map() counter(name: string, help = name): Counter { let m = this.counters.get(name) if (m === undefined) { m = new Counter(name, help) this.counters.set(name, m) } return m } gauge(name: string, help = name): Gauge { let m = this.gauges.get(name) if (m === undefined) { m = new Gauge(name, help) this.gauges.set(name, m) } return m } histogram(name: string, buckets: readonly number[], help = name): Histogram { let m = this.histograms.get(name) if (m === undefined) { m = new Histogram(name, help, buckets) this.histograms.set(name, m) } return m } /** The Prometheus text exposition of every registered series. */ render(): string { const blocks: string[] = [] for (const m of this.counters.values()) blocks.push(m.render()) for (const m of this.gauges.values()) blocks.push(m.render()) for (const m of this.histograms.values()) blocks.push(m.render()) return `${blocks.join("\n")}\n` } } /** Create a standalone registry to register custom app metrics on, shared into `metrics({ registry })`. */ export function createMetricsRegistry(): MetricsRegistry { return new MetricsRegistry() } /** OpenTelemetry-style default latency buckets (seconds). */ const DEFAULT_BUCKETS: readonly number[] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] export interface MetricsOptions { /** Where to expose the Prometheus text. Default `/metrics`. This route is excluded from the metrics. */ readonly path?: string /** Reuse a registry (so custom app metrics render alongside the RED series). Default: a fresh one. */ readonly registry?: MetricsRegistry /** Histogram buckets in SECONDS for request duration. Default OpenTelemetry latency buckets. */ readonly buckets?: readonly number[] } interface RequestMark { readonly start: number readonly method: string } /** * Enable RED metrics + a `/metrics` Prometheus endpoint. Records `nifra_http_requests_total`, * `nifra_http_request_duration_seconds`, and `nifra_http_requests_in_flight`, labeled by method, * matched route template, and status. Apply once (named-plugin dedupe). */ export function metrics(options: MetricsOptions = {}): IdentityPlugin { const path = options.path ?? "/metrics" const registry = options.registry ?? new MetricsRegistry() const buckets = options.buckets ?? DEFAULT_BUCKETS const requests = registry.counter( "nifra_http_requests_total", "Total HTTP requests by method, route, and status.", ) const duration = registry.histogram( "nifra_http_request_duration_seconds", buckets, "HTTP request duration in seconds by method, route, and status.", ) const inFlight = registry.gauge( "nifra_http_requests_in_flight", "HTTP requests currently being served, by method.", ) const marks = new WeakMap() // The route matcher is built lazily on first request: by serving time every route is registered, // so `app.routes()` is complete (building at plugin-apply time would miss later routes). let matcher: Router | undefined const routeOf = ( app: { routes(): ReadonlyArray }, method: string, p: string, ) => { if (matcher === undefined) { matcher = new Router() for (const route of app.routes()) matcher.add(route.method as Method, route.path, route.path) } const found = matcher.find(method, p) return found.found ? found.payload : "unmatched" } // Routes and hooks are mounted as side effects so the plugin stays a type identity: the caller's // server type (and every route declared after `.use(metrics())`) survives. return defineRouterPlugin("metrics", (app) => { app.onRequest((req) => { if (pathnameOf(req.url) === path) return undefined // don't measure the scrape endpoint marks.set(req, { start: performance.now(), method: req.method }) inFlight.inc({ method: req.method }) return undefined }) app.get( path, () => new Response(registry.render(), { headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" }, }), ) app.use({ name: "metrics-record", onResponseFinalized: (outcome, req) => { const mark = marks.get(req) if (mark === undefined) return marks.delete(req) inFlight.dec({ method: mark.method }) const route = routeOf(app, mark.method, pathnameOf(req.url)) const status = String(outcome.response.status) const labels = { method: mark.method, route, status } requests.inc(labels) duration.observe((performance.now() - mark.start) / 1000, labels) }, }) return app }) }