The original version of this post walked through building a Prometheus and Grafana stack by hand: add micrometer-registry-prometheus, expose /actuator/prometheus, run a Prometheus container with a hand-written scrape config, run a Grafana container, wire up a data source, import a dashboard by ID. None of that stopped working on Spring Boot 4.1 — it’s still a completely valid way to run Prometheus and Grafana. What changed is that Boot 4.1 adds a fourth option, built by the Spring team, that skips almost all of it: point one Docker Compose file at a real observability backend and the metrics and traces just start arriving, verified end to end against a real running container, not a diagram.
Versions this was verified against. Spring Boot 4.1.1 (GA, 20 August 2026), Spring Framework 7.0.9, Eclipse Temurin JDK 25.0.4.1 LTS, grafana/otel-lgtm (bundled Grafana, Prometheus-compatible backend, Tempo and Loki). Companion code: observability — every number below came out of a real container queried live, captured under docs/output/.
The problem: three signals, and metrics is the one that scales
A handful of services is easy to reason about by reading logs. Past a dozen, “what’s actually happening right now” stops being something you can grep your way to, and observability splits into three pillars that answer different questions: logs (“what happened to this one request”), traces (“where did the time go, across which services”), and metrics (“what’s the aggregate behaviour over the last five minutes, and is it getting worse”). This post is about the last one, and about the newest way to get it — and its neighbour, tracing — out of a Spring Boot application.
The classic way: Actuator, Prometheus, Grafana
This is still exactly how most Spring Boot shops run metrics today, and it still works unchanged on Boot 4.1. Add the dependencies — the same two this blog’s own Actuator module already ships in production configuration:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
(actuator-in-production/pom.xml)
expose the scrape endpoint:
management:
endpoints:
web:
exposure:
include: prometheus,health,info
metrics:
tags:
application: ${spring.application.name}
and a Prometheus container polls /actuator/prometheus on a timer, storing whatever it finds. Grafana then points at Prometheus as a data source and turns the stored series into dashboards. It is a pull model end to end: nothing your app does pushes data anywhere, a scraper reaches in and takes it, and the failure mode when something’s wrong is usually “Prometheus’s Targets page shows this job as DOWN.”
Run it against a real container and the Targets page confirms the scrape succeeded, and a query against a custom counter returns real numbers — this part of the post hasn’t changed in substance since the original, so it isn’t re-proven here; what’s new below is the option that replaces it.
- Full Docker run commands, data-source setup and dashboard import steps for this classic path are unchanged from before — see the Prometheus getting-started guide and Grafana’s own docs for the current versions.
The new way: one starter, one Docker Compose file, verified against a real container
Spring Boot 4.0 shipped a starter built by the Spring team specifically for this:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-opentelemetry</artifactId>
</dependency>
(pom.xml) One dependency replaces the registry jar and a separate tracing bridge. It pulls in micrometer-registry-otlp and micrometer-tracing-bridge-otel, and switches the export model from pull to push: the app itself POSTs metrics and traces, in one vendor-neutral wire format (OTLP), to wherever you point it — and in local development, “wherever you point it” can be nowhere you configure at all.
Docker Compose does the wiring — watched happen against a real container
Name one image in a compose.yaml next to the app:
services:
lgtm:
image: grafana/otel-lgtm:latest
ports:
- "3000:3000" # Grafana UI
- "4317:4317" # OTLP gRPC ingest
- "4318:4318" # OTLP HTTP ingest
- "9090:9090" # Prometheus-compatible query API
- "3200:3200" # Tempo query API (traces)
(compose.yaml) grafana/otel-lgtm bundles Loki, Grafana, Tempo and a Prometheus-compatible backend behind one OTLP ingest endpoint — the same image the Spring team’s own docs use as the local-development example. There is no management.otlp.* property anywhere in this repo’s application.yml.
Compare that to the pull diagram above: the arrow now points the other way, and there is only one box on the right instead of two, because one container and one wire format now carry metrics and traces together. Running mvn spring-boot:run against it produces this, captured verbatim:
DockerComposeLifecycleManager : Using Docker Compose file /tmp/obs-module/compose.yaml
DockerCli : Container obs-module-lgtm-1 Starting
DockerCli : Container obs-module-lgtm-1 Started
DockerCli : Container obs-module-lgtm-1 Healthy
PushMeterRegistry : Publishing metrics for OtlpMeterRegistry every 1m to http://127.0.0.1:4318/v1/metrics with resource attributes {service.name=order-service}
(00-docker-compose-auto-wiring.txt) The detail worth noticing is 127.0.0.1, not localhost. Micrometer’s own OtlpConfig already defaults to localhost:4318 with zero configuration at all, so an app that happens to reuse the default port can look auto-wired when it’s really just coincidence. 127.0.0.1 is what Boot’s Docker Compose service-connection support specifically resolves the container to — if your own app logs localhost instead, the auto-wiring didn’t actually happen.
This only auto-wires locally. Every other environment needs the endpoint set explicitly — eithermanagement.otlp.*properties, or, as of Boot 4.1, standardOTEL_*environment variables (more on exactly which ones, below). Docker Compose detection is a development convenience, not a deployment strategy.
Real traffic against the running app, queried straight out of the real backend — not the app’s own /actuator, and no scraping involved, since this is push:
$ curl -s "http://localhost:9090/api/v1/query?query=orders_placed_total"
{
"status": "success",
"data": {
"resultType": "vector",
"result": [
{
"metric": {
"__name__": "orders_placed_total",
"application": "order-service",
"channel": "web",
"job": "order-service",
"service_name": "order-service"
},
"value": [1789722945.151, "8"]
}
]
}
}
(01-metrics-and-traces-in-lgtm.txt, source: OrderController.java) 8 real HTTP POSTs, 8 counted — via a container that was created, started and health-checked by Boot itself, in this run, not left over from a previous session.
The full beginner-to-advanced treatment of what this starter changes underneath — the Observation API, cardinality, context propagation across @Async and hand-built HTTP clients — already exists on this blog and isn’t repeated here: Micrometer to OpenTelemetry: The Spring Boot 4 Observability Guide. This post exists to run a few of that guide’s claims against real infrastructure, and running them surfaced two things worth a correction.
- Dependency and property reference for the starter: docs/01-migrating-to-opentelemetry-starter.md
@Observed looked broken. It wasn’t broken — it was missing one bean.
OrderController.placeOrder is annotated to produce a trace span on top of the metric it already records:
@Observed(name = "place-order", contextualName = "order-controller#placeOrder")
@PostMapping("/orders/{id}")
Map<String, Object> placeOrder(@PathVariable String id) throws InterruptedException {
ordersPlaced.increment();
...
}
(OrderController.java) The idea — one recording, two signals — is exactly what the deeper guide linked above describes. spring-boot-starter-opentelemetry was present. spring-boot-starter-aspectj — the AspectJ weaver, needed because @Observed is AOP-based, and covered for a different starter entirely in the resilience module’s chapter on the aop→aspectj rename — was on the classpath too. Micrometer Tracing was active; the HTTP server span for every request was already showing up correctly. And the @Observed child span still never appeared:
$ curl -s "http://localhost:3200/api/traces/<trace-id>"
span: http post /orders/{id} | kind: SPAN_KIND_SERVER | parent: (root)
One span. No error, no startup warning — the method-level span was just silently absent. Spring Boot’s autoconfiguration wires an ObservationRegistry bean for you the instant Micrometer Tracing is on the classpath. It does not also register a Micrometer ObservedAspect — the weaver being present is necessary but not sufficient, because without the aspect there’s nothing for it to apply @Observed through. The fix is one small bean:
@Configuration(proxyBeanMethods = false)
class ObservationConfig {
@Bean
ObservedAspect observedAspect(ObservationRegistry registry) {
return new ObservedAspect(registry);
}
}
(ObservationConfig.java) Same annotation, same request, new trace, fetched from the same real backend:
span: order-controller#placeOrder | kind: SPAN_KIND_INTERNAL | parent: <server-span-id>
span: http post /orders/{id} | kind: SPAN_KIND_SERVER | parent: (root)
(03-observed-needs-explicit-bean.txt) Two spans, correctly nested. This is worth stating plainly against the natural reading of “Boot wires this up for you when Micrometer Tracing is active” — it wires the registry, not the aspect, and the gap survives code review because nothing fails.
- If your own
@Observedspans aren’t showing up, check for this bean before anything else — self-invocation is the usual second suspect, covered generally in the caching module’s self-invocation chapter. - Full write-up: docs/02-observation-api.md
Metrics showed up. Most of the traces didn’t. That’s sampling, not a bug.
Chasing the @Observed bug above meant staring at trace counts for a while, which surfaced a second, unrelated thing worth its own section. management.tracing.sampling.probability defaults to 0.10, and metrics are never subject to it at all:
$ for i in $(seq 101 115); do curl -s -X POST "http://localhost:8080/orders/$i" > /dev/null; done
# 15 requests sent
$ curl -s "http://localhost:9090/api/v1/query?query=orders_placed_total"
# metric value: 15 -- all 15 counted, metrics are never subject to trace sampling
$ curl -s "http://localhost:3200/api/search?limit=50"
# trace count increased by 1 -- only ~1 of the 15 requests was sampled into a trace
--- with the fulltrace profile (management.tracing.sampling.probability=1.0) ---
$ mvn -Dspring-boot.run.profiles=fulltrace spring-boot:run
$ for i in $(seq 401 410); do curl -s -X POST "http://localhost:8080/orders/$i" > /dev/null; done
# 10 requests sent
$ curl -s "http://localhost:3200/api/search?limit=50"
# post /orders traces increased by 10 -- 10 of 10, all of them present
(02-low-sampling-demo.txt, profile: application.yml) 10% is a reasonable default for real production traffic volumes — tracing every request at scale is expensive. It is the wrong setting for a five-minute local demo: send a handful of test requests and the honest, common experience is “my trace isn’t showing up,” which reads exactly like a broken exporter rather than sampling working as designed.
- If metrics show up in your backend but traces don’t, check the sampling probability before you suspect the pipeline.
What’s actually new in Boot 4.1: standard OTEL_* environment variables, checked against two real Boot versions
Boot’s own 4.1 release notes state: “Support has been added to read most of the OpenTelemetry environment variables.” That’s easy to either overstate or take on faith. The real answer, from a small side experiment in the same repo — identical Java source, compiled once against spring-boot-starter-parent 4.0.8 and once against 4.1.1, each run with only standard OTEL_* variables set and zero management.otlp.* Spring properties, pointed at a minimal stand-in OTLP receiver:
| Variable | Boot 4.0.8 | Boot 4.1.1 |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | Honored | Honored |
OTEL_METRIC_EXPORT_INTERVAL | Ignored — stays at the 1-minute default | Honored |
--- Spring Boot 4.1.1 ---
Publishing metrics for OtlpMeterRegistry every 2s to http://localhost:PORT/v1/metrics ...
receiver log (20s window):
... POST /v1/metrics content-length=8247 (x11, one every ~2s)
--- Spring Boot 4.0.8, same two env vars ---
Publishing metrics for OtlpMeterRegistry every 1m to http://localhost:PORT/v1/metrics ...
receiver log (20s window):
... POST /v1/metrics content-length=8247 (x1, at JVM shutdown only)
(04-otel-env-vars-4.0-vs-4.1.txt, source: env-var-proof/) The endpoint variable already worked on 4.0 — Micrometer’s own OtlpConfig has long fallen back to it, independent of anything Spring-specific. What’s genuinely new in 4.1 is the rest of the standard surface: interval, protocol, per-signal overrides. Boot 4.0’s own management.otlp.* properties always covered this (the same transcript shows MANAGEMENT_OTLP_METRICS_EXPORT_STEP working identically on 4.0); the practical win in 4.1 is that the exact same environment variables already used to configure an OTel Collector, or a service written in another language entirely, now also configure this Spring Boot app, with nothing Spring-specific to learn.
- Full variable-to-property mapping: Spring Boot 4.1 Release Notes, OpenTelemetry section
- Reproduce this yourself: scripts/run-env-var-proof.sh
Which one, for what
| Situation | Reach for |
|---|---|
| Already running Prometheus + Grafana, scraping other services the classic way | Keep micrometer-registry-prometheus alongside the new starter — they’re not mutually exclusive; Micrometer can push to more than one registry from the same meters |
| New service, want traces and metrics from one dependency, one wire format | spring-boot-starter-opentelemetry |
| Local development against any OTLP-capable backend | A compose.yaml naming grafana/otel-lgtm (or another OTLP-capable image) — zero export properties needed |
| Any non-local environment | Set the export endpoint explicitly — management.otlp.* properties or, on 4.1+, standard OTEL_* variables. Auto-wiring never applies outside local dev |
@Observed not producing spans | Register an explicit ObservedAspect bean — it is not autoconfigured |
| Traces missing at low local traffic | Check management.tracing.sampling.probability (default 0.10) before suspecting the exporter |
Frequently Asked Questions
Do I have to give up Prometheus and Grafana to use the new starter? No. Keep micrometer-registry-prometheus alongside spring-boot-starter-opentelemetry if your infrastructure still scrapes /actuator/prometheus — Micrometer can publish to multiple registries from the same meters at once.
Does Spring Boot 4 use Micrometer 2? No. As covered in depth in the linked guide’s correction of this exact rumor, Boot 4.0/4.1 manage Micrometer 1.x; Micrometer 2.0 remains pre-release.
Is the Docker Compose auto-wiring safe to rely on in production? No — it’s development-only, and this post says so directly above. Set the export endpoint explicitly for every other environment.
Why didn’t my @Observed span show up even though tracing works for HTTP requests? Almost certainly the missing ObservedAspect bean covered above — Boot autoconfigures the registry, not the aspect.
AI Prompts for Migrating to spring-boot-starter-opentelemetry
Audit for the ObservedAspect Gap
Here is my Spring Boot project: [paste relevant files]. Find every @Observed annotation and check whether an ObservedAspect bean is registered anywhere in the application context (a @Bean method returning ObservedAspect, or an explicit @Configuration class for it). If none exists, tell me exactly which @Observed annotations are currently silently producing no span.
What it does: Catches the exact gap this post found by execution — AspectJ weaving and Micrometer Tracing both present is not sufficient on its own.
Decide Whether to Add the OpenTelemetry Starter
Here is my current observability setup: [paste pom.xml/build.gradle and management.* properties]. Tell me whether spring-boot-starter-opentelemetry would let me consolidate any registry-specific or tracing-bridge dependencies, and whether I still need to keep micrometer-registry-prometheus based on what’s scraping me today.
What it does: Turns the “which one, for what” table above into a decision on a real project’s actual dependencies.
Further reading
- observability — companion repository, every transcript quoted above, regenerated by
scripts/run-all.sh - Micrometer to OpenTelemetry: The Spring Boot 4 Observability Guide — the full Observation API, cardinality and context-propagation deep dive this post cross-links rather than repeats
- spring-boot-starter-aop renamed to spring-boot-starter-aspectj, the same rename this post’s AspectJ dependency needed
- grafana/otel-lgtm on GitHub
- Spring Boot 4.1 Release Notes
- Prometheus: Getting Started
No Comments yet!