DEV Community

Duvan Jamid Vargas
Duvan Jamid Vargas

Posted on

Observability in a Lightweight Java Framework: Health, Metrics, Traces and OpenTelemetry (Building Ligero, Part 6)

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());
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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,...}
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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.route and http.response.status_code;
  • records exceptions and marks the span ERROR on failures;
  • exposes its trace id as the traceId context 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:

  1. Teams already have a stack. Hard-wiring one vendor means every adopter either accepts it or forks.
  2. OpenTelemetry is the lingua franca. One adapter covers most backends, and the SPI covers the rest.
  3. The core stays dependency-free. ligero-core depends only on slf4j-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. traceparent in, traceparent out on every outbound call.
  • Log the trace id. Make "log line to trace" one click.
  • Separate liveness from readiness. A /health that 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)