Search docs

Jump between documentation pages.

Browse docs

Metrics & the /metrics endpoint

Metrics are the third observability pillar alongside the structured logger and the OpenTelemetry-compatible tracer. As of 0.37.0 DaloyJS ships a dependency-free Prometheus / OpenMetrics stack: a metrics registry (counters, gauges, histograms), RED (Rate / Errors / Duration) instrumentation for every route, and an opt-in, auth-guarded /metrics scrape route that inherits the same hardened posture as app.healthcheck().

Everything is built on Web-standard primitives (plus optional process.* gauges guarded for non-Node runtimes), so it runs unchanged on Node, Bun, Deno, Cloudflare Workers, and Vercel Edge.

Quick start

Call app.metrics() before registering the routes you want measured. It installs RED instrumentation and registers the scrape route in one step.

ts
import { App } from "@daloyjs/core";

const app = new App();

// Install instrumentation + the scrape route BEFORE your routes.
app.metrics({ token: process.env.METRICS_TOKEN });

app.route({
  method: "GET",
  path: "/books",
  operationId: "listBooks",
  responses: { 200: { description: "ok" } },
  handler: () => ({ status: 200 as const, body: { items: [] } }),
});

// GET /metrics  (Authorization: Bearer <METRICS_TOKEN>)
// # TYPE daloy_http_requests_total counter
// daloy_http_requests_total{method="GET",route="/books",status="200"} 1
// # TYPE daloy_http_request_duration_seconds histogram
// daloy_http_request_duration_seconds_bucket{method="GET",route="/books",le="0.005"} 1
// ...

Because the instrumentation is installed as a group hook, it only wraps routes registered after the app.metrics() call — the same ordering rule as any app.use(...) middleware.

What gets exported

Out of the box, the scrape route exposes:

  • daloy_http_requests_total{method,route,status} — a request counter (rate; the error rate is the subset with a 4xx/5xx status).
  • daloy_http_request_duration_seconds{method,route} — a latency histogram with conventional Prometheus buckets.
  • daloy_http_requests_in_flight — a gauge of concurrently-handled requests.
  • process gauges (daloy_process_resident_memory_bytes, daloy_process_heap_used_bytes, daloy_process_uptime_seconds) collected at scrape time on Node-like runtimes.

The route label

High-cardinality labels are the classic way to melt a Prometheus server. By default the route label uses the request pathname, capped at maxRouteCardinality (100) distinct values before further paths collapse to <other>. For templated routes, supply a resolver that returns the route template:

ts
app.metrics({
  token: process.env.METRICS_TOKEN,
  // Group "/books/1", "/books/2", ... into a single series.
  route: (ctx) => new URL(ctx.request.url).pathname.replace(/\/books\/[^/]+/, "/books/:id"),
});

Custom application metrics

Pass your own MetricsRegistry to register business metrics that render alongside the built-in HTTP series.

ts
import { App, MetricsRegistry } from "@daloyjs/core";

const registry = new MetricsRegistry();
const ordersPlaced = registry.counter("orders_placed_total", "Orders placed.");
const queueDepth = registry.gauge("job_queue_depth", "Pending jobs.");
const renderTime = registry.histogram(
  "render_seconds",
  "Template render time.",
  [0.001, 0.01, 0.1, 1],
);

const app = new App();
app.metrics({ registry, token: process.env.METRICS_TOKEN });

// Later, from your handlers / workers:
ordersPlaced.inc({ channel: "web" });
queueDepth.set(undefined, 12);
renderTime.observe({ template: "invoice" }, 0.042);

Use registry.collect(fn) to refresh point-in-time gauges (queue depth, connection-pool size) only when the endpoint is actually scraped, instead of on a timer.

Manual instrumentation

Prefer to wire the pieces yourself? httpMetrics() returns a Hooks bundle you can app.use(...) without the built-in scrape route — render the registry from your own handler.

ts
import { App, MetricsRegistry, httpMetrics } from "@daloyjs/core";

const registry = new MetricsRegistry();
const app = new App();
app.use(httpMetrics({ registry, maxRouteCardinality: 50 }));

app.route({
  method: "GET",
  path: "/metrics",
  responses: { 200: { description: "ok" } },
  handler: () => ({
    status: 200 as const,
    body: registry.render(),
    headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" },
  }),
});

Security posture

A /metrics endpoint leaks internal route names, latency distributions, request volume, and process memory — so it ships with the same hardened defaults as app.healthcheck():

  • Bearer token (opts.token) compared with timingSafeEqual. Missing token is a 401 with WWW-Authenticate; wrong token is a 403.
  • Per-IP rate limit (default { limit: 60, windowMs: 60_000 }) returning 429 with Retry-After on overflow. Pass rateLimit: false to disable.
  • Refuse-to-boot: an unauthenticated scrape endpoint in production throws at registration unless you set a token or explicitly pass acknowledgeUnauthenticated: true.
  • Cardinality cap: every metric is bounded by maxSeries (default 5000); overflowing label combinations are dropped and counted in daloy_metrics_series_dropped_total, a memory-exhaustion defense.
  • Exposition-injection defense: metric and label names are validated against the Prometheus grammar at definition time, and label values escape \\, ", and newlines so a hostile value cannot forge extra samples.

In most deployments you should also scope the scrape endpoint to your monitoring network at the ingress/firewall layer in addition to the bearer token.