Observability with Actuator, logging and metrics
Exposing the right endpoints, custom health indicators, Micrometer metrics, and structured logs you can actually search.
Actuator endpoints
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
base-path: /internal
endpoint:
health:
probes:
enabled: true # /health/liveness and /health/readiness
show-details: when-authorized
metrics:
tags:
application: ${spring.application.name}
server:
port: 8081 # keep management off the public port| Endpoint | Answers | Caution |
|---|---|---|
/health | Is the app up, and are its dependencies reachable | Details must be authorized - they leak hostnames |
/metrics | JVM, HTTP, pool and custom meters | High cardinality tags blow up memory |
/prometheus | Scrape format for your metrics store | Expose only on the internal port |
/loggers | Change a log level at runtime | A powerful write endpoint; lock it down |
/info | Build and git metadata | No secrets - it is usually public |
⚠️
Exposing
* on the public port publishes heap dumps and environment properties. Use include with an explicit list, and put management on a separate port that the load balancer does not route to the internet.Custom health and metrics
@Component
class PricingFeedHealth implements HealthIndicator {
private final PricingClient client;
PricingFeedHealth(PricingClient client) { this.client = client; }
@Override public Health health() {
try {
var age = client.lastUpdateAge();
if (age.compareTo(Duration.ofMinutes(5)) > 0) {
return Health.outOfService().withDetail("ageSeconds", age.toSeconds()).build();
}
return Health.up().withDetail("ageSeconds", age.toSeconds()).build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}@Service
class OrderService {
private final Counter placed;
private final Timer lookup;
OrderService(MeterRegistry registry) {
this.placed = Counter.builder("orders.placed")
.description("Orders accepted").register(registry);
this.lookup = registry.timer("orders.lookup");
}
Order place(CreateOrder cmd) {
var result = lookup.record(() -> repo.save(toEntity(cmd)));
placed.increment();
return result;
}
}Tag meters with low-cardinality dimensions such as status code or endpoint template. Never tag with a user id, an order id or a raw URL - each distinct value creates a new time series.
Logging that survives production
logging:
level:
root: INFO
com.example.orders: DEBUG
org.hibernate.SQL: WARN
pattern:
console: '{"ts":"%d{yyyy-MM-dd''T''HH:mm:ss.SSSXXX}","level":"%p","logger":"%c{1.}","msg":"%m"}%n'- Log at the boundary with a correlation id from the inbound header, and propagate it to outbound calls.
- Never log credentials, tokens, full request bodies or personal data - logs are the least protected copy of your data.
- Use parameterised logging (
log.debug("id={}", id)) so arguments are not formatted when the level is disabled. - Prefer a JSON encoder in production so a log platform can index fields instead of regex-scraping text.
FAQ
Why is my readiness probe failing at startup?
Readiness stays DOWN until the application context is fully refreshed, but the readiness group can report a failing external dependency as well. Distinguish a slow start from a broken dependency - do not mark the database down as unready if a degraded cache miss is acceptable.
Counters or gauges?
A counter only increases and suits events such as requests or errors. A gauge reports a current value such as queue depth or cache size. Do not compute rates yourself - let the metrics backend derive them from counters, which survive restarts correctly.
Related
Packaging and deploying a Spring Boot service Caching, async processing and scheduling
Last refreshed 2026-09-18.