Backend Development 26 min read

How to Add Full Observability to Spring Boot 3 with Micrometer Observation API

This article explains how Spring Boot 3.0.0‑RC1 integrates Micrometer Observation to provide unified metrics, logging, and distributed tracing, showing the required dependencies, configuration, code examples for both server and client, and how to visualize data with Prometheus, Grafana, Loki, and Tempo.

Java Architecture Diary
Java Architecture Diary
Java Architecture Diary
How to Add Full Observability to Spring Boot 3 with Micrometer Observation API

Introduction

The Spring Observability team adds built‑in support for metrics, logging, and distributed tracing in Spring Framework 6 and Spring Boot 3, making it easier to understand application behavior and debug latency or performance issues.

Spring Boot 3.0.0‑RC1 ships with extensive auto‑configuration that uses Micrometer to improve metrics and provides Micrometer Tracing (formerly Spring Cloud Sleuth) for distributed tracing. Notable changes include built‑in log correlation, W3C context propagation as the default, and automatic metadata propagation for tracing infrastructure.

The goal is a single API that can capture code execution and emit metrics, tracing, and logging information.

1. How Micrometer Observation Works

Observation requires registering an

ObservationRegistry

and an

ObservationHandler

. The handler only acts on supported

Observation.Context

implementations and can react to lifecycle events to create timers, spans, and logs.

start

– calls

Observation#start()

to begin the observation.

stop

– calls

Observation#stop()

to end it.

error

– calls

Observation#error(exception)

to record an error.

event

– calls

Observation#event(event)

for custom events.

scope started

– calls

Observation#openScope()

to open a scope.

scope stopped

– calls

Observation.Scope#close()

to close the scope.

Each of these triggers corresponding methods on the

ObservationHandler

such as

onStart

and

onStop

. The

Observation.Context

can be used to pass state between handlers.

<code>Observation           Observation
Context               Context
Created  --> Started --> Stopped</code>
<code>Observation
Context
Scope Started --> Scope Closed</code>

For debugging, observations can carry additional metadata (tags) that are either low‑cardinality (few possible values) or high‑cardinality (many possible values). Low‑cardinality tags are suitable for metrics, while high‑cardinality tags are used for spans.

Micrometer Observation API Example

<code>// Create ObservationRegistry
ObservationRegistry registry = ObservationRegistry.create();
// Register ObservationHandler
registry.observationConfig().observationHandler(new MyHandler());

// Create and run an Observation
Observation.createNotStarted("user.name", registry)
    .contextualName("getting-user-name")
    .lowCardinalityKeyValue("userType", "userType1")
    .highCardinalityKeyValue("userId", "1234")
    .observe(() -> log.info("Hello"));
</code>

A table in the original source explained high‑ vs low‑cardinality tags; this information is retained in the text above.

To separate naming conventions from configuration, you can implement

ObservationConvention

to override default names.

2. Building an Observable Application

Start a project from

https://start.spring.io

with Spring Boot 3.0.0‑SNAPSHOT (or RC1) and your preferred build tool. The example creates a WebMvc server and a RestTemplate client.

2.1 WebMvc Service Setup

Add the following dependencies:

org.springframework.boot:spring-boot-starter-web

– HTTP server.

org.springframework.boot:spring-boot-starter-aop

– enables the

@Observed

aspect.

org.springframework.boot:spring-boot-starter-actuator

– adds Micrometer to the classpath.

Metrics can be exported to Prometheus by adding

io.micrometer:micrometer-registry-prometheus

. For tracing, choose a tracer bridge such as Zipkin Brave (

io.micrometer:micrometer-tracing-bridge-brave

) or OpenTelemetry (

io.micrometer:micrometer-tracing-bridge-otel

) and the corresponding exporter (e.g.,

io.opentelemetry:opentelemetry-exporter-zipkin

). Logs are sent to Loki using

com.github.loki4j:loki-logback-appender

.

Configure Actuator and metrics in

src/main/resources/application.properties

:

<code># /src/main/resources/application.properties

server.port=7654
spring.application.name=server

# Send all traces to latency analysis tool
management.tracing.sampling.probability=1.0
management.endpoints.web.exposure.include=prometheus

# Enable histogram buckets for exemplars
management.metrics.distribution.percentiles-histogram.http.server.requests=true

# Include traceId and spanId in log pattern
logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]
</code>

Logback configuration (keep only allowed tags):

<code>&lt;configuration&gt;
  &lt;include resource="org/springframework/boot/logging/logback/base.xml"/&gt;
  &lt;springProperty scope="context" name="appName" source="spring.application.name"/&gt;
  &lt;appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender"&gt;
    &lt;http&gt;
      &lt;url&gt;http://localhost:3100/loki/api/v1/push&lt;/url&gt;
    &lt;/http&gt;
    &lt;format&gt;
      &lt;label&gt;
        &lt;pattern&gt;app=${appName},host=${HOSTNAME},traceID=%X{traceId:-NONE},level=%level&lt;/pattern&gt;
      &lt;/label&gt;
      &lt;message&gt;
        &lt;pattern&gt;${FILE_LOG_PATTERN}&lt;/pattern&gt;
      &lt;/message&gt;
      &lt;sortByTime&gt;true&lt;/sortByTime&gt;
    &lt;/format&gt;
  &lt;/appender&gt;
  &lt;root level="INFO"&gt;
    &lt;appender-ref ref="LOKI"/&gt;
  &lt;/root&gt;
&lt;/configuration&gt;
</code>

2.2 Server Code

Controller that logs a request and delegates to a service:

<code>// MyController.java
@RestController
class MyController {
    private static final Logger log = LoggerFactory.getLogger(MyController.class);
    private final MyUserService myUserService;
    MyController(MyUserService myUserService) { this.myUserService = myUserService; }
    @GetMapping("/user/{userId}")
    String userName(@PathVariable("userId") String userId) {
        log.info("Got a request");
        return myUserService.userName(userId);
    }
}
</code>

Service method annotated with

@Observed

to generate a timer, long‑task timer, and span:

<code>// MyUserService.java
@Service
class MyUserService {
    private static final Logger log = LoggerFactory.getLogger(MyUserService.class);
    private final Random random = new Random();

    @Observed(name = "user.name",
              contextualName = "getting-user-name",
              lowCardinalityKeyValues = {"userType", "userType2"})
    String userName(String userId) {
        log.info("Getting user name for user with id <{}>", userId);
        try { Thread.sleep(random.nextLong(200L)); } catch (InterruptedException e) { throw new RuntimeException(e); }
        return "foo";
    }
}
</code>

Custom

ObservationHandler

that logs before and after the observation:

<code>// MyHandler.java
@Component
class MyHandler implements ObservationHandler<Observation.Context> {
    private static final Logger log = LoggerFactory.getLogger(MyHandler.class);
    @Override public void onStart(Observation.Context ctx) { log.info("Before running the observation for context [{}], userType [{}]", ctx.getName(), getUserTypeFromContext(ctx)); }
    @Override public void onStop(Observation.Context ctx) { log.info("After running the observation for context [{}], userType [{}]", ctx.getName(), getUserTypeFromContext(ctx)); }
    @Override public boolean supportsContext(Observation.Context ctx) { return true; }
    private String getUserTypeFromContext(Observation.Context ctx) {
        return StreamSupport.stream(ctx.getLowCardinalityKeyValues().spliterator(), false)
            .filter(kv -> "userType".equals(kv.getKey()))
            .map(KeyValue::getValue)
            .findFirst()
            .orElse("UNKNOWN");
    }
}
</code>

Bean that registers the observation filter for the

/user/*

path:

<code>// MyConfiguration.java
@Configuration(proxyBeanMethods = false)
class MyConfiguration {
    @Bean
    ObservationAspect observedAspect(ObservationRegistry registry) { return new ObservationAspect(registry); }

    @Bean
    FilterRegistrationBean observationWebFilter(ObservationRegistry registry) {
        FilterRegistrationBean bean = new FilterRegistrationBean(new HttpRequestsObservationFilter(registry));
        bean.setDispatcherTypes(DispatcherType.ASYNC, DispatcherType.ERROR, DispatcherType.FORWARD, DispatcherType.INCLUDE, DispatcherType.REQUEST);
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        bean.setUrlPatterns(Collections.singletonList("/user/*"));
        return bean;
    }
}
</code>

2.3 RestTemplate Client

Add

spring-boot-starter-web

and

spring-boot-starter-actuator

dependencies. Use the same Micrometer registry for tracing and metrics.

RestTemplate bean (instrumented via the builder):

<code>// MyConfiguration.java
@Configuration(proxyBeanMethods = false)
class MyConfiguration {
    @Bean
    RestTemplate restTemplate(RestTemplateBuilder builder) { return builder.build(); }
}
</code>

CommandLineRunner that creates a manual observation, adds low‑ and high‑cardinality tags, and calls the server:

<code>// MyConfiguration.java
@Configuration(proxyBeanMethods = false)
class MyConfiguration {
    @Bean
    CommandLineRunner myCommandLineRunner(ObservationRegistry registry, RestTemplate restTemplate) {
        Random highCardinalityValues = new Random();
        List<String> lowCardinalityValues = Arrays.asList("userType1", "userType2", "userType3");
        return args -> {
            String highCardinalityUserId = String.valueOf(highCardinalityValues.nextLong(100_000));
            Observation.createNotStarted("my.observation", registry)
                .lowCardinalityKeyValue("userType", randomUserTypePicker(lowCardinalityValues))
                .highCardinalityKeyValue("userId", highCardinalityUserId)
                .contextualName("command-line-runner")
                .observe(() -> {
                    log.info("Will send a request to the server");
                    String response = restTemplate.getForObject("http://localhost:7654/user/{userId}", String.class, highCardinalityUserId);
                    log.info("Got response [{}]", response);
                });
        };
    }
    private String randomUserTypePicker(List<String> types) { return types.get(new Random().nextInt(types.size())); }
}
</code>

2.4 Running the Example

Start the observability stack with Docker Compose:

<code>$ docker-compose up</code>

Then run the server and client applications:

<code>$ ./mvnw spring-boot:run -pl :server
$ ./mvnw spring-boot:run -pl :client</code>

Open Grafana (http://localhost:3000) and view the Logs, Traces, and Metrics dashboards. Select a trace ID (e.g.,

bbe3aea006077640b66d40f3e62f04b9

) to see correlated logs and spans across both services. Metrics such as

user_name

timer and

my_observation

timer appear in Prometheus.

Grafana dashboard
Grafana dashboard
Trace view
Trace view

2.5 Native Image Support

To build a native executable, install GraalVM (e.g., via SDKMAN) and run:

<code>sdk install java 22.2.r17-nik
./mvnw -Pnative clean package</code>

Run the native binaries:

<code>$ ./server/target/server
$ ./client/target/client</code>

Note: Logging to Loki is not yet supported in native mode; see issue 25847 for details.

Conclusion

The blog demonstrates the core concepts of Micrometer Observation, how to use the API and

@Observed

annotation to add metrics, tracing, and log correlation to a Spring Boot application, and how to visualize the data with Prometheus, Grafana, Loki, and Tempo. It also shows how to build and run the application as a native image.

Next Steps

Future work includes community‑driven improvements, GA release planned for November, and further documentation on Micrometer Context Propagation, Micrometer Observation, Micrometer Tracing, and the Micrometer Docs Generator.

JavaObservabilitymetricsSpring BoottracingMicrometer
Java Architecture Diary
Written by

Java Architecture Diary

Committed to sharing original, high‑quality technical articles; no fluff or promotional content.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.