New micrometer observation API with Spring Boot 3.0
Last time we talked about spring boot 3 features, it was about GraalVM Native Image Support. Today we’re going to show you another very cool feature present in the spring boot 3.0, support for the new observation APIs introduced in Micrometer 1.10. To show you how you can use it in your applications, we’re going to add this to our application from the previous blog post.
What is the observation API in Micrometer?
Micrometer Observation API is a new type of API in micrometer that allows us to hide low level APIs such as metrics, logging, and tracing. Instead, we have a new concept called Observation
. As you can see previously, you wanted to measure, log or trace something and now you just want to observe that something happened in our system, and based on that, you may add metrics, logs, or traces. And because it is just a facade for low level API you can still expose your metrics and traces to your monitoring or tracing systems such as Datadog, Graphite, Prometheus, or Zipkin. You can find the full list of supported systems in the spring boot metrics documentation and in the spring boot tracing documentation.
The main sentence from the docs explains it very well: Instrument code once, and get multiple benefits out of it. Multiple benefits mean that you can add many features on top of the added observation. And additionally, what is more important for me is that with this API, we shift our focus to what we want to observe, not how. We don’t need to think about low-level abstractions like Timer
, Counter
, or Logger
to measure something, we just need to tell what we want to observe. Let me show you an example.
How to use this API
I’m going to decorate our UserService
with an example observation, and I’ll explain what they’ve meant by multiple benefits.
Here is an excerpt of a UserService
class that we’re going to decorate with observation API
public class UserApplicationService implements UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final BearerTokenSupplier bearerTokenSupplier;
public UserApplicationService(
UserRepository userRepository, PasswordEncoder passwordEncoder, BearerTokenSupplier bearerTokenSupplier) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.bearerTokenSupplier = bearerTokenSupplier;
}
@Transactional
@Override
public User signUp(SignUpUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("Email(`%s`) already exists.".formatted(request.email()));
}
if (userRepository.existsByUsername(request.username())) {
throw new IllegalArgumentException("Username(`%s`) already exists.".formatted(request.username()));
}
User newUser = this.createNewUser(request);
return userRepository.save(newUser);
}
}
And here is a decorated singUp method inside a new class called ObservedUserService
that wraps our signUp
method in an Observation
private static final String USER_CONTEXT = "user";
@Override
public User signUp(SignUpUserRequest request) {
Observation observation = Observation.start(USER_CONTEXT + ".signUp", this::createUserContext, observationRegistry);
try (Observation.Scope ignored = observation.openScope()) {
User registeredUser = userService.signUp(request);
observation.event(Observation.Event.of("signedUp", "User signed up"));
return registeredUser;
} catch (Exception ex) {
observation.error(ex);
throw ex;
} finally {
observation.stop();
}
}
private Observation.Context createUserContext() {
Observation.Context context = new Observation.Context();
context.addLowCardinalityKeyValues(KeyValues.of("context", "user"));
return context;
}
Because we’re only adding some extra behavior to the code, we used a decorator pattern to add the observation to not mix our business logic with an additional abstraction of observation. As you can see, there is no such thing as Timer
or Counter
or logger.info
in this code, we don’t need these low-level abstractions there. Finally, we can focus on what is important. We want to know what happens in our application, when it started (observation.start), when it finished (observation.stop), if there were any errors in the process (observation.error) or if something important happened in our process (observation.event) etc. . We want to describe what we want to observe, not how we want to achieve that. Of course, this is just a simple example of the usage of a new API. The API is much richer, and I’m sure that you will find something which will fit your needs.
Ok, so now we can focus on our intent, and this is very good, but how can we get some metrics, logs, or traces if all that we did is just added observation? This is where the ObservationHandler
comes in. All we need to do now is to register a specific instance of the handler in the ObservationRegistry
.
Here is the part of the code from the ObservationHandler
from the micrometer library, so you can see where in the lifecycle of the observation we can do some extra work.
public interface ObservationHandler<T extends Observation.Context> {
default void onStart(T context) {
}
default void onError(T context) {
}
default void onEvent(Observation.Event event, T context) {
}
default void onStop(T context) {
}
.
.
.
boolean supportsContext(Observation.Context context);
}
Out of the box, there are a couple of ObservationHandlers
already provided for us. For example there is a DefaultMeterObservationHandler
that creates Timer
and Counter
metrics for our Observation
, so all our observations are measured for us. We just need to export our metrics to our metrics storage system. Let me show you how we can expose your metrics from observations for the prometheus on a dedicated endpoint.
How to configure metrics for observations
The easiest way to do this is to use the spring-boot-starter-actuator
module. Let’s add the necessary modules to our application in build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation(platform("io.micrometer:micrometer-bom:1.11.0"))
implementation("io.micrometer:micrometer-core")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("io.micrometer:micrometer-observation") // new micrometer module with observation API
All the lines in this configuration should be self-explanatory.
Spring configuration part
Then let’s expose our metrics in prometheus format on a HTTP endpoint., To do that, we just need to set the exposed endpoints in our application.yaml
file
management:
endpoints:
web:
exposure:
include: "health,metrics,**prometheus**"
And that’s all, now spring-boot
will automatically create a DefaultMeterObservationHandler
bean for us, and here is the magic of spring: all the beans of type ObservationHandler
, and a couple of others from the observations API will be registered in the observation registry automatically for us. This particular handler adds Timer.Sample
and Counter
metrics to all our observations. How cool is that!!!! Let me show you what metrics we have now in the endpoint with metrics for prometheus.
Part of the response from the metrics endpoint
...
# HELP user_signUp_seconds_max
# TYPE user_signUp_seconds_max gauge
user_signUp_seconds_max{error="none",} 0.147985042
# HELP user_signUp_seconds
# TYPE user_signUp_seconds summary
user_signUp_seconds_count{error="none",} 2.0
user_signUp_seconds_sum{error="none",} 0.219810917
...
That’s really cool, but let me show you another thing…
How to configure logs for observations
Do you want to add logs to this process? There’s already a handler available for us to do it - ObservationTextPublisher
, we just need to register it as a spring bean.
@Configuration
public class ObservationLogsConfiguration {
private final Logger logger = LoggerFactory.getLogger(ObservationLogsConfiguration.class);
@Bean
public ObservationTextPublisher observationTextPublisher() {
return new ObservationTextPublisher(
logger::info,
context -> context
.getLowCardinalityKeyValues()
.stream()
.anyMatch(keyValue -> keyValue.getKey().equals("context") && keyValue.getValue().equals("user")),
Observation.Context::getName);
}
}
The first parameter in the ObservationTextPublisher
is the consumer of the message, in our case logger::info
. With the second parameter which is a Predicate<Observation.Context>
you can apply the handler only to selected contexts, we added simple filtering based on low cardinality tags. The last parameter is the converter of the context to the message Function<Observation.Context, String> converter
, we just used its name as a message.
And here are some logs from our process
...
c.s.r.a.c.ObservationLogsConfiguration : STOP - user.signUp
c.s.r.a.c.ObservationLogsConfiguration : START - user.login
c.s.r.a.c.ObservationLogsConfiguration : OPEN - user.login
...
Conclusion
As you can see, the new micrometer API in combination with spring-boot-actuator
is really powerful. It allows us to switch our focus to intent instead of thinking about the low-level implementation details. This is a new way of thinking about observability, and I like it! If you want to go even further I encourage you to watch this video about the new observation API in combination with metrics, tracing, and logging, it’s mind-blowing!