Observability: Health, Logging, Metrics, Tracing
|
This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against. Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change. This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Observability in Quarkus rests on three extensions that can be added independently but are designed to work
together: quarkus-smallrye-health (is the application up, and is it ready to take traffic), quarkus-micrometer
(what is it doing, in numbers), and quarkus-opentelemetry (what happened to this one request, end to end,
across every service it touched). Logging sits underneath all three — every one of them can correlate its own
output with the current trace via the same mechanism. This page covers all four together; REST-specific and
virtual-thread-specific monitoring concerns are only summarised here and covered in depth on
REST Services and Virtual
Threads respectively.
Health checks with SmallRye Health
./mvnw quarkus:add-extension -Dextensions=smallrye-health
Adding the extension alone exposes three endpoints without any code: /q/health/live (liveness), /q/health/ready
(readiness), and /q/health (the union of both). Many extensions register their own readiness check
automatically the moment they are on the classpath — quarkus-agroal (the datasource pool) and
quarkus-messaging-kafka (broker connectivity) are the most common examples — so a project that only adds a
datasource and smallrye-health already gets a meaningful /q/health/ready for free.
Custom checks
package com.example.health;
import org.eclipse.microprofile.health.HealthCheck;
import org.eclipse.microprofile.health.HealthCheckResponse;
import org.eclipse.microprofile.health.Liveness;
import org.eclipse.microprofile.health.Readiness;
import org.eclipse.microprofile.health.Startup;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
class OrdersLivenessCheck implements HealthCheck {
@Override
@Liveness
public HealthCheckResponse call() {
return HealthCheckResponse.up("orders-service-alive");
}
}
@ApplicationScoped
class DownstreamReadinessCheck implements HealthCheck {
@Inject
DownstreamPingClient pingClient;
@Override
@Readiness
public HealthCheckResponse call() {
boolean reachable = pingClient.ping();
return HealthCheckResponse.named("downstream-api")
.status(reachable)
.withData("checkedAt", System.currentTimeMillis())
.build();
}
}
@ApplicationScoped
class CacheWarmupCheck implements HealthCheck {
@Inject
CacheWarmupState state;
@Override
@Startup
public HealthCheckResponse call() {
return HealthCheckResponse.status("cache-warmup", state.isWarm());
}
}
@Liveness answers "should this instance be restarted" — a failing liveness check should mean the process is
in a state it cannot recover from on its own (deadlocked, unrecoverable internal error), not merely "temporarily
overloaded"; a flapping liveness probe causes an orchestrator to restart-loop an instance that would otherwise
have recovered. @Readiness answers "should traffic be routed to this instance right now" — it can legitimately
flip between up and down repeatedly (a downstream dependency blips) without the process being restarted.
@Startup gates readiness until an initial startup sequence (cache warm-up, initial data load) completes, and is
checked once before the instance is considered ready for the first time.
The /q/health* endpoints and Kubernetes probe wiring
| Endpoint | Aggregates |
|---|---|
|
All |
|
All |
|
All |
|
The union of all three groups. |
Each responds 200 with a JSON body listing every check’s individual status when all checks in the group pass,
and 503 the moment any one of them reports DOWN. The quarkus-kubernetes extension reads these same paths
to generate the Deployment manifest’s own liveness/readiness probes automatically:
quarkus.kubernetes.liveness-probe.path=/q/health/live
quarkus.kubernetes.readiness-probe.path=/q/health/ready
quarkus.kubernetes.startup-probe.path=/q/health/started
which is also the reason the liveness/readiness distinction above matters operationally, not just semantically — a check registered under the wrong annotation changes what Kubernetes actually does when it fails (restart the pod vs. pull it out of the Service’s endpoint list).
The info endpoint
quarkus-info exposes /q/info, a build/runtime metadata endpoint distinct from health — it never reports a
failure, it just describes the running instance: build timestamp, Git commit (when quarkus.info.git.enabled=true
and the module was built inside a Git working copy), OS and Java runtime details, and any extension-contributed
section (a datasource’s JDBC URL host, without credentials). It is useful for confirming exactly which build and
commit is running in a given environment without cross-referencing a separate deployment log:
./mvnw quarkus:add-extension -Dextensions=info
quarkus.info.git.enabled=true
quarkus.info.build.enabled=true
quarkus.info.os.enabled=true
Logging
Categories and levels
Quarkus logging is configured per category (usually a package or class name), with quarkus.log.level
setting the root:
quarkus.log.level=INFO
quarkus.log.category."com.example.orders".level=DEBUG
quarkus.log.category."org.hibernate.SQL".level=DEBUG
quarkus.log.min-level=TRACE
quarkus.log.min-level caps the lowest level any category can ever be raised to (at build time, categories
below it are compiled out entirely for performance in native builds), so a category-level override above it has
no effect unless the floor is lowered too. Per-category levels set via quarkus.log.category.* are
runtime-reloadable in dev mode and, in a packaged application, mutable without a restart via the same
configuration mechanism (an updated environment variable or a mounted config file picked up on the next config
refresh), which is the practical way to raise a specific category’s verbosity in production for a short window
without redeploying.
Handlers: console, JSON, file, syslog
./mvnw quarkus:add-extension -Dextensions=logging-json
# console (default handler, human-readable by default)
quarkus.log.console.format=%d{HH:mm:ss} %-5p [%c{2.}] (%t) %s%e%n
quarkus.log.console.json=true
# file
quarkus.log.file.enable=true
quarkus.log.file.path=/var/log/orders-service/app.log
quarkus.log.file.rotation.max-file-size=10M
quarkus.log.file.rotation.max-backup-index=5
# syslog
quarkus.log.syslog.enable=true
quarkus.log.syslog.endpoint=syslog-collector:514
quarkus.log.syslog.protocol=tcp
quarkus-logging-json switches any of these handlers to structured JSON output (one JSON object per log line,
including MDC and, when quarkus-opentelemetry is present, the current trace/span ID) instead of the
quarkus.log.console.format pattern — structured output is what makes centralised log aggregation practical, since
the collector can then index individual fields instead of regex-parsing free text.
Centralised log management
Quarkus does not ship a Graylog/Logstash/Fluentd client of its own for the general case — the standard pattern
is: emit JSON (quarkus.log.console.json=true), let the container runtime capture stdout, and let a sidecar or
node-level agent (Filebeat, Fluent Bit, the Fluentd daemonset) ship it to the aggregator, which is what most
Kubernetes logging stacks already do regardless of the application inside the pod. quarkus-logging-gelf is the
one extension that talks a specific backend’s wire protocol directly — the Graylog Extended Log Format — useful
when there is no log-shipping sidecar available:
./mvnw quarkus:add-extension -Dextensions=logging-gelf
quarkus.log.handler.gelf.enabled=true
quarkus.log.handler.gelf.host=udp:graylog.example.com
quarkus.log.handler.gelf.port=12201
OpenTelemetry logging
With quarkus-opentelemetry present, every log record’s MDC automatically carries traceId/spanId for the
request it was emitted during, so a JSON log line and a distributed trace can be correlated in whichever backend
receives both (see OpenTelemetry). Quarkus can additionally export log records themselves as OTLP log
records, alongside traces and metrics, rather than (or in addition to) writing them to a file/console handler:
quarkus.otel.logs.enabled=true
quarkus.otel.exporter.otlp.logs.endpoint=http://otel-collector:4317
Metrics with Micrometer
./mvnw quarkus:add-extension -Dextensions=micrometer-registry-prometheus
Adding a registry extension (micrometer-registry-prometheus here) pulls in quarkus-micrometer itself and wires
that registry as the target for every meter Quarkus and its extensions record. Multiple registries can be active
at once (a Prometheus registry for scraping plus a micrometer-registry-otlp push registry, for example); every
meter is recorded once and fanned out to whichever registries are present.
MeterRegistry, @Counted, and @Timed
package com.example.orders;
import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@ApplicationScoped
@Path("/orders")
public class OrderResource {
@Inject
MeterRegistry registry;
@POST
@Counted(value = "orders.created", description = "Number of orders created")
@Timed(value = "orders.create.duration", description = "Time to create an order")
public void create() {
registry.counter("orders.created.manual", "channel", "rest").increment();
}
}
@Counted/@Timed (io.micrometer.core.annotation) instrument a method declaratively without touching its body;
injecting MeterRegistry directly is the escape hatch for a metric whose tags depend on runtime data (here, a
channel tag) that an annotation’s static value cannot express.
Built-in binders
Once quarkus-micrometer is present, several meter families are registered automatically with no code:
HTTP server request metrics (http.server.requests, tagged by method/status/URI template), JVM metrics (heap and
non-heap memory, garbage-collection pause time, live thread count), and datasource metrics for Agroal-managed
pools (agroal.active.count, agroal.available.count, agroal.acquire.count). Each binder can be disabled
individually if it is not wanted:
quarkus.micrometer.binder.http-server.enabled=true
quarkus.micrometer.binder.jvm=true
quarkus.micrometer.binder.datasource.enabled=true
The Prometheus registry and /q/metrics
micrometer-registry-prometheus exposes every registered meter, in Prometheus’s own text exposition format, at
/q/metrics — see the Prometheus reference for how a Prometheus server
scrapes that endpoint, the resulting metric types, and building alerting/dashboards on top of it. Distinguishing
this from the OpenTelemetry metrics pipeline below matters: the Prometheus registry is pull-based (Prometheus
scrapes /q/metrics on its own schedule) while OTLP metrics export is push-based (Quarkus sends metrics to a
collector on its own interval) — a project can run either or, via the bridge described next, both from the same
set of meters.
OpenTelemetry
./mvnw quarkus:add-extension -Dextensions=opentelemetry
quarkus.application.name=orders-service
quarkus.otel.exporter.otlp.endpoint=http://otel-collector:4317
quarkus.otel.traces.sampler=parentbased_traceidratio
quarkus.otel.traces.sampler.arg=0.25
Automatic instrumentation and context propagation
quarkus-opentelemetry instruments the extensions most likely to matter for a distributed trace without any
code change: Quarkus REST server and client calls, JDBC statements (through the Agroal datasource), Kafka
producers/consumers, and gRPC calls each create their own span automatically, and the trace context (the
current trace/span ID) propagates across each of those boundaries — an inbound HTTP request’s trace ID is the
same one attached to the JDBC query and the outbound REST client call it triggers, and the same one Kafka carries
in message headers to a downstream consumer. Propagation across reactive/async hops (a Uni continuation, an
event-loop-to-worker-thread handoff, a @RunOnVirtualThread method) is handled by the same context-propagation
machinery Quarkus already uses for CDI request-scoped beans, so a span started before an asynchronous boundary
still shows up as the parent of spans created after it.
Sampling
quarkus.otel.traces.sampler controls what fraction of traces are actually recorded and exported — parentbased_always_on (the default; export everything, but never override a sampling decision already made by
an upstream caller), parentbased_traceidratio with quarkus.otel.traces.sampler.arg as the ratio (shown above),
or always_off to disable trace export entirely while keeping metrics/logs. Consistent sampling across a
distributed system relies on every service using a parentbased_* sampler, so a downstream service does not
independently decide to drop a trace its caller chose to keep.
Manual spans: @WithSpan and the Tracer API
package com.example.orders;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanBuilder;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.instrumentation.annotations.SpanAttribute;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PricingEngine {
@Inject
Tracer tracer;
@WithSpan("pricing.calculate")
public double calculate(@SpanAttribute("order.id") String orderId, double baseAmount) {
return applyDiscounts(orderId, baseAmount);
}
private double applyDiscounts(String orderId, double baseAmount) {
SpanBuilder builder = tracer.spanBuilder("pricing.apply-discounts");
Span span = builder.startSpan();
try {
span.setAttribute("order.id", orderId);
return baseAmount * 0.9;
} finally {
span.end();
}
}
}
@WithSpan (io.opentelemetry.instrumentation.annotations) is the declarative shortcut for a method whose whole
body should be one span, with @SpanAttribute binding parameters onto it; injecting the Tracer directly is
needed for anything an annotation cannot express — a span that only covers part of a method, or one whose
attributes depend on values not known until partway through execution.
The Micrometer → OpenTelemetry bridge
quarkus-micrometer-opentelemetry re-exports every meter already recorded through MeterRegistry (including the
built-in HTTP/JVM/datasource binders and any @Counted/@Timed metric) as OpenTelemetry metrics, so a project
that has already instrumented its code with Micrometer does not need to duplicate that work with the OTel Metrics
API just to get metrics into an OTLP-based backend:
./mvnw quarkus:add-extension -Dextensions=micrometer-opentelemetry
With the bridge active, Micrometer remains the instrumentation API used in application code; OpenTelemetry becomes purely an additional export path for the same data, alongside (or instead of) the Prometheus registry.
Observability Dev Services and the Grafana OTel-LGTM stack
In dev mode and for @QuarkusTest, adding quarkus-opentelemetry (or quarkus-micrometer-opentelemetry) without
configuring quarkus.otel.exporter.otlp.endpoint triggers Dev Services to start a containerised Grafana
OTel-LGTM stack automatically — an all-in-one image bundling an OTLP collector, Loki (logs), Grafana
(the UI), Tempo (traces), and Mimir/Prometheus (metrics) — and points the application’s exporters at it,
with the Grafana UI’s URL printed to the console/Dev UI. This gives full local trace/metric/log exploration with
zero backend setup during development; it is a development-time-only convenience and plays no part in a
production deployment, which should point quarkus.otel.exporter.otlp.endpoint at a real collector as shown
earlier.
Java Flight Recorder
Independently of any Quarkus extension, the JDK’s own Java Flight Recorder can profile a running Quarkus
application — JVM-level events (GC pauses, allocation, thread contention, and, for virtual threads, pinning via
jdk.VirtualThreadPinned, see Virtual Threads) with very low
overhead, viewable afterwards in JDK Mission Control:
java -XX:StartFlightRecording=filename=recording.jfr,duration=60s -jar target/quarkus-app/quarkus-run.jar
JFR is complementary to, not a replacement for, the application-level signals above: it answers "what was the JVM itself doing" during an incident, where Micrometer/OpenTelemetry answer "what was the application doing" at the request/span level.
Signal flow and further reading
See the Prometheus reference for scraping, PromQL, and alerting on the
metrics this page’s /q/metrics endpoint exposes, and
the Spring Boot equivalent for how the same three
signals (health, metrics, tracing) are wired up in a Spring Boot application via Actuator and Micrometer Tracing,
which shares the same Micrometer API this page describes.