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.
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
ObservationRegistryand an
ObservationHandler. The handler only acts on supported
Observation.Contextimplementations 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
ObservationHandlersuch as
onStartand
onStop. The
Observation.Contextcan 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
ObservationConventionto override default names.
2. Building an Observable Application
Start a project from
https://start.spring.iowith 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
@Observedaspect.
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><configuration>
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<springProperty scope="context" name="appName" source="spring.application.name"/>
<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>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
@Observedto 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
ObservationHandlerthat 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-weband
spring-boot-starter-actuatordependencies. 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_nametimer and
my_observationtimer appear in Prometheus.
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
@Observedannotation 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.
Java Architecture Diary
Committed to sharing original, high‑quality technical articles; no fluff or promotional content.
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.