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
EndpointAnswersCaution
/healthIs the app up, and are its dependencies reachableDetails must be authorized - they leak hostnames
/metricsJVM, HTTP, pool and custom metersHigh cardinality tags blow up memory
/prometheusScrape format for your metrics storeExpose only on the internal port
/loggersChange a log level at runtimeA powerful write endpoint; lock it down
/infoBuild and git metadataNo 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.

Packaging and deploying a Spring Boot service Caching, async processing and scheduling

Last refreshed 2026-09-18.