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
ObservationHandlerwith 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.iowith 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
@Observedaspect.
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-braveor
io.opentelemetry:opentelemetry-exporter-zipkin). For logs, use
com.github.loki4j:loki-logback-appenderto 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
ObservationConventionto 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><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>
</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
@Observedannotation:
<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_nametimer and
my_observationtimer 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
@Observedannotation to add metrics, tracing, and logging to Spring Boot applications, and how to visualize the collected data with Prometheus, Grafana, Loki, and Tempo.
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.