Operations 26 min read

Unlock Full Observability in Spring Boot 3 with Micrometer Observation API

This article explains how Spring Boot 3.0.0‑RC1 integrates Micrometer Observation API to provide unified metrics, logging, and distributed tracing, showing the observation lifecycle, configuration steps, sample server and client code, Docker‑compose setup, and notes on native image support for comprehensive application observability.

macrozheng
macrozheng
macrozheng
Unlock Full Observability in 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 system behavior and debug performance issues.

Micrometer Observation API

Spring Boot 3.0.0‑RC1 ships with auto‑configuration that uses Micrometer to improve metrics and provides a new Observation API for unified observability.

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

How Micrometer Observation Works

Register an

ObservationHandler

with an

ObservationRegistry

. The handler reacts to lifecycle events (

start

,

stop

,

error

,

event

,

openScope

,

closeScope

) and can create timers, spans, and logs.

Observation#start()

– starts the observation.

Observation#stop()

– stops the observation.

Observation#error(exception)

– records an error.

Observation#event(event)

– records a custom event.

Observation#openScope()

– opens a scope for thread‑local data.

Observation.Scope#close()

– closes the scope.

Observation state diagram:

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

Observation scope state diagram:

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

Tags (low‑cardinality or high‑cardinality key‑value pairs) are attached to observations for later querying.

Building an Observable Application

Start a project from

https://start.spring.io

with Spring Boot 3.0.0‑SNAPSHOT (or RC1). Add the following dependencies:

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

– HTTP server.

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

– enables

@Observed

aspect.

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

– exposes metrics.

For metrics, add

io.micrometer:micrometer-registry-prometheus

. For tracing, add a tracer bridge such as

io.micrometer:micrometer-tracing-bridge-brave

(or OpenTelemetry bridge) and the corresponding exporter (e.g.,

io.zipkin.reporter2:zipkin-reporter-brave

or

io.opentelemetry:opentelemetry-exporter-zipkin

). For logs, use

com.github.loki4j:loki-logback-appender

to push logs to Loki.

Server Code

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

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

Important: high‑cardinality tags can have many distinct values (e.g., URLs), while low‑cardinality tags have a limited set.

Use

ObservationConvention

to separate naming conventions from observation configuration.

WebMvc Service

Add the following dependencies in

pom.xml

(or Gradle):

<code>org.springframework.boot:spring-boot-starter-web
org.springframework.boot:spring-boot-starter-aop
org.springframework.boot:spring-boot-starter-actuator
io.micrometer:micrometer-registry-prometheus
io.micrometer:micrometer-tracing-bridge-brave
io.zipkin.reporter2:zipkin-reporter-brave
com.github.loki4j:loki-logback-appender:latest.release
</code>

Configure Actuator and metrics in

src/main/resources/application.properties

:

<code>server.port=7654
spring.application.name=server
management.tracing.sampling.probability=1.0
management.endpoints.web.exposure.include=prometheus
management.metrics.distribution.percentiles-histogram.http.server.requests=true
logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]
</code>

Configure Loki appender (

logback-spring.xml

) to send logs to Loki.

<code>&lt;configuration&gt;
  &lt;include resource="org/springframework/boot/logging/logback/base.xml"/&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>

Controller example:

<code>@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 with

@Observed

annotation:

<code>@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>

Observation handler to log before/after:

<code>@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 observation for context [{}], userType [{}]", ctx.getName(), getUserTypeFromContext(ctx)); }
    @Override public void onStop(Observation.Context ctx) { log.info("After running 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>

Register a web filter to create observations for incoming HTTP requests:

<code>@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>

Client Code

Add the same web and actuator starters, plus a tracer bridge (e.g., OpenTelemetry) and Loki appender.

<code># src/main/resources/application.properties
server.port=6543
spring.application.name=client
management.tracing.sampling.probability=1.0
management.endpoints.web.exposure.include=prometheus
logging.pattern.level=%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]
</code>

RestTemplate bean:

<code>@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) { return builder.build(); }
</code>

CommandLineRunner that uses the Observation API to call the server:

<code>@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);
            });
    };
}
</code>

Running the Example

Start the observability stack with Docker‑compose:

<code>$ docker-compose up
# Prometheus: http://localhost:9090/
# Grafana:   http://localhost:3000/
</code>

Run the server and client applications:

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

Observe logs, traces, and metrics in Grafana. Metrics such as

user_name

timer and

my_observation

timer appear in the Prometheus view; traces can be queried by trace ID in Tempo.

Native Image Support

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

<code>$ sdk install java 22.2.r17-nik
$ ./mvnw -Pnative clean package
$ ./server/target/server   # run server native image
$ ./client/target/client   # run client native image
</code>

Note: Logging to Loki is not yet supported in native mode, and additional reflection configuration may be required.

Conclusion

The article demonstrated the core concepts of Micrometer Observation API, how to use the API and

@Observed

annotation to add metrics, tracing, and logging to Spring Boot applications, and how to visualize the collected data with Prometheus, Grafana, Loki, and Tempo.

Grafana dashboard
Grafana dashboard
JavaObservabilitymetricsSpring BoottracingMicrometer
macrozheng
Written by

macrozheng

Dedicated to Java tech sharing and dissecting top open-source projects. Topics include Spring Boot, Spring Cloud, Docker, Kubernetes and more. Author’s GitHub project “mall” has 50K+ stars.

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.