Add OpenTelemetry metrics to a Spring Boot service
Record request counts, in-flight work, latency histograms, and downstream failures in Java, then validate the exported metrics in Guance.
For a Spring Boot service, OpenTelemetry usually needs two kinds of instrumentation: automatic instrumentation for supported frameworks and explicit measurements for business behavior the framework cannot know about. The Java Agent can supply the SDK and export pipeline; your application can use the OpenTelemetry API to record custom metrics through that same pipeline.
This guide adds request counts, in-flight requests, request durations, and downstream API measurements. The examples use Spring Boot 3's jakarta.servlet packages and a synchronous Spring MVC request path. They are not an async-servlet completion tracker.
Choose one owner for the SDK
There are two valid deployment patterns:
- Java Agent plus application API calls. Start the JVM with
-javaagent:opentelemetry-javaagent.jar. Let the Agent initialize the SDK and exporters; use the API for your business instruments. - Application-managed SDK. Initialize an SDK, meter provider, metric reader, exporter, and shutdown lifecycle yourself, or use an appropriate supported starter.
Adding API and SDK dependencies alone does not initialize a working export pipeline. Likewise, creating a second SDK alongside the Agent can produce separate resources and duplicate collection. The OpenTelemetry instrumentation guide explains these integration choices.
The examples below follow the Agent pattern. Add the API dependency, using a version selected by your dependency management:
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-api</artifactId>
<version>${opentelemetry.version}</version>
</dependency>
Define opentelemetry.version in the build using a release compatible with your tested Agent version, or import the corresponding OpenTelemetry BOM and omit the explicit version. If you choose application-managed initialization instead, add the aligned opentelemetry-sdk and opentelemetry-exporter-otlp dependencies as well. Do not assume Spring creates a MeterProvider bean merely because those jars are present.
For the Agent-based example, expose the provider already initialized by the Agent:
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.metrics.MeterProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class TelemetryConfiguration {
@Bean
public MeterProvider meterProvider() {
return GlobalOpenTelemetry.getMeterProvider();
}
}
Without the Agent or another initialized SDK, the global API can be a no-op. A successfully constructed instrument does not prove that anything is being exported. Extending Java Agent instrumentation with the API
Put instruments in a service
A counter records increasing totals. An up-down counter fits a count that increases and decreases, such as in-flight work. A gauge reports an observed value, such as a current queue size; a histogram records a distribution, such as latency.
Create instruments once and reuse them. The custom shop.* names below keep this teaching example separate from HTTP metrics the Agent may already emit:
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.LongUpDownCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.metrics.MeterProvider;
import org.springframework.stereotype.Service;
@Service
public class MetricsService {
private final LongCounter requestCounter;
private final LongUpDownCounter activeRequests;
private final DoubleHistogram requestDuration;
public MetricsService(MeterProvider meterProvider) {
Meter meter = meterProvider.get("shop-service");
requestCounter = meter.counterBuilder("shop.http.requests")
.setDescription("Completed HTTP requests")
.setUnit("{request}")
.build();
activeRequests = meter.upDownCounterBuilder("shop.http.active_requests")
.setDescription("HTTP requests currently being processed")
.setUnit("{request}")
.build();
requestDuration = meter.histogramBuilder("shop.http.request.duration")
.setDescription("Time spent processing a synchronous HTTP request")
.setUnit("s")
.build();
}
public void requestStarted(Attributes attributes) {
activeRequests.add(1, attributes);
}
public void requestFinished(Attributes activeAttributes,
Attributes completedAttributes,
double seconds) {
activeRequests.add(-1, activeAttributes);
requestCounter.add(1, completedAttributes);
requestDuration.record(seconds, completedAttributes);
}
}
For an existing production service, first inspect the Agent's HTTP instruments. Keep its standard metrics where they meet your needs, and add application-specific instruments where they do not. A different name prevents a name collision; it does not make duplicate measurement free.
Measure synchronous requests with bounded attributes
A filter can measure the synchronous request boundary. Use a monotonic clock for elapsed time and a route template for aggregation:
import io.opentelemetry.api.common.Attributes;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerMapping;
@Component
public class MetricsFilter extends OncePerRequestFilter {
private static final Set<String> KNOWN_METHODS = Set.of(
"CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH",
"POST", "PUT", "TRACE");
private final MetricsService metricsService;
public MetricsFilter(MetricsService metricsService) {
this.metricsService = metricsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String method = request.getMethod();
Attributes activeAttributes = Attributes.builder()
.put("http.request.method",
KNOWN_METHODS.contains(method) ? method : "_OTHER")
.build();
metricsService.requestStarted(activeAttributes);
long started = System.nanoTime();
boolean failed = false;
try {
chain.doFilter(request, response);
} catch (IOException | ServletException | RuntimeException | Error e) {
failed = true;
throw e;
} finally {
Object matched = request.getAttribute(
HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
String route = matched == null ? "unmatched" : matched.toString();
Attributes completedAttributes = activeAttributes.toBuilder()
.put("http.route", route)
.put("http.response.status_code", failed ? 500 : response.getStatus())
.build();
metricsService.requestFinished(
activeAttributes,
completedAttributes,
(System.nanoTime() - started) / 1_000_000_000.0);
}
}
}
The route is read after the handler runs because Spring may not have selected it when the filter begins. A path such as /users/12345 would create a different attribute value for every user. The template /users/{id} represents the endpoint without that growth. Unmatched routes use one bounded value rather than an attacker-controlled raw path.
The example also maps unknown HTTP methods to _OTHER to keep clients from creating an unbounded number of method values. Add any legitimate extension methods your application supports to KNOWN_METHODS. This follows the bounded-method principle in the OpenTelemetry HTTP conventions.
The in-flight metric uses the same attributes for its increment and decrement. Adding a route or final status only on the decrement would leave one series permanently positive and another negative.
This example assigns 500 when an exception escapes the filter because the outer container may not yet have set its error response. It measures application processing, not verified network delivery. For asynchronous servlet responses, use supported automatic instrumentation or implement lifecycle-aware tracking with async completion and error handling; this filter's return point is too early to represent the full async request.
Track downstream API calls separately
A slow request can be spending its time in a dependency. Record dependency call count, outcome, and duration separately from server request latency. The following service uses Java 17's record syntax for a minimal response type:
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.metrics.MeterProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class ProductClient {
private final RestTemplate restTemplate;
private final LongCounter productApiCalls;
private final DoubleHistogram productApiDuration;
public ProductClient(RestTemplateBuilder builder,
MeterProvider meterProvider,
@Value("${product.api.base-url}") String baseUrl) {
restTemplate = builder.rootUri(baseUrl).build();
Meter meter = meterProvider.get("shop-service");
productApiCalls = meter.counterBuilder("shop.product_api.calls")
.setUnit("{call}").build();
productApiDuration = meter.histogramBuilder("shop.product_api.duration")
.setUnit("s").build();
}
public Product fetchProduct(String id) {
long started = System.nanoTime();
try {
Product product = restTemplate.getForObject(
"/products/{id}", Product.class, id);
productApiCalls.add(1, Attributes.builder()
.put("api", "product-service")
.put("result", "success").build());
return product;
} catch (RuntimeException e) {
productApiCalls.add(1, Attributes.builder()
.put("api", "product-service")
.put("result", "failure").build());
throw e;
} finally {
productApiDuration.record(
(System.nanoTime() - started) / 1_000_000_000.0,
Attributes.builder().put("api", "product-service").build());
}
}
public record Product(String id, String name) {}
}
Set product.api.base-url to the dependency's base URL, and adapt Product to its response schema. Configure connection and read timeouts for your Spring Boot and HTTP-client version before using the client in production. The stable api value identifies the dependency without putting product IDs into metric attributes.
Export to the receiver you actually configured
The Agent commonly provides JVM telemetry and instrumentation for supported HTTP, database, and messaging libraries. A supported library may produce traces, metrics, or both; do not assume every automatically traced JDBC, Redis, or messaging operation has an equivalent metric.
| Measurement | Practical starting point |
|---|---|
| Supported framework operations | Inspect Java Agent instrumentation and the signals it emits |
| JVM heap, garbage collection, and threads | Agent runtime metrics, subject to configuration |
| Orders, business outcomes, and application-specific work | Custom API instruments |
Set the export protocol and endpoint together. This example assumes an OTLP/HTTP receiver has explicitly been configured to listen on port 4318:
export OTEL_SERVICE_NAME=shop-service
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
java -javaagent:opentelemetry-javaagent.jar -jar shop-service.jar
That is an example Collector endpoint, not a claim that DataKit listens there by default. With DataKit, enable its OpenTelemetry collector and use its documented HTTP or gRPC listener and route. An endpoint, protocol, or path mismatch can leave an apparently healthy application with no incoming metrics. Guance OpenTelemetry integration
Generate a small, known number of requests. Check the metric names, units, route attributes, and resource attributes in Guance. Verify that active requests return to zero after the workload finishes. Then build a service-level query and a detection rule for a concrete condition such as downstream failures or sustained in-flight work.
Metrics and traces are exported independently. An aggregate count does not establish that every corresponding trace was retained.
Frequently asked questions
Do I need to replace Micrometer?
No. Existing Spring applications can keep Micrometer instrumentation and use an appropriate OTLP registry or other supported integration. Micrometer is a metrics facade; the choice of export path is separate from whether the application uses the OpenTelemetry API directly. Rewriting working instruments is not a prerequisite for sending metrics to an OpenTelemetry-compatible backend.
Will the Agent and my code report the same thing twice?
They can if both measure the same operation. Use the Agent's SDK for custom API calls, inspect the framework instruments already enabled, and avoid maintaining two parallel HTTP metric sets unless you have a specific reason.
How should I choose histogram boundaries?
Start with the latency objective. If the target is P99 below one second, boundaries around 0.1, 0.3, 0.5, 1, and 2 seconds can provide useful resolution near that threshold. Configure the aggregation through the SDK or supported Agent configuration rather than assuming those boundaries follow from the instrument name. Validate that the exported histogram and backend aggregation preserve the intended units and boundaries.
How do I distinguish instances?
Set or verify resource attributes such as service.name and service.instance.id. Do not assume every SDK or deployment automatically supplies a unique instance ID. Inspect an exported point, then decide whether a query should group by instance or aggregate across the service.
Related reading
Contact us
Join the community
to join the community
Try Guance
Start online and pay only for what you use.
Get startedChoose a Guance plan