Devtools (previous post) is for development. In production you need something different: signals you can alert on, correlate across services and export to whatever backend your team already runs.
Ligero's approach to observability is shaped by one rule: the framework defines vocabulary and SPIs; the client chooses the backend. This post walks through the four pillars (health, metrics, logs, traces) and the design decisions behind each.
1. Health checks
app.use(HealthMiddleware.builder()
.path("/health")
.check("db", () -> dataSource.getConnection().isValid(1))
.build());
GET /health returns 200 {"status":"UP","checks":{"db":"UP"}}, or 503 with the failing checks marked DOWN. Named checks matter operationally: the response tells the on-call engineer which dependency is down, and a 503 drives readiness in orchestrators.
2. Metrics: label by route pattern, not by path
MetricsMiddleware records method, matched route pattern, status and duration through the MetricsCollector SPI.
The key word is pattern: /users/{id}, not /users/48213. This is the single most important decision in HTTP metrics. Using raw paths as a label creates one time series per user id, which is unbounded cardinality, which is how you take down a metrics backend. The router already knows which pattern matched, so the middleware gets a bounded label for free.
// dependency-free; expose however you like
InMemoryMetricsCollector metrics = new InMemoryMetricsCollector();
app.use(new MetricsMiddleware(metrics));
app.get("/metrics", ctx -> ctx.json(metrics.snapshot()));
// or Micrometer (ligero-metrics-micrometer) for Prometheus/Datadog/...
MeterRegistry registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
app.use(new MetricsMiddleware(new MicrometerMetricsCollector(registry)));
Two adapters, one SPI: the dependency-free in-memory collector for small services and tests, Micrometer for everything else.
3. Logs: structured, and joined to traces
app.use(new RequestLoggingMiddleware()); // GET /users -> 200 (431 µs)
app.use(RequestLoggingMiddleware.json()); // {"method":"GET","path":"/users","status":200,...}
The JSON form includes the request id when RequestIdMiddleware runs first. Logs are only useful in an incident if you can pivot from a log line to the trace and back, so the next piece matters.
4. Traces: W3C context and a vendor-neutral SPI
RequestIdMiddleware handles X-Request-Id passthrough/generation and parses the W3C traceparent header. Even with no tracing backend installed, it exposes the traceId as a context attribute, so your logs are correlatable across services.
For real tracing, the core defines a Tracer SPI and a TracingMiddleware; the first adapter is ligero-otel (OpenTelemetry). Datadog, New Relic, Jaeger and friends work through the OTel SDK or agent exporters, or you can implement the SPI directly.
// With the OTel Java agent (or SDK autoconfigure), ServiceLoader finds the tracer:
app.use(TracingMiddleware.fromServiceLoader());
// Or wire an explicit SDK:
app.use(new TracingMiddleware(new OtelTracer(openTelemetrySdk)));
Per request you get a SERVER span that:
- joins the incoming W3C
traceparent, so traces continue across services; - is named by the matched route pattern (
GET /users/{id}), again bounded cardinality; - carries
http.request.method,url.path,http.routeandhttp.response.status_code; - records exceptions and marks the span
ERRORon failures; - exposes its trace id as the
traceIdcontext attribute for log correlation.
flowchart LR
A[Service A] -- traceparent --> L[Ligero service]
L -- "SERVER span per route pattern" --> O[OTel SDK / agent]
O --> V[(Backend of your choice)]
L -- JSON access log + traceId --> G[(Log pipeline)]
L -- route-pattern metrics --> P[(Metrics backend)]
Why SPIs instead of picking a vendor
Three reasons, all about who pays the cost of change:
- Teams already have a stack. Hard-wiring one vendor means every adopter either accepts it or forks.
- OpenTelemetry is the lingua franca. One adapter covers most backends, and the SPI covers the rest.
-
The core stays dependency-free.
ligero-coredepends only onslf4j-api; tracing and metrics libraries arrive only if you add the module.
A practical checklist
If you operate services like this, a few habits pay off regardless of framework:
- Bound every label. Route patterns, not raw paths; status classes if you need to reduce further.
-
Propagate context everywhere.
traceparentin,traceparentout on every outbound call. - Log the trace id. Make "log line to trace" one click.
-
Separate liveness from readiness. A
/healththat checks the database tells an orchestrator to stop sending traffic, which is not always the same as restart me. - Alert on symptoms. Latency and error rate per route pattern beat host-level resource graphs.
Takeaways
- Health, metrics, logs and traces are built into the middleware pipeline, not bolted on.
- Route-pattern labels give bounded cardinality for both metrics and span names.
- The framework owns the vocabulary (SPIs); you own the backend (OTel, Micrometer or your own).
Next: what "production-ready" means beyond observability, covering resilience, auth, scheduling, native image and an MCP server.
Top comments (0)