Observation Components
In this section we will describe main components related to Micrometer Observation.
Micrometer Observation basic flow
Observation
through ObservationRegistry
gets created with a mutable Observation.Context
. On each Micrometer Observation lifecycle action (e.g. start()
) a corresponding ObservationHandler
method is called (e.g. onStart
) with the mutable Observation.Context
as argument.
┌───────────────────┐┌───────┐
│ObservationRegistry││Context│
└┬──────────────────┘└┬──────┘
┌▽────────────────────▽┐
│Observation │
└┬─────────────────────┘
┌▽──────┐
│Handler│
└───────┘
Micrometer Observation detailed flow
┌───────────────────┐┌───────┐┌─────────────────────┐┌────────────────────┐
│ObservationRegistry││Context││ObservationConvention││ObservationPredicate│
└┬──────────────────┘└┬──────┘└┬────────────────────┘└┬───────────────────┘
┌▽────────────────────▽────────▽──────────────────────▽┐
│Observation │
└┬─────────────────────────────────────────────────────┘
┌▽──────┐
│Handler│
└┬──────┘
┌▽────────────────┐
│ObservationFilter│
└─────────────────┘
Observation
through ObservationRegistry
gets created with a mutable Observation.Context
. To allow name and key-value customization, an ObservationConvention
can be used instead of direct name setting. List of ObservationPredicate
is run to verify if an Observation
should be created instead of a no-op version. On each Micrometer Observation lifecycle action (e.g. start()
) a corresponding ObservationHandler
method is called (e.g. onStart
) with the mutable Observation.Context
as argument. On Observation
stop, before calling the ObservationHandler
onStop
methods, list of ObservationFilter
is called to optionally further modify the Observation.Context
.
Observation.Context
To pass information between the instrumented code and the handler (or between handler methods, such as onStart
and onStop
), you can use an Observation.Context
. An Observation.Context
is a Map
-like container that can store values for you while your handler can access the data inside the context.
Observation Handler
Observation Handler allows adding capabilities to existing instrumentations (i.e. you instrument code once and depending on the Observation Handler setup, different actions, such as create spans, metrics, logs will happen). In other words, if you have instrumented code and want to add metrics around it, it’s enough for you to register an Observation Handler in the Observation Registry to add that behaviour.
Let’s look at the following example of adding a timer behaviour to an existing instrumentation.
A popular way to record Observations is storing the start state in a Timer.Sample
instance and stopping it when the event has ended.
Recording such measurements could look like this:
MeterRegistry registry = new SimpleMeterRegistry();
Timer.Sample sample = Timer.start(registry);
try {
// do some work here
}
finally {
sample.stop(Timer.builder("my.timer").register(registry));
}
If you want to have more observation options (such as metrics and tracing — already included in Micrometer — plus anything else you will plug in), you need to rewrite that code to use the Observation
API.
ObservationRegistry registry = ObservationRegistry.create();
Observation.createNotStarted("my.operation", registry).observe(this::doSomeWorkHere);
Starting with Micrometer 1.10, you can register "handlers" (ObservationHandler
instances) that are notified about the lifecycle event of an observation (for example, you can run custom code when an observation is started or stopped).
Using this feature lets you add tracing capabilities to your existing metrics instrumentation (see: DefaultTracingObservationHandler
). The implementation of these handlers does not need to be tracing related. It is completely up to you how you are going to implement them (for example, you can add logging capabilities).
ObservationHandler Example
Based on this, we can implement a simple handler that lets the users know about its invocations by printing them out to stdout
:
static class SimpleHandler implements ObservationHandler<Observation.Context> {
@Override
public void onStart(Observation.Context context) {
System.out.println("START " + "data: " + context.get(String.class));
}
@Override
public void onError(Observation.Context context) {
System.out.println("ERROR " + "data: " + context.get(String.class) + ", error: " + context.getError());
}
@Override
public void onEvent(Observation.Event event, Observation.Context context) {
System.out.println("EVENT " + "event: " + event + " data: " + context.get(String.class));
}
@Override
public void onStop(Observation.Context context) {
System.out.println("STOP " + "data: " + context.get(String.class));
}
@Override
public boolean supportsContext(Observation.Context handlerContext) {
// you can decide if your handler should be invoked for this context object or
// not
return true;
}
}
You need to register the handler to the ObservationRegistry
:
ObservationRegistry registry = ObservationRegistry.create();
registry.observationConfig().observationHandler(new SimpleHandler());
You can use the observe
method to instrument your codebase:
ObservationRegistry registry = ObservationRegistry.create();
Observation.Context context = new Observation.Context().put(String.class, "test");
// using a context is optional, so you can call createNotStarted without it:
// Observation.createNotStarted(name, registry)
Observation.createNotStarted("my.operation", () -> context, registry).observe(this::doSomeWorkHere);
You can also take full control of the scoping mechanism:
ObservationRegistry registry = ObservationRegistry.create();
Observation.Context context = new Observation.Context().put(String.class, "test");
// using a context is optional, so you can call start without it:
// Observation.start(name, registry)
Observation observation = Observation.start("my.operation", () -> context, registry);
try (Observation.Scope scope = observation.openScope()) {
doSomeWorkHere();
}
catch (Exception ex) {
observation.error(ex); // and don't forget to handle exceptions
throw ex;
}
finally {
observation.stop();
}
Signaling Errors and Arbitrary Events
When instrumenting code, we might want to signal that an error happened or signal that an arbitrary event happened. The observation API lets us do so through its error
and event
methods.
One use-case for signaling an arbitrary event can be attaching annotations to Span
for Distributed Tracing, but you can also process them however you want in your own handler, such as emitting log events based on them:
ObservationRegistry registry = ObservationRegistry.create();
Observation observation = Observation.start("my.operation", registry);
try (Observation.Scope scope = observation.openScope()) {
observation.event(Observation.Event.of("my.event", "look what happened"));
doSomeWorkHere();
}
catch (Exception exception) {
observation.error(exception);
throw exception;
}
finally {
observation.stop();
}
Observation.ObservationConvention Example
When instrumenting code, we want to provide sensible defaults for tags, but also we want to let users easily change those defaults. An ObservationConvention
interface is a description of what tags and name we should create for an Observation.Context
:
/**
* A dedicated {@link Observation.Context} used for taxing.
*/
class TaxContext extends Observation.Context {
private final String taxType;
private final String userId;
TaxContext(String taxType, String userId) {
this.taxType = taxType;
this.userId = userId;
}
String getTaxType() {
return taxType;
}
String getUserId() {
return userId;
}
}
/**
* An example of an {@link ObservationFilter} that will add the key-values to all
* observations.
*/
class CloudObservationFilter implements ObservationFilter {
@Override
public Observation.Context map(Observation.Context context) {
return context.addLowCardinalityKeyValue(KeyValue.of("cloud.zone", CloudUtils.getZone()))
.addHighCardinalityKeyValue(KeyValue.of("cloud.instance.id", CloudUtils.getCloudInstanceId()));
}
}
/**
* An example of an {@link ObservationConvention} that renames the tax related
* observations and adds cloud related tags to all contexts. When registered via the
* `ObservationRegistry#observationConfig#observationConvention` will override the
* default {@link TaxObservationConvention}. If the user provides a custom
* implementation of the {@link TaxObservationConvention} and passes it to the
* instrumentation, the custom implementation wins.
*
* In other words
*
* 1) Custom {@link ObservationConvention} has precedence 2) If no custom convention
* was passed and there's a matching {@link GlobalObservationConvention} it will be
* picked 3) If there's no custom, nor matching global convention, the default
* {@link ObservationConvention} will be used
*
* If you need to add some key-values regardless of the used
* {@link ObservationConvention} you should use an {@link ObservationFilter}.
*/
class GlobalTaxObservationConvention implements GlobalObservationConvention<TaxContext> {
// this will be applicable for all tax contexts - it will rename all the tax
// contexts
@Override
public boolean supportsContext(Observation.Context context) {
return context instanceof TaxContext;
}
@Override
public String getName() {
return "global.tax.calculate";
}
}
// Interface for an ObservationConvention related to calculating Tax
interface TaxObservationConvention extends ObservationConvention<TaxContext> {
@Override
default boolean supportsContext(Observation.Context context) {
return context instanceof TaxContext;
}
}
/**
* Default convention of tags related to calculating tax. If no user one or global
* convention will be provided then this one will be picked.
*/
class DefaultTaxObservationConvention implements TaxObservationConvention {
@Override
public KeyValues getLowCardinalityKeyValues(TaxContext context) {
return KeyValues.of(TAX_TYPE.withValue(context.getTaxType()));
}
@Override
public KeyValues getHighCardinalityKeyValues(TaxContext context) {
return KeyValues.of(USER_ID.withValue(context.getUserId()));
}
@Override
public String getName() {
return "default.tax.name";
}
}
/**
* If micrometer-docs-generator is used, we will automatically generate documentation
* for your observations. Check this URL
* https://github.com/micrometer-metrics/micrometer-docs-generator#documentation for
* setup example and read the {@link ObservationDocumentation} javadocs.
*/
enum TaxObservationDocumentation implements ObservationDocumentation {
CALCULATE {
@Override
public Class<? extends ObservationConvention<? extends Observation.Context>> getDefaultConvention() {
return DefaultTaxObservationConvention.class;
}
@Override
public String getContextualName() {
return "calculate tax";
}
@Override
public String getPrefix() {
return "tax";
}
@Override
public KeyName[] getLowCardinalityKeyNames() {
return TaxLowCardinalityKeyNames.values();
}
@Override
public KeyName[] getHighCardinalityKeyNames() {
return TaxHighCardinalityKeyNames.values();
}
};
enum TaxLowCardinalityKeyNames implements KeyName {
TAX_TYPE {
@Override
public String asString() {
return "tax.type";
}
}
}
enum TaxHighCardinalityKeyNames implements KeyName {
USER_ID {
@Override
public String asString() {
return "tax.user.id";
}
}
}
}
/**
* Our business logic that we want to observe.
*/
class TaxCalculator {
private final ObservationRegistry observationRegistry;
// If the user wants to override the default they can override this. Otherwise,
// it will be {@code null}.
@Nullable
private final TaxObservationConvention observationConvention;
TaxCalculator(ObservationRegistry observationRegistry,
@Nullable TaxObservationConvention observationConvention) {
this.observationRegistry = observationRegistry;
this.observationConvention = observationConvention;
}
void calculateTax(String taxType, String userId) {
// Create a new context
TaxContext taxContext = new TaxContext(taxType, userId);
// Create a new observation
TaxObservationDocumentation.CALCULATE
.observation(this.observationConvention, new DefaultTaxObservationConvention(), () -> taxContext,
this.observationRegistry)
// Run the actual logic you want to observe
.observe(this::calculateInterest);
}
private void calculateInterest() {
// do some work
}
}
/**
* Example of user changing the default conventions.
*/
class CustomTaxObservationConvention extends DefaultTaxObservationConvention {
@Override
public KeyValues getLowCardinalityKeyValues(TaxContext context) {
return super.getLowCardinalityKeyValues(context)
.and(KeyValue.of("additional.low.cardinality.tag", "value"));
}
@Override
public KeyValues getHighCardinalityKeyValues(TaxContext context) {
return KeyValues.of("this.would.override.the.default.high.cardinality.tags", "value");
}
@Override
public String getName() {
return "tax.calculate";
}
}
/**
* A utility class to set cloud related arguments.
*/
static class CloudUtils {
static String getZone() {
return "...";
}
static String getCloudInstanceId() {
return "...";
}
}
For a more detailed example, see the full usage example of an instrumentation together with overriding the default tags.
The following example puts the whole code together:
// Registry setup
ObservationRegistry observationRegistry = ObservationRegistry.create();
// add metrics
SimpleMeterRegistry registry = new SimpleMeterRegistry();
observationRegistry.observationConfig().observationHandler(new DefaultMeterObservationHandler(registry));
observationRegistry.observationConfig().observationConvention(new GlobalTaxObservationConvention());
// This will be applied to all observations
observationRegistry.observationConfig().observationFilter(new CloudObservationFilter());
// In this case we're overriding the default convention by passing the custom one
TaxCalculator taxCalculator = new TaxCalculator(observationRegistry, new CustomTaxObservationConvention());
// run the logic you want to observe
taxCalculator.calculateTax("INCOME_TAX", "1234567890");
Observation Predicates and Filters
To globally disable observations under given conditions, you can use an ObservationPredicate
. To mutate the Observation.Context
, you can use an ObservationFilter
.
To set these, call the ObservationRegistry#observationConfig()#observationPredicate()
and ObservationRegistry#observationConfig()#observationFilter()
methods, respectively.
The following example uses predicates and filters:
// Example using a metrics handler - we need a MeterRegistry
MeterRegistry meterRegistry = new SimpleMeterRegistry();
// Create an ObservationRegistry
ObservationRegistry registry = ObservationRegistry.create();
// Add predicates and filter to the registry
registry.observationConfig()
// ObservationPredicate can decide whether an observation should be
// ignored or not
.observationPredicate((observationName, context) -> {
// Creates a noop observation if observation name is of given name
if ("to.ignore".equals(observationName)) {
// Will be ignored
return false;
}
if (context instanceof MyContext) {
// For the custom context will ignore a user with a given name
return !"user to ignore".equals(((MyContext) context).getUsername());
}
// Will proceed for all other types of context
return true;
})
// ObservationFilter can modify a context
.observationFilter(context -> {
// We're adding a low cardinality key to all contexts
context.addLowCardinalityKeyValue(KeyValue.of("low.cardinality.key", "low cardinality value"));
if (context instanceof MyContext) {
// We're mutating a specific type of a context
MyContext myContext = (MyContext) context;
myContext.setUsername("some username");
// We want to remove a high cardinality key value
return myContext.removeHighCardinalityKeyValue("high.cardinality.key.to.ignore");
}
return context;
})
// Example of using metrics
.observationHandler(new DefaultMeterObservationHandler(meterRegistry));
// Observation will be ignored because of the name
then(Observation.start("to.ignore", () -> new MyContext("don't ignore"), registry)).isSameAs(Observation.NOOP);
// Observation will be ignored because of the entries in MyContext
then(Observation.start("not.to.ignore", () -> new MyContext("user to ignore"), registry))
.isSameAs(Observation.NOOP);
// Observation will not be ignored...
MyContext myContext = new MyContext("user not to ignore");
myContext.addHighCardinalityKeyValue(KeyValue.of("high.cardinality.key.to.ignore", "some value"));
Observation.createNotStarted("not.to.ignore", () -> myContext, registry).observe(this::yourCodeToMeasure);
// ...and will have the context mutated
then(myContext.getLowCardinalityKeyValue("low.cardinality.key").getValue()).isEqualTo("low cardinality value");
then(myContext.getUsername()).isEqualTo("some username");
then(myContext.getHighCardinalityKeyValues())
.doesNotContain(KeyValue.of("high.cardinality.key.to.ignore", "some value"));
Using Annotations With @Observed
If you have turned on Aspect Oriented Programming (for example, by using org.aspectj:aspectjweaver
), you can use the @Observed
annotation to create observations. You can put that annotation either on a method to observe it or on a class to observe all the methods in it.
The following example shows an ObservedService
that has an annotation on a method:
static class ObservedService {
@Observed(name = "test.call", contextualName = "test#call",
lowCardinalityKeyValues = { "abc", "123", "test", "42" })
void call() {
System.out.println("call");
}
}
The following test asserts whether the proper observation gets created when a proxied ObservedService
instance gets called:
// create a test registry
TestObservationRegistry registry = TestObservationRegistry.create();
// add a system out printing handler
registry.observationConfig().observationHandler(new ObservationTextPublisher());
// create a proxy around the observed service
AspectJProxyFactory pf = new AspectJProxyFactory(new ObservedService());
pf.addAspect(new ObservedAspect(registry));
// make a call
ObservedService service = pf.getProxy();
service.call();
// assert that observation has been properly created
assertThat(registry)
.hasSingleObservationThat()
.hasBeenStopped()
.hasNameEqualTo("test.call")
.hasContextualNameEqualTo("test#call")
.hasLowCardinalityKeyValue("abc", "123")
.hasLowCardinalityKeyValue("test", "42")
.hasLowCardinalityKeyValue("class", ObservedService.class.getName())
.hasLowCardinalityKeyValue("method", "call").doesNotHaveError();