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>