zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Operations and deployment

ObservabilitySupported

Supported. A configured Meter receives the HTTP request-duration and database operation-duration histograms. There is still no Prometheus client, exporter, backend, @Metric decorator or built-in /metrics endpoint.

The metric names and units, and which attributes come from compile time rather than runtime, are frozen in packages/web/src/observability/SPEC.md against semantic conventions v1.30.0. Its #647 ownership amendment assigns the generic ports and database instrumentation to @zmdb/app/observability; HTTP spans remain web-owned. The registry below remains a dependency-free alternative; values exported through the framework Meter use the conventional names and seconds units documented below.

@zmdb/app/observability and the HTTP instrumentation have no OpenTelemetry dependency. To adapt application-owned OpenTelemetry objects, install @zmdb/app@1.0.0-beta.2 @opentelemetry/api@^1.9.1 and import @zmdb/app/otel. Neither the adapter nor its peer is part of the @zmdb/core default install. The adapter owns no provider, processor, exporter, collector client, global registration, or shutdown hook.

The four things worth measuring#

Instrument these and you can diagnose most incidents. Anything beyond them is usually noise.

MetricWhy
Request rate, duration, status by routethe whole user-visible picture
Query duration by statement shapewhere the time nearly always is
Pool utilisation and wait timethe usual ceiling under load
Error rate by typewhat broke, distinct from what is slow

A metrics registry without a dependency#

export class Metrics {
  readonly #counters = new Map<string, number>();
  readonly #histograms = new Map<string, number[]>();

  increment(name: string, labels: Record<string, string> = {}): void {
    const key = seriesKey(name, labels);
    this.#counters.set(key, (this.#counters.get(key) ?? 0) + 1);
  }

  observe(name: string, ms: number, labels: Record<string, string> = {}): void {
    const key = seriesKey(name, labels);
    const list = this.#histograms.get(key) ?? [];
    list.push(ms);
    this.#histograms.set(key, list);
  }

  render(): string {
    const lines: string[] = [];
    for (const [key, value] of this.#counters) lines.push(`${key} ${value}`);
    for (const [key, values] of this.#histograms) {
      const sorted = [...values].sort((a, b) => a - b);
      lines.push(`${key}_count ${sorted.length}`);
      lines.push(`${key}_p50 ${quantile(sorted, 0.5)}`);
      lines.push(`${key}_p95 ${quantile(sorted, 0.95)}`);
      lines.push(`${key}_p99 ${quantile(sorted, 0.99)}`);
    }
    return lines.join('\n') + '\n';
  }
}
function seriesKey(name: string, labels: Record<string, string>): string {
  const pairs = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b));
  return pairs.length === 0 ? name : `${name}{${pairs.map(([k, v]) => `${k}="${v}"`).join(',')}}`;
}

Sorting the labels is what makes the series key stable — otherwise the same metric with keys in a different order becomes two series.

Unbounded histogram arrays grow forever. Reset on scrape, or keep a reservoir sample; a naive version is a slow memory leak that only shows after days of uptime.

Label cardinality is the trap#

metrics.increment('http_requests', { path: ctx.path }); // wrong
metrics.increment('http_requests', { route: '/posts/:id' }); // right

ctx.path is /posts/1, /posts/2, … — one time series per id. That is how a metrics backend falls over, and how a bill arrives. Label by the route pattern, which you can get from getRoutes, and never by a user id, email, tenant or request id.

Do not put personal data in a label either. Metrics are retained long-term and are usually less access-controlled than logs.

Getting the route pattern is harder than the two lines above suggest, and for a structural reason: Ctx carries path, the concrete one, and only the matched route knows the pattern. Reconstructing it from getRoutes outside the router means re-running the match. The router therefore owns the server span and request histogram — it is the one place http.route exists without being derived twice.

Wiring in the framework metrics#

createRouter and createApp accept the same Observability object. The separately installed OpenTelemetry adapter takes application-owned API objects:

import { metrics, trace } from '@opentelemetry/api';
import { tracedDriver } from '@zmdb/app/observability';
import { fromOpenTelemetry } from '@zmdb/app/otel';
import { createApp } from '@zmdb/web';

const observability = fromOpenTelemetry({
  tracer: trace.getTracer('checkout'),
  meter: metrics.getMeter('checkout'),
});

await using app = createApp(AppModule, { observability });

@opentelemetry/api is the sole required peer of the separately installed @zmdb/app/otel package. Neither the app kernel nor the HTTP core declares it or chooses an exporter or metrics backend.

Queries use tracedDriver. Passing ctx.span is what parents a query span to the handler; metrics work without a tracer:

const driver = tracedDriver(baseDriver, observability, ctx.span);
const users = defineRepository(UserSchema, driver);

The wrapper marks the driver as needing query telemetry. Repositories then ask the compiler to attach an optional { system, operation, collection } object. Without that opt-in a compiled query contains text, parameters and required execution effects, with no telemetry field.

Do not derive the verb by parsing SQL. A first-word regex reads WITH for a CTE that ends in an INSERT, and a leading comment changes the first token. Optional compile-time telemetry exists so the driver does not have to guess.

Measured framework overhead#

The committed run measured all three configurations on 2026-09-05 with Node 26.8.1 on an AMD Ryzen 7 7840U. Each row is the median of six samples after a 750 ms warmup per workload and mode; all six mode orders were used. The recording case used a real BasicTracerProvider, SimpleSpanProcessor and bounded SpanExporter, with exporter flush/reset outside the timed interval and metrics disabled.

workloadconfigurationmedian ns/opoverhead vs offexported spans/opmax/min spread
requestoff354.67baseline01.052x
requestAPI no-op1305.48+268.1%01.080x
requestrecording exporter6834.88+1827.1%31.022x
queryoff76.97baseline01.115x
queryAPI no-op311.29+304.4%01.118x
queryrecording exporter2591.42+3266.7%11.090x

The request workload is one matched GET; the query workload is one compiled SELECT through tracedDriver. These are nanosecond-scale framework microbenchmarks, not end-to-end service latency. The raw 36 samples, runtime provenance, input hashes and median operations per second are published in benchmarks/site/observability.json and summarised on Web Performance & Benchmarks.

Exposing a hand-rolled registry#

@Controller('/metrics')
export class MetricsController {
  @Inject(METRICS) private readonly metrics!: Metrics;

  @Get()
  scrape() {
    return { text: this.metrics.render() };
  }
}

Prometheus wants text/plain in its exposition format, which a handler can now return directly:

@Get('/metrics')
metrics() {
  return text(renderExposition());
}

Keep /metrics off your public listener, or require an auth header — it names every route and leaks traffic shape.

This endpoint is application code. zmdb does not install a registry, renderer or scrape route.

⚠️ Warning

/metrics must not be publicly reachable. It reveals route inventory, traffic volumes, error rates and often internal identifiers — a reconnaissance gift. Bind it to a separate internal port (see Multiple Servers), or restrict it at the proxy.

Structured logs may be enough#

If you already emit one structured line per request, most backends derive rate, latency and error metrics from logs. That gives you the four measurements above with no metrics infrastructure — worth doing before adding a second telemetry system. See Logging.

The names change if you export them#

The registry on this page is yours, so http_duration_ms is whatever you say it is. The moment the numbers leave through a Meter, the names and units are a convention:

this pageconventionunit
http_duration_mshttp.server.request.durationseconds
db_query_msdb.client.operation.durationseconds
http_errorsthe HTTP histogram's optional error.type labelseconds
db_errors— application- or backend-owned

Seconds, not milliseconds, and this is the one that bites without an error: the convention's histograms have bucket boundaries chosen for seconds, so millisecond observations exported under a seconds-named metric all land in the top bucket and every percentile reads as "slower than the largest bucket". A dashboard built on it looks plausible and is meaningless.

The HTTP error rate is derivable from error.type on the request-duration histogram, so a separate HTTP error counter would be a second source for one number. The database-duration histogram has no error label; derive database failures from spans or an application-owned counter. Two framework histograms, not four: pool statistics belong to a driver the framework does not own.

Framework boundaries#

The framework supplies a Meter port and the low-cardinality route information only the router knows. The separately installed @zmdb/app/otel package adapts caller-owned OpenTelemetry objects.

Exactly two histograms are emitted, and only when a meter exists: http.server.request.duration and db.client.operation.duration. HTTP error rate is derivable from the first histogram's optional error.type; database error and pool metrics remain the application or driver's responsibility.

The per-request observation point for _interceptors_ remains deliberately absent: runChain is still not wired into the router, so there is no interceptor span or interceptor-owned framework metric.

---

See also: Tracing · Logging · Health Checks