Week 12: Observability & Production Readiness

The app you're building now spans multiple services — the Task and Notification services from Weeks 9-10 — a Redis cache from Week 11, and a database underneath all of it. This week isn't about adding a new feature; it's about actually knowing what the system is doing once it's live: health checks that mean something, metrics you can graph, structured logs that don't turn into noise the moment a second service exists, and a trace ID that follows one request as it crosses from Task to Notification and back.

Module 9 of 12 Week 12 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Expose and interpret Spring Boot Actuator endpoints, securely
  • Write custom health indicators and export metrics with Micrometer
  • Understand structured logging and distributed tracing basics across services

1. Spring Boot Actuator

Every service you've built since Week 3 has been a black box from the outside — it either responds or it doesn't. Spring Boot Actuator opens that box: it's a set of production-ready endpoints, built into Spring Boot, that report health, configuration, and runtime metrics without you writing a single controller for them.

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

Add that one starter and restart, and Actuator is live at /actuator — but by default only /actuator/health is exposed over HTTP. That's a deliberate, safe default, and you'll be turning it up carefully rather than opening everything at once. The endpoints worth knowing:

key actuator endpoints
GET /actuator/health    -> overall app health (UP / DOWN), plus each health indicator
GET /actuator/info      -> static build/app metadata you choose to expose
GET /actuator/metrics   -> index of every metric name Micrometer is recording
GET /actuator/metrics/{name}  -> one metric's current value, e.g. jvm.memory.used
GET /actuator/prometheus      -> all metrics in Prometheus scrape format (Section 3)
GET /actuator/env       -> resolved configuration properties -- DANGEROUS to expose publicly
GET /actuator/loggers    -> view and change log levels at runtime, no redeploy needed

Choosing what to expose

management.endpoints.web.exposure.include controls which endpoints are reachable over HTTP at all. The safe pattern is an explicit allow-list, not */actuator/env and /actuator/heapdump can leak database passwords, API keys, and internal hostnames to anyone who can reach the port.

application.properties
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
management.endpoint.health.show-components=always
management.info.env.enabled=true
info.app.name=@project.artifactId@
info.app.version=@project.version@

Securing actuator endpoints

You already have Spring Security wired up from Weeks 6-7 — reuse it here rather than treating Actuator as a special case. The pattern is the same SecurityFilterChain approach: liveness-style checks stay open for infrastructure to poll, everything else requires authentication.

ActuatorSecurityConfig.java
package com.codeverse.week12.config;

import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    SecurityFilterChain actuatorSecurityChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health")).permitAll()
                .anyRequest().hasRole("OPS")
            )
            .httpBasic(basic -> {});
        return http.build();
    }
}
Never expose /actuator/env publicly

The same access-control instincts from Week 6/7's security module apply directly here — /actuator/env, /actuator/configprops and /actuator/heapdump can dump your Redis credentials, database URL, and JWT signing secret in plain text to anyone who can reach the port. Explicit exposure plus authentication on non-health endpoints is not optional.

2. Custom Health Indicators

Out of the box, /actuator/health aggregates checks Spring Boot can infer automatically — a DataSource being pingable, a Redis connection factory responding. But "is the database reachable" is only half of what your service actually depends on. It also depends on the Notification service from Week 9 being up. A custom HealthIndicator lets you say so explicitly.

NotificationServiceHealthIndicator.java
package com.codeverse.week12.health;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Component
public class NotificationServiceHealthIndicator implements HealthIndicator {

    private final RestClient restClient;

    public NotificationServiceHealthIndicator(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://notification-service:8081").build();
    }

    @Override
    public Health health() {
        try {
            restClient.get()
                .uri("/actuator/health")
                .retrieve()
                .toBodilessEntity();
            return Health.up()
                .withDetail("service", "notification-service")
                .build();
        } catch (RestClientException ex) {
            return Health.down()
                .withDetail("service", "notification-service")
                .withDetail("reason", ex.getMessage())
                .build();
        }
    }
}

Spring Boot registers any bean implementing HealthIndicator automatically — no wiring needed. Every indicator's name comes from stripping the HealthIndicator suffix and lowercasing the rest, so the class above shows up as a component named notificationService under /actuator/health.

Checking the cache, too

The database and Redis (Week 11) already get automatic checks from DataSourceHealthIndicator and RedisHealthIndicator, but it's worth confirming what "down" actually means for your app. A Redis outage shouldn't necessarily take the whole service down if you built the cache-aside pattern correctly — reads should fall back to the database. You can reflect that nuance with a custom indicator that reports Status.OUT_OF_SERVICE (a degraded-but-serving state) instead of DOWN:

CacheHealthIndicator.java
package com.codeverse.week12.health;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.boot.actuate.health.Status;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheHealthIndicator implements HealthIndicator {

    private final StringRedisTemplate redisTemplate;

    public CacheHealthIndicator(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public Health health() {
        try {
            redisTemplate.getConnectionFactory().getConnection().ping();
            return Health.up().withDetail("cache", "redis").build();
        } catch (Exception ex) {
            // Redis being down degrades performance (Week 11's cache-aside
            // pattern falls back to the database) but doesn't take the app down.
            return Health.status(Status.OUT_OF_SERVICE)
                .withDetail("cache", "redis")
                .withDetail("reason", ex.getMessage())
                .build();
        }
    }
}
This is what Kubernetes will poll in Week 13

When you containerize and deploy this service next week, Kubernetes needs a way to know "is this pod ready for traffic" versus "is this pod alive at all." Actuator's /actuator/health/readiness and /actuator/health/liveness groups map directly onto readiness and liveness probes — the custom indicators you write this week are exactly what those probes will end up checking.

3. Metrics with Micrometer

Health tells you up or down. Metrics tell you how — request latency, error rates, queue depth, how many tasks got completed in the last five minutes. Spring Boot Actuator doesn't collect metrics itself; it delegates to Micrometer, a vendor-neutral metrics facade that plays the same role for metrics that SLF4J plays for logging — your code talks to Micrometer's API, and Micrometer ships the numbers to whichever backend you configure (Prometheus, Datadog, CloudWatch) without your business code knowing which one.

The three core instrument types

Micrometer gives you a small set of instruments, and picking the right one matters:

Micrometer instruments
Counter -> a number that only goes up (or resets to zero)
           e.g. tasks.completed, orders.rejected

Gauge   -> a number that goes up AND down, sampled at read time
           e.g. active.websocket.connections, cache.size

Timer   -> counts AND times occurrences of an event
           e.g. how long POST /tasks took, and how many calls happened

Timing a method with @Timed

For request-level timing, the annotation-driven approach is the least invasive. Enable it once with a TimedAspect bean, then annotate any method:

MetricsConfig.java
package com.codeverse.week12.config;

import io.micrometer.core.aop.TimedAspect;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MetricsConfig {

    @Bean
    TimedAspect timedAspect(MeterRegistry registry) {
        return new TimedAspect(registry);
    }
}
TaskService.java
package com.codeverse.week12.service;

import io.micrometer.core.annotation.Timed;
import org.springframework.stereotype.Service;

@Service
public class TaskService {

    @Timed(value = "task.create.duration", description = "Time to create a task")
    public void createTask(String title) {
        // ... persist the task, publish the Kafka event from Week 10
    }
}

A custom business metric: tasks-completed counter

JVM and HTTP metrics come free, but the numbers that matter most to the business — "how many tasks did users actually complete today" — you have to record yourself. Inject the MeterRegistry and increment a counter wherever the domain event happens:

TaskCompletionService.java
package com.codeverse.week12.service;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Service;

@Service
public class TaskCompletionService {

    private final Counter tasksCompletedCounter;

    public TaskCompletionService(MeterRegistry registry) {
        this.tasksCompletedCounter = Counter.builder("tasks.completed")
            .description("Number of tasks marked complete")
            .tag("service", "task-service")
            .register(registry);
    }

    public void markComplete(Long taskId) {
        // ... update task status in the database
        tasksCompletedCounter.increment();
    }
}

Exporting to Prometheus

Add the Prometheus registry dependency and Micrometer starts formatting every metric — JVM, HTTP, and your custom tasks.completed counter — in the text format Prometheus expects, served at /actuator/prometheus (already added to exposure.include in Section 1):

pom.xml
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
terminal
curl http://localhost:8080/actuator/prometheus | grep tasks_completed

# tasks_completed_total{service="task-service",} 42.0
This is how you actually verify Week 11's cache helped

"Adding Redis should speed things up" was an assumption in Week 11 — metrics are how you confirm it. Wrap the cached lookup path in a Timer alongside a cache.hit / cache.miss counter pair, graph both, and you'll see the P95 latency for cache hits drop compared to database round-trips, with real numbers instead of a guess.

4. Structured Logging

Plain-text logs work fine for one service on one machine. They stop working the moment you have two: Task and Notification each write their own System.out-style lines, on different hosts, with no shared way to say "these five lines, across both services, belong to the same request." Grepping two separate log streams by eyeball doesn't scale past a demo.

The fix is structured logging — emitting each log line as a JSON object with consistent field names, instead of a free-form sentence. A log aggregator (ELK, Loki, Datadog) can then index, filter, and join those fields across every service instead of pattern-matching text.

JSON logging with Logback

Spring Boot uses Logback by default. Add the structured-log encoder dependency and swap the console appender's encoder for a JSON one:

pom.xml
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
logback-spring.xml
<configuration>
    <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>traceId</includeMdcKeyName>
            <includeMdcKeyName>spanId</includeMdcKeyName>
            <includeMdcKeyName>requestId</includeMdcKeyName>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="JSON" />
    </root>
</configuration>

Every log line now comes out as one JSON object per line, ready to ship straight into a log aggregator without a fragile regex parser on the other end:

example log line
{"@timestamp":"2026-08-02T14:03:11.482Z","level":"INFO",
 "logger_name":"com.codeverse.week12.service.TaskService",
 "message":"Task 482 marked complete",
 "traceId":"6b8f1c2e9a3d4f11","spanId":"a1b2c3d4e5f60718",
 "requestId":"req-9f3e2c1a"}

MDC: attaching context to every log line automatically

Manually passing a request ID into every log statement doesn't scale either. The Mapped Diagnostic Context (MDC) is a thread-local map that Logback reads automatically — set a value once per request, and every log line on that thread includes it without your code repeating itself:

RequestIdFilter.java
package com.codeverse.week12.web;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

@Component
public class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String requestId = request.getHeader("X-Request-Id");
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString();
        }
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.clear(); // never leak context onto the next request on a pooled thread
        }
    }
}
MDC.clear() is not optional

Servlet containers reuse threads across requests via a thread pool. If you forget to clear the MDC in a finally block, a request ID from one user's request can leak into the log lines of the next unrelated request handled by that same thread — a subtle bug that only shows up under load.

5. Distributed Tracing Basics

The requestId from Section 4 solves logging within one service. It doesn't solve the harder problem: a single user action can trigger a call chain like Task -> Notification (Week 9's synchronous call) or Task -> Kafka -> Notification (Week 10's event-driven path). Following that one logical operation across process boundaries, asynchronously, is exactly what distributed tracing is for.

Trace IDs and span IDs

A trace represents one end-to-end operation — say, "create a task, which triggers a notification." A span represents one unit of work within that trace — the HTTP handler in Task, the Kafka publish, the consumer in Notification. Every span shares the same trace ID, while each span gets its own unique span ID and records which span it's a child of, letting a tracing backend reconstruct the whole call tree.

pom.xml
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>

With Micrometer Tracing on the classpath, Spring Boot instruments incoming and outgoing HTTP calls automatically. When Task calls Notification via the RestClient from Week 9, the trace ID and parent span ID are propagated in outgoing request headers (the W3C traceparent header, by default) with no manual plumbing:

application.properties
management.tracing.sampling.probability=1.0
management.zipkin.tracing.endpoint=http://localhost:9411/api/v2/spans
propagated header, Task -> Notification
traceparent: 00-6b8f1c2e9a3d4f11a1b2c3d4e5f60718-a1b2c3d4e5f60718-01
             |  |------ trace id ------------|  |--span id----|

For the asynchronous path — Task publishing to Kafka in Week 10, Notification consuming it later — Micrometer Tracing's Kafka instrumentation injects the trace context into message headers on publish and restores it on consume, so the trace stays intact even though the two spans never share a thread or an HTTP connection.

Closing the loop with structured logs

This is where Section 4 and this section connect: once tracing is on the classpath, Spring Boot automatically populates the MDC with traceId and spanId for every log line — the same MDC keys already wired into the logback-spring.xml encoder above. Search your log aggregator for one trace ID, and you get every log line from Task and Notification that belongs to that single request, in causal order, even though they were written by two separate JVMs on two separate hosts.

The services from Weeks 9 and 10 are now visible end to end

Before this week, "did the Notification service actually get the event Task published?" meant manually cross-referencing two separate log files by timestamp and hoping the clocks agreed. With tracing wired in, one trace ID answers that question directly — you can see the HTTP call or the Kafka publish/consume pair as connected spans in a single trace, regardless of which integration pattern from Week 9 or 10 was used.

6. Hands-on Exercise

Hands-on

Make the Task and Notification services observable end to end

Add health, metrics, structured logs and tracing to the services from Weeks 9-11, then prove a single request is traceable across both processes.

Requirements:

  1. Add spring-boot-starter-actuator to both the Task and Notification services. Configure management.endpoints.web.exposure.include to expose only health, info, metrics and prometheus, and secure every endpoint except health behind your existing Spring Security setup so only an authenticated "ops" role can read them.
  2. Write a custom HealthIndicator in the Task service that checks both the database connection and connectivity to the Notification service, following the pattern in Section 2. Confirm /actuator/health reports DOWN when the Notification service is stopped.
  3. Add a custom Micrometer Counter named tasks.completed that increments whenever a task is marked complete, plus the Prometheus registry dependency so it's readable at /actuator/prometheus.
  4. Switch both services to JSON structured logging with Logstash's Logback encoder, and add an OncePerRequestFilter (or Micrometer Tracing, if you've wired it in) that puts a request or trace ID into the MDC on every incoming request, cleared in a finally block.
  5. Add Micrometer Tracing with the Brave bridge to both services, trigger one end-to-end request (create a task that notifies), and confirm the same trace ID appears in log lines from both the Task service and the Notification service for that one request.
Hint

You don't need a Zipkin or Prometheus server actually running to complete requirement 5 — printing structured logs to the console and grepping both services' output for the same traceId value is enough to prove propagation is working. Standing up the real collectors is optional polish, not a requirement.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why shouldn't you just set management.endpoints.web.exposure.include=* and leave every actuator endpoint open?

Several actuator endpoints expose information an attacker can use directly: /actuator/env and /actuator/configprops can reveal database credentials, API keys, and JWT secrets from your configuration; /actuator/heapdump can leak in-memory secrets and session data; /actuator/threaddump and /actuator/shutdown hand out operational control over the process. The safe pattern is an explicit allow-list of only the endpoints you actually need externally (typically health, info, metrics, and prometheus), with everything beyond a bare health check gated behind the same authentication and authorization you'd require for any other sensitive route.

Q2

What's the practical difference between a Micrometer Counter, a Gauge, and a Timer?

A Counter only ever goes up (or resets to zero on restart) and fits monotonically increasing totals like tasks.completed or orders.rejected — you never decrement it. A Gauge reports a value sampled at read time that can move in either direction, like the number of active WebSocket connections or the current size of a cache — Micrometer doesn't store it, it calls a supplier function whenever the value is scraped. A Timer combines both ideas for timed events: it records a count of occurrences and a duration distribution simultaneously, which is why it's the right instrument for something like "how long did POST /tasks take, and how many times was it called" rather than reaching for a counter and a separate gauge to approximate the same thing.

Q3

Why does structured JSON logging start to matter specifically once there's more than one service?

With one service and one developer tailing one console, a free-form text log line like "Task 482 completed" is perfectly readable. The moment you have Task and Notification running as separate processes -- possibly on separate hosts -- you need a log aggregator to merge both streams, and that aggregator can only filter, index, and correlate on fields it can parse reliably. A JSON log line with consistent keys (message, level, traceId, requestId) is trivial for a tool like ELK or Loki to index and query across services; a free-text sentence requires a fragile regex that breaks the moment someone changes the wording. Structured logging turns "grep and hope the format matches" into "query by field," which is the only approach that scales past one service.

Q4

What does a propagated trace ID and span ID let you do that separate per-service log files don't?

Per-service logs, even structured ones with a local request ID, only tell you what happened inside one process. A trace ID is generated once at the start of a logical operation and automatically propagated across process boundaries -- in HTTP headers for Task calling Notification directly, or in Kafka message headers for the event-driven path -- so every span of that one operation, no matter which service or which thread handled it, carries the same trace ID with a unique span ID per unit of work. That means you can take one trace ID, search across every service's logs simultaneously, and reconstruct the full causal chain of one user action -- "the API call arrived here, triggered a Kafka publish there, was consumed over there" -- in order, without manually cross-referencing timestamps across log files that were never designed to line up.