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.
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 --> StoppedObservation scope state diagram:
Observation
Context
Scope Started --> Scope ClosedTags (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.releaseConfigure 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 :clientObserve 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 imageNote: 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
