Custom Instrumentation
- Before You Start
- Annotation-Based Instrumentation
- Fluent Helpers
- Raw Registry Beans
- Choosing an Approach
- Naming and Tag Cardinality
Observability Kit records everything it measures into your application’s Micrometer MeterRegistry, and emits tracing spans through the Micrometer Observation API.
Your own instrumentation uses the same registry and the same Observation API, so any meters and spans you add sit right alongside the built-in ones and export through whatever backend you already configured — Prometheus, OTLP, Zipkin, and so on.
There’s nothing kit-specific to learn: if you know Micrometer, you know how to extend the kit.
Three ways exists for adding custom instrumentation, listed here from the most declarative to the most explicit:
- Annotation-based
-
Add
@Timed,@Counted, or@Observedto a method and let an aspect do the work. Least code, requires Spring AOP. - Fluent helpers
-
Build a meter or observation with Micrometer’s fluent builders. Full control over names, tags, and descriptions, no AOP required.
- Raw registry beans
-
Inject the
MeterRegistry(orObservationRegistry) and call it directly. The lowest-level option, useful for dynamic meters and gauges.
Fluent helpers and raw beans work in every deployment — Spring Boot, plain-Spring, and standalone. Annotation-based instrumentation is the exception: it depends on an AOP proxying container, so it’s available only in Spring-based setups (Spring Boot or plain Spring), not in a standalone deployment.
Before You Start
Custom instrumentation needs access to a MeterRegistry, and — for tracing — an ObservationRegistry.
How you obtain them depends on how the kit is set up:
| Setup | How to obtain the registries |
|---|---|
Spring Boot starter | Both registries are Spring beans.
Inject them anywhere, including into a Vaadin view’s constructor.
The starter pulls in Boot’s Micrometer auto-configuration, and Actuator supplies the |
Plain Spring | You define the |
Standalone | You create the registries when you call |
|
Note
| A meter or span is only exported if a registry backend is configured. See the Configuration page for adding Actuator and a registry such as Prometheus. |
Annotation-Based Instrumentation
The least intrusive way to instrument a method is to annotate it. Micrometer ships three method-level annotations, each backed by an aspect:
|
Note
|
What is Spring AOP?
Aspect-Oriented Programming (AOP) lets you attach behavior to method calls without changing the method’s own code.
Spring implements it by wrapping each managed bean in a proxy: when one bean calls another, the call passes through the proxy first, which runs the extra behavior (here, starting and stopping a timer or span) and then forwards to your real method.
This is how the annotations below take effect — and why they only work on Spring-managed beans, since a plain object created with |
@Timed-
Records a
Timer— how long the method takes and how often it’s called. @Counted-
Records a
Counter— how many times the method is invoked. @Observed-
Creates an Observation, producing both a timer and a tracing span. This is the annotation that ties into the kit’s trace tree.
Enabling the Aspects
The annotations do nothing on their own — a corresponding aspect bean must be present, and the application context must support AOP proxying.
With the Spring Boot starter, add the AOP starter and the aspects are registered automatically by Boot (TimedAspect and CountedAspect from the metrics auto-configuration, ObservedAspect from the observation auto-configuration).
No further wiring is needed.
Source code
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>XML
Java
Java
|
Important
|
The aspects intercept calls through a Spring proxy, so an annotation only fires when the method is called from another bean. Self-invocation (a bean calling its own annotated method) and calls on plain, non-managed objects are not intercepted. For those cases, use the fluent helpers instead. |
Using the Annotations
Annotate a service method to time it and count its errors.
The value becomes the meter name; extraTags adds static tags.
Source code
CheckoutService.java
CheckoutService.java@Service
public class CheckoutService {
@Timed(value = "app.checkout.duration",
description = "Time to complete a checkout",
extraTags = { "module", "checkout" })
@Counted(value = "app.checkout.count", description = "Checkout attempts")
public Receipt checkout(Cart cart) {
// business logic
}
}For an @Observed method you get a timer and a span.
When the call happens during a Vaadin request, the span is automatically parented under the kit’s vaadin.request (or vaadin.rpc) span, so it shows up in the same trace.
Source code
PricingService.java
PricingService.java@Service
public class PricingService {
@Observed(name = "app.pricing",
contextualName = "calculate-price",
lowCardinalityKeyValues = { "tier", "standard" })
public Money calculatePrice(Order order) {
// produces both an `app.pricing` timer and an `app.pricing` span,
// nested inside the current Vaadin request trace
}
}|
Note
|
Keep extraTags and lowCardinalityKeyValues to a small, fixed set of values.
See Naming and Cardinality.
|
Fluent Helpers
When you need control over the meter — custom percentiles, SLA buckets, dynamically computed tags — or when AOP proxying isn’t an option, use Micrometer’s fluent builders. They give you the full meter definition and work in any deployment, including standalone.
Metrics
Every meter type has a builder(…) that ends in register(registry).
Registration is idempotent: calling it again with the same name and tags returns the existing meter, so it’s safe to build inside a hot path.
Source code
Recording a timer with the fluent builder
Timer timer = Timer.builder("app.report.duration")
.description("Time to render a report")
.tag("format", "pdf")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
timer.record(() -> renderReport());Source code
A counter and a gauge
Counter exports = Counter.builder("app.report.exports")
.description("Reports exported")
.tag("format", "pdf")
.register(registry);
exports.increment();
// A gauge tracks a live value by holding a weak reference to the source.
Gauge.builder("app.queue.depth", workQueue, Queue::size)
.description("Pending background jobs")
.register(registry);Traces
For a custom span, build an Observation and wrap the work in observe(…).
The observation opens a scope, so any span started inside it — including the kit’s own RPC and navigation spans — nests correctly, and it’s stopped (and marked error on exception) for you.
Source code
Wrapping work in an Observation
Observation.createNotStarted("app.import", observationRegistry)
.contextualName("import-customers")
.lowCardinalityKeyValue("source", "csv")
.observe(() -> importCustomers(file));When tracing is enabled, this also emits an app.import timer through the kit’s DefaultMeterObservationHandler — one annotation-free way to get a metric and a span from a single call.
Raw Registry Beans
The most direct option is to inject the registry and call it. This is what you reach for when meter names or tags are computed at runtime, or when you want to read the kit’s own meters back out. Because the kit records into the very same registry, a custom meter recorded here is indistinguishable from a built-in one to your backend.
The following Vaadin view records a custom timer next to the kit’s meters, and reads the built-in vaadin.request.duration timer back out:
Source code
LatencyView.java
LatencyView.java@Route("latency")
public class LatencyView extends VerticalLayout {
private static final String SERVER_TIMER = "vaadin.request.duration";
private final transient MeterRegistry registry;
public LatencyView(MeterRegistry registry) {
this.registry = registry;
add(new Button("Do work", e -> timed("do-work", this::doWork)));
add(new Button("Show server timing", e -> {
Timer timer = registry.find(SERVER_TIMER).timer();
if (timer != null) {
Notification.show("%d requests, max %.0f ms".formatted(
timer.count(), timer.max(TimeUnit.MILLISECONDS)));
}
}));
}
/** Record a custom timer next to the kit's built-in meters. */
private void timed(String action, Runnable work) {
Timer.Sample sample = Timer.start(registry);
try {
work.run();
} finally {
sample.stop(registry.timer("app.interaction", "action", action));
}
}
}The convenience methods — registry.timer(name, tags…), registry.counter(name, tags…), registry.gauge(…) — create the meter on first use and return the existing one thereafter.
Use Timer.Sample (as above) when start and stop happen at different points in the code.
For a custom span at this level, drive the Observation lifecycle yourself with an explicit scope — the same pattern the kit uses internally:
Source code
Manually managing an Observation scope
Observation observation = Observation
.createNotStarted("app.batch", observationRegistry)
.contextualName("nightly-batch")
.start();
try (Observation.Scope ignored = observation.openScope()) {
runBatch();
} catch (Throwable t) {
observation.error(t);
throw t;
} finally {
observation.stop();
}|
Tip
|
In a standalone deployment, retrieve the registries from wherever you stashed them at |
Choosing an Approach
| Approach | Use it when |
|---|---|
Annotations | You want declarative, low-noise instrumentation of service methods, and you’re on Spring with AOP. Best for coarse-grained "how long does this operation take" measurements. |
Fluent helpers | You need control over the meter definition (percentiles, SLAs, descriptions), self-invocation defeats the aspect, or you’re standalone. The default choice for most explicit instrumentation. |
Raw beans | Meter names or tags are computed at runtime, you’re recording inside a tight loop, or you need to read existing meters back out of the registry. |
Naming and Tag Cardinality
Custom meters and spans share a namespace and a backend with the kit’s, so follow the same conventions:
- Use dotted, lowercase names
-
Match Micrometer/OpenTelemetry convention —
app.checkout.duration, notcheckoutTime. Prefix application meters with a stable namespace such asapp.to keep them clearly separate from thevaadin.meters. - Keep tag values low-cardinality
-
Every distinct combination of tag values creates a new time series. Tag with bounded values (a status, a small enum, a fixed module name) — never with user IDs, session IDs, full URLs, or anything unbounded. The kit guards its own
routetag with a cardinality limit (seevaadin.observability.route-cardinality-limit); apply the same discipline to your tags. - Reuse meters, don’t recreate state
-
Builders and the
registry.timer(…)shortcuts are idempotent — they return the existing meter for a given name+tags. Don’t cache aCounterkeyed by a high-cardinality value.
See the Reference page for the full list of built-in meter and span names so your own names don’t collide with them.
D4A8490D-0E40-4BFA-839C-E542512F69E3