Skip to main content

Java Observability

This guide covers implementing all four observability pillars in Java applications with Spring Boot.

Logging

Spring Boot uses Logback by default. Add the Logstash encoder for JSON output.

Dependencies

<!-- pom.xml -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency>

Configuration

<!-- src/main/resources/logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>true</includeContext>
<includeMdc>true</includeMdc>
<includeStructuredArguments>true</includeStructuredArguments>
<includeTags>true</includeTags>
</encoder>
</appender>

<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>

<!-- Reduce noise from frameworks -->
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>
</configuration>

Usage

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static net.logstash.logback.argument.StructuredArguments.*;

@Service
public class OrderService {
private static final Logger logger = LoggerFactory.getLogger(OrderService.class);

public void processOrder(Order order) {
logger.info("Processing order",
kv("orderId", order.getId()),
kv("customerId", order.getCustomerId()),
kv("amount", order.getAmount()));

try {
processPayment(order);
logger.info("Order completed", kv("orderId", order.getId()));
} catch (Exception e) {
logger.error("Order processing failed",
kv("orderId", order.getId()),
kv("error", e.getMessage()), e);
throw e;
}
}
}

MDC for Request Context

import org.slf4j.MDC;
import jakarta.servlet.*;
import java.util.UUID;

@Component
public class RequestContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;

String requestId = httpRequest.getHeader("X-Request-Id");
if (requestId == null) {
requestId = UUID.randomUUID().toString();
}

MDC.put("requestId", requestId);
MDC.put("path", httpRequest.getRequestURI());

try {
chain.doFilter(request, response);
} finally {
MDC.clear();
}
}
}

Metrics

Spring Boot Actuator with Micrometer provides Prometheus metrics.

Dependencies

<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

Configuration

# application.yml
management:
endpoints:
web:
exposure:
include: health,prometheus,info
endpoint:
health:
show-details: when_authorized
prometheus:
metrics:
export:
enabled: true

Custom Metrics

import io.micrometer.core.instrument.*;

@Service
public class OrderService {
private final Counter ordersTotal;
private final Timer orderProcessingTimer;
private final AtomicInteger activeOrders;

public OrderService(MeterRegistry registry) {
this.ordersTotal = Counter.builder("orders_total")
.description("Total orders processed")
.tag("service", "order-service")
.register(registry);

this.orderProcessingTimer = Timer.builder("order_processing_duration")
.description("Order processing duration")
.publishPercentiles(0.5, 0.9, 0.99)
.register(registry);

this.activeOrders = registry.gauge("active_orders",
new AtomicInteger(0));
}

public void processOrder(Order order) {
activeOrders.incrementAndGet();
try {
orderProcessingTimer.record(() -> {
doProcessOrder(order);
});
ordersTotal.increment();
} finally {
activeOrders.decrementAndGet();
}
}
}

Annotations

import io.micrometer.core.annotation.Timed;
import io.micrometer.core.annotation.Counted;

@Service
public class PaymentService {

@Timed(value = "payment.process", percentiles = {0.5, 0.95, 0.99})
@Counted(value = "payments.total")
public PaymentResult processPayment(Payment payment) {
// Implementation
}
}

Enable annotation support:

@Configuration
public class MetricsConfig {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}

Tracing

Use OpenTelemetry or Micrometer Tracing (successor to Spring Cloud Sleuth).

OpenTelemetry

<!-- pom.xml -->
<dependency>
<groupId>io.opentelemetry.instrumentation</groupId>
<artifactId>opentelemetry-spring-boot-starter</artifactId>
</dependency>
# application.yml
otel:
exporter:
otlp:
endpoint: http://otel-collector:4317
service:
name: my-service

Micrometer Tracing

<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
# application.yml
management:
tracing:
sampling:
probability: 1.0 # Sample all requests
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces

Manual Instrumentation

import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;

@Service
public class OrderService {
private final Tracer tracer;

public OrderService(Tracer tracer) {
this.tracer = tracer;
}

public void processOrder(Order order) {
Span span = tracer.spanBuilder("processOrder")
.setAttribute("order.id", order.getId())
.setAttribute("customer.id", order.getCustomerId())
.startSpan();

try (var scope = span.makeCurrent()) {
validateOrder(order); // Child spans auto-linked
processPayment(order);
span.setStatus(StatusCode.OK);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e);
throw e;
} finally {
span.end();
}
}
}

With Micrometer Tracing

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;

@Service
public class OrderService {
private final ObservationRegistry observationRegistry;

public void processOrder(Order order) {
Observation.createNotStarted("order.process", observationRegistry)
.lowCardinalityKeyValue("order.type", order.getType())
.observe(() -> doProcessOrder(order));
}
}

Health Checks

Spring Boot Actuator provides health check infrastructure.

Dependencies

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Configuration

# application.yml
management:
endpoints:
web:
exposure:
include: health,prometheus
endpoint:
health:
show-details: always
probes:
enabled: true # Enables /health/liveness and /health/readiness
health:
livenessstate:
enabled: true
readinessstate:
enabled: true

This exposes:

  • /actuator/health - Comprehensive health
  • /actuator/health/liveness - Liveness probe
  • /actuator/health/readiness - Readiness probe

Custom Health Indicator

import org.springframework.boot.actuate.health.*;

@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
private final WebClient webClient;

@Override
public Health health() {
try {
var response = webClient.get()
.uri("/health")
.retrieve()
.toBodilessEntity()
.block(Duration.ofSeconds(5));

if (response.getStatusCode().is2xxSuccessful()) {
return Health.up()
.withDetail("service", "external-api")
.build();
}
return Health.down()
.withDetail("status", response.getStatusCode())
.build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}

Readiness Health Group

# application.yml
management:
endpoint:
health:
group:
readiness:
include: db,redis,externalService
liveness:
include: ping

Kubernetes Configuration

apiVersion: v1
kind: Pod
spec:
containers:
- name: app
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10

readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5

Complete Example

// Application.java
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}
# application.yml
spring:
application:
name: order-service

management:
endpoints:
web:
exposure:
include: health,prometheus,info
endpoint:
health:
show-details: always
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
tracing:
sampling:
probability: 1.0
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces

logging:
level:
root: INFO
org.springframework: WARN
<!-- logback-spring.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>