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:

Observation          Observation
Context            Context
Created --> Started --> Stopped

Observation scope state diagram:

Observation
Context
Scope Started --> Scope Closed

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

// 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"));

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):

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

Configure Actuator and metrics in src/main/resources/application.properties:

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:-}]

Configure Loki appender ( logback-spring.xml) to send logs to Loki.

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

Controller example:

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

Service with @Observed annotation:

@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";
    }
}

Observation handler to log before/after:

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

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

@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;
}

Client Code

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

# 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:-}]

RestTemplate bean:

@Bean
RestTemplate restTemplate(RestTemplateBuilder builder) { return builder.build(); }

CommandLineRunner that uses the Observation API to call the server:

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

Running the Example

Start the observability stack with Docker‑compose:

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

Run the server and client applications:

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

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:

$ 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

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
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

javaMetricsSpring 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

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.