@nage-api/observability
v1.0.0-beta.4
Published
Logging, tracing, metrics and health for @nage-api
Readme
@nage-api/observability
Logging, tracing, metrics and health (PLAN.md §18, §21, §25 P1).
Ports with no-op defaults. The framework's own instrumentation — a span per request, a counter per response — costs nothing when nobody is collecting, which is the normal case in development and in tests. An application that wants OpenTelemetry or a hosted metrics backend binds an adapter, and no caller changes.
| Concern | Port | Default | Enabled by |
| ------- | ------------- | ------------------- | ------------------------------- |
| Logging | LoggerPort | JsonLogger (core) | always |
| Tracing | TracerPort | NoopTracer | observability.tracing.enabled |
| Metrics | MetricsPort | NoopMetrics | observability.metrics.enabled |
| Health | — | HealthRegistry | always |
Health
/health/live never touches a dependency. If a database outage made liveness
fail, the orchestrator would restart every pod and turn a degraded system into
an outage.
/health/ready does check, concurrently, each probe under its own timeout —
because a dependency that hangs must be reported down in bounded time rather
than holding the probe open until the orchestrator gives up.
It also answers 503 draining the moment a shutdown signal arrives, before any
probe runs: dependency state was never the only reason to stop taking traffic, and
a draining instance is about to close the connection it is being offered. The flag
comes from NAGE_LIFECYCLE, published by NageCoreModule and flipped by
installShutdown; /health/live ignores it deliberately, because failing liveness
during a drain gets the process killed before it can finish.
import { Module } from '@nestjs/common';
import { NageObservabilityModule, healthCheck } from '@nage-api/observability';
// Whatever the application already holds: a Sequelize instance, an ioredis
// client. A probe is any function that resolves when the dependency answers.
declare const repository: { ping(): Promise<unknown> };
declare const redis: { ping(): Promise<unknown> };
@Module({
imports: [
NageObservabilityModule.forRoot({
checks: [
healthCheck('database', () => repository.ping()),
healthCheck('cache', () => redis.ping(), { critical: false }),
],
}),
],
})
export class AppModule {}critical: false degrades the report instead of failing it. Pulling a node out
of the load balancer because Redis blinked makes the outage worse. Readiness
answers 503 only when something critical is down.
Failure detail is withheld by default: a probe endpoint is unauthenticated,
and a connection string in an error message is a disclosure.
exposeHealthDetail: true opts in.
Metrics
An in-process registry with Prometheus text exposition, served from /metrics
when metrics are enabled. Counters, gauges and histograms; nothing else.
The thing it takes seriously is cardinality. A label whose value comes from
a request path or a user id creates a new time series per value, and a metrics
endpoint that grows without bound eventually takes the scraper down with it. So
the HTTP interceptor labels by route template (/orders/:id, never
/orders/1), and the registry caps series per metric name and exposes
nage_metrics_dropped_total rather than growing silently or dropping silently.
Built-in series: nage_http_requests_total{method,route,status},
nage_http_request_duration_ms{method,route}, nage_http_requests_in_flight.
In-flight is decremented on the error path too, so a failing endpoint does not
show a permanent backlog.
Tracing
NAGE_TRACER is what application code injects; it resolves to whatever the
deployment bound, or to NoopTracer, and the call site does not change.
import { Inject, Injectable } from '@nestjs/common';
import { NAGE_TRACER, type TracerPort } from '@nage-api/observability';
interface Order {
readonly id: string;
readonly total: number;
}
declare const gateway: { charge(order: Order): Promise<{ reference: string }> };
@Injectable()
export class PaymentService {
constructor(@Inject(NAGE_TRACER) private readonly tracer: TracerPort) {}
async charge(order: Order): Promise<{ reference: string }> {
return this.tracer.trace(
'charge-card',
async (span) => {
span.setAttribute('order.id', order.id);
return gateway.charge(order);
},
{ kind: 'client' },
);
}
}The span ends in a finally, so a thrown error still closes it — an unclosed
span reads as an operation that never returned. RecordingTracer is the same
interface backed by memory, so a test can assert on spans without a collector.
The span per request comes from the same HTTP interceptor that records the metrics, so that interceptor is registered as soon as either metrics or tracing is enabled. A deployment that turns tracing on and leaves metrics off still gets its request spans.
Two details: the correlation id from the request context is attached automatically, which is what ties a span to the log lines and the response envelope for the same request; and string attributes go through the same redaction that guards log lines, because attributes reach a collector the team may not control.
Not yet implemented
- The OpenTelemetry adapter itself.
TracerPortis the seam; the exporter, sampler and propagation headers are not written. - pino as a
LoggerPortimplementation (§8 names it);JsonLoggerin@nage-api/corecovers structured output today. - Sentry.
ObservabilityConfig.sentryis declared and unread. - Metric-name collision detection: registering the same name as two different types produces two series rather than an error.
- Binding your own
metricssink whilemetrics.enabledis true still mounts/metricsfrom the built-in registry, which then renders empty: the framework's series go to the bound sink, and only that sink has them.
