Most of what Spring Boot 4.2 changes about OpenTelemetry will not break your build. It will break a dashboard, an alert or a log search a week later, because it changes names: of meters, of tags, of spans, and of the keys that carry a trace id. A renamed metric does not raise an error. The panel that reads it goes empty.
This post runs those changes on Spring Boot 4.2.0-RC1 against 4.1.2, with one small web application and a 60-line OTLP receiver, so every name and every request path below was observed, not read. It is the second post in a pair; the first is the Spring Boot 4.2 upgrade checklist.
Versions this was written and run against. Spring Boot 4.2.0-RC1 (a release candidate, not GA) and 4.1.2 (the latest GA). On RC1: Spring Framework 7.1.0-RC1, Micrometer 1.18.0-RC1, Micrometer Tracing 1.8.0-RC1, OpenTelemetry Java 1.67.0. On 4.1.2: Framework 7.0.10, Micrometer 1.17.2, OpenTelemetry Java 1.62.0. JDK 25.0.5 (Temurin). 4.2.0-RC1 was on Maven Central on 22 October 2026; 4.2.0 GA was not, and I found no published GA date.
One result on RC1 may be fixed before GA (section 2): the value the reference documentation gives for the conventions switch does not start the application. Re-run the module when 4.2.0 ships.
What “conventions” decides: the names a dashboard queries
Spring Boot records one observation for each HTTP request it serves. An observation is a neutral description of something that happened: this request, this route, this status, this long. Two handlers then turn it into data: one produces a timer meter (the number behind a latency graph) and one produces a span (one box in a trace). What those two outputs are called, and what their tags are called, is decided by a set of naming rules called semantic conventions.
Until 4.2 there was one answer: Micrometer’s own names, such as http.server.requests with tags method, status and uri. OpenTelemetry has its own, published rules for the same request, and 4.2 adds a switch to use them: management.observations.conventions, default micrometer, with open-telemetry as the alternative.
The figure comes from one request to the application’s single endpoint, OtelApplication.java, read back from the meter registry and from the bytes the trace exporter sent. The test is ConventionsTest.java. The meter lines below are verbatim from 01-conventions-metrics-and-spans.txt; the tag order is just sorted.
== management.observations.conventions=(property not set)
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
== management.observations.conventions=micrometer
http.server.requests [error=none, exception=none, method=GET, outcome=SUCCESS, status=200, uri=/hello/{name}]
== management.observations.conventions=open-telemetry
-- application FAILED to start
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.http.server.observation.ServerRequestObservationConvention' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
== management.observations.conventions=opentelemetry
http.server.request.duration [error.type=none, error=none, http.request.method=GET, http.response.status_code=200, http.route=/hello/{name}, outcome=SUCCESS, url.scheme=http]
Three things to read off that. The tag uri became http.route, method became http.request.method and status became http.response.status_code. The exception tag is gone. error and outcome survived, and error.type and url.scheme are new. The span name changed from http get /hello/{name} to GET /hello/{name}; that comes from strings read out of the OTLP trace payload, which are in the full transcript.
Notice the third block of that output, which is the subject of the next section.
The value in the documentation does not start on RC1
The 4.2 reference documentation says to set management.observations.conventions to open-telemetry. On 4.2.0-RC1 that exact value stops the application from starting, with a missing ServerRequestObservationConvention bean. A closely related spelling, opentelemetry, works. To see how far the problem goes, the test starts the application once per spelling and records what happens: ConventionSpellingsTest.java.
ConfigurableApplicationContext ctx = Apps.start("management.observations.conventions=" + value,
"management.otlp.metrics.export.enabled=false");
Apps.call(ctx, "/hello/x");
MeterRegistry registry = ctx.getBean(MeterRegistry.class);
Apps.awaitHttpMeter(registry);
result = "starts, server meter: " + registry.getMeters().stream()
.map(m -> m.getId().getName())
.filter(n -> n.startsWith("http.server.request") && !n.endsWith(".active"))
.distinct()
.sorted()
.toList();
ctx.close();
The result on 4.2.0-RC1, from 07-conventions-spellings.txt:
open-telemetry -> FAILS to start
open_telemetry -> starts, server meter: [http.server.request.duration]
OPEN_TELEMETRY -> starts, server meter: [http.server.request.duration]
opentelemetry -> starts, server meter: [http.server.request.duration]
openTelemetry -> starts, server meter: [http.server.request.duration]
micrometer -> starts, server meter: [http.server.requests]
MICROMETER -> starts, server meter: [http.server.requests]
And on 4.1.2, where the property does not exist, from 07-conventions-spellings-on-4.1.2.txt:
open-telemetry -> starts, server meter: [http.server.requests]
open_telemetry -> starts, server meter: [http.server.requests]
OPEN_TELEMETRY -> starts, server meter: [http.server.requests]
opentelemetry -> starts, server meter: [http.server.requests]
openTelemetry -> starts, server meter: [http.server.requests]
micrometer -> starts, server meter: [http.server.requests]
MICROMETER -> starts, server meter: [http.server.requests]
Every spelling with the hyphen replaced by an underscore, run together, or in camel case starts and switches the meter. Only the hyphenated one fails. On 4.1.2 every spelling starts and every one keeps http.server.requests, because Boot ignores a property it does not know: setting the new property on an old service does nothing and tells you nothing.
What I know and what I do not. What I know: the documented spelling fails on 4.2.0-RC1, and the start-up error names the missing convention bean. I did not trace the cause in Boot’s source, and I could not search the Spring Boot issue tracker because GitHub blocks automated requests from my environment, so I cannot say whether it is already reported. This test is pinned to RC1 on purpose: when a later build changes the result, the test fails and this section is the thing to rewrite. Until GA, treat the switch as something to try on a branch, and do not copy the working spelling into shared configuration.
Going deeper: how the test copes with a race in the server observation
The first version of this test printed an empty meter list for several spellings that clearly worked. The cause is in the test, not in Boot: the server observation stops after the response bytes are on the wire, so a client that reads the registry immediately can win the race and see nothing. The test now polls for the timer before reading it, in Apps.java:
static void awaitHttpMeter(MeterRegistry registry) throws InterruptedException {
for (int i = 0; i < 100; i++) {
if (registry.getMeters().stream().anyMatch(m -> m.getId().getName().startsWith("http.server.request")
&& !m.getId().getName().endsWith(".active"))) {
return;
}
Thread.sleep(50);
}
}The polling condition also excludes the .active long task timer, because on 4.1.2 that meter appears at the start of the request and would end the wait too early. That second bug cost one more run: the 4.1.2 transcript was missing http.server.requests until it was fixed.
What changes in dashboards and alerts
This is the part that costs time. Once the meter is renamed, every query, panel and alert that mentions the old names needs to change, and the Prometheus side adds its own suffixes. The test scrapes /actuator/prometheus after one request under each convention and lists the http_server* names: PrometheusNamesTest.java.
| Micrometer conventions (default) | OpenTelemetry conventions | |
|---|---|---|
| Meter | http.server.requests | http.server.request.duration |
| Prometheus name | http_server_requests_seconds_count | http_server_request_duration_seconds_count |
| Method | method | http.request.method (http_request_method) |
| Status | status | http.response.status_code (http_response_status_code) |
| Route | uri | http.route (http_route) |
| Exception name | exception | no tag; error.type is new |
| Kept | error, outcome | error, outcome |
| New | url.scheme | |
| Span name | http get /hello/{name} | GET /hello/{name} |
The Prometheus-side names in the table are from 06-prometheus-names.txt. Here is what that does to a typical error-rate query. These two are written from the names above; I did not run them against a Prometheus server, so test them against yours:
| Query | |
|---|---|
| Before | sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) |
| After | sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m])) |
One change is easy to miss because it is not in the table: the reference documentation says only conventions marked stable are used and that properties which customise meter names no longer take effect; I read that and did not run it.
Going deeper: the full Prometheus listing, both versions
From 06-prometheus-names.txt on 4.2.0-RC1:
== management.observations.conventions=micrometer
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_seconds_count{error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/hello/{name}"}
== management.observations.conventions=opentelemetry
http_server_request_duration_seconds_count
http_server_request_duration_seconds_max
http_server_request_duration_seconds_sum
sample: http_server_request_duration_seconds_count{error="none",error_type="none",http_request_method="GET",http_response_status_code="200",http_route="/hello/{name}",outcome="SUCCESS",url_scheme="http"}And from 06-prometheus-names-on-4.1.2.txt on 4.1.2, where the switch is ignored in both blocks, so both show the Micrometer names:
== management.observations.conventions=micrometer
http_server_requests_active_seconds_count
http_server_requests_active_seconds_max
http_server_requests_active_seconds_sum
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_active_seconds_count{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN"}
== management.observations.conventions=opentelemetry
http_server_requests_active_seconds_count
http_server_requests_active_seconds_max
http_server_requests_active_seconds_sum
http_server_requests_seconds_count
http_server_requests_seconds_max
http_server_requests_seconds_sum
sample: http_server_requests_active_seconds_count{exception="none",method="GET",outcome="SUCCESS",status="200",uri="UNKNOWN"}The sample: line shows the first _count series found, which on 4.1.2 is the .active long task timer; that is why the section on that timer follows.
One OTLP endpoint for traces, metrics and logs
OTLP is the protocol OpenTelemetry uses to ship telemetry to a backend such as a collector, Tempo or Grafana. Until 4.2 you configured an endpoint for traces, another for metrics and another for logs, each with its own headers. 4.2 adds one shared set, management.opentelemetry.otlp.endpoint, .headers and .compression, which each signal uses unless it has its own.
The figure is observed behaviour, not documentation. The local receiver, OtlpSink.java, accepts any POST, un-gzips it and remembers the path and headers. The test points one property at it, in SharedOtlpTest.java:
ConfigurableApplicationContext ctx = Apps.start(
"management.opentelemetry.otlp.endpoint=" + sink.baseUrl(),
"management.opentelemetry.otlp.headers.Authorization=Bearer shared-token",
"management.opentelemetry.otlp.compression=gzip",
"management.otlp.metrics.export.step=1s");
The metrics step is shortened to one second only so an export happens within the test’s wait; the default push interval is longer. What reached the receiver, from 02-shared-otlp-endpoint.txt:
management.opentelemetry.otlp.endpoint = <sink> (one property, no per-signal URLs)
management.opentelemetry.otlp.headers = Authorization: Bearer shared-token
management.opentelemetry.otlp.compression = gzip
requests that reached the sink:
POST /v1/metrics Authorization=Bearer shared-token Content-Encoding=gzip
POST /v1/traces Authorization=Bearer shared-token Content-Encoding=gzip
One property produced two requests, each to the right path, each carrying the shared header and gzip. On 4.1.2 the same configuration reached the receiver zero times, because the shared keys are unknown there and each exporter used its default localhost:4318 (02-shared-otlp-endpoint-on-4.1.2.txt).
The second test sets both a shared configuration and a signal-specific tracing endpoint and header, to check precedence, in 03-signal-specific-overrides-shared.txt:
shared endpoint = <sink>
tracing.export.otlp.endpoint = <sink>/custom/spans
shared header Authorization = Bearer shared-token
shared header X-Shared = shared-only
tracing.export.otlp.headers.Authorization = Bearer traces-token
requests that reached the sink:
POST /custom/spans Authorization=Bearer traces-token X-Shared=shared-only
POST /v1/metrics Authorization=Bearer shared-token X-Shared=shared-only
The traces went to /custom/spans, exactly as written, with no /v1/traces appended, and with the tracing-specific Authorization. The shared extra header X-Shared was still sent, so the headers are merged, with the signal-specific value winning for a key both define. Metrics, which had no override, used the shared endpoint and the shared Authorization.
Going deeper: environment variable equivalents and what Boot does not export (read, not run)
From the 4.2 reference page; I did not run these.
OTEL_EXPORTER_OTLP_ENDPOINT,OTEL_EXPORTER_OTLP_COMPRESSIONandOTEL_EXPORTER_OTLP_HEADERSmap to the shared properties; signal-specific variables such asOTEL_EXPORTER_OTLP_TRACES_ENDPOINTtake precedence.- The mapping can be switched off with
management.opentelemetry.map-environment-variables=false. - The documented path rule: when the shared endpoint is used and the signal speaks HTTP,
v1/traces,v1/logsorv1/metricsis appended. I observed traces and metrics; I did not observe logs. - The reference says Boot does not export OpenTelemetry metrics or logs automatically, and exports traces automatically only together with Micrometer Tracing. Logging to OTLP needs a log bridge you add yourself.
A wider look at monitoring Spring Boot with Prometheus and Grafana is A Practical Guide to Monitoring Spring Boot Microservices.
Going deeper: the 60-line receiver that makes these claims checkable
A real collector would also work, but a receiver that records what it was sent lets a test assert on paths and headers. The handler, from OtlpSink.java:
server.createContext("/", exchange -> {
byte[] raw = exchange.getRequestBody().readAllBytes();
String enc = exchange.getRequestHeaders().getFirst("Content-Encoding");
byte[] body = raw;
if ("gzip".equalsIgnoreCase(enc)) {
try (InputStream in = new GZIPInputStream(new java.io.ByteArrayInputStream(raw))) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
in.transferTo(bos);
body = bos.toByteArray();
}
}
hits.add(new Hit(exchange.getRequestURI().getPath(), exchange.getRequestHeaders().getFirst("Authorization"),
exchange.getRequestHeaders().getFirst("X-Shared"), enc, exchange.getRequestHeaders().getFirst("Content-Type"), body));
exchange.getResponseHeaders().add("Content-Type", "application/x-protobuf");
exchange.sendResponseHeaders(200, -1);
exchange.close();
});
server.start();It also pulls printable runs of at least six characters out of the protobuf bytes, which is how the span names in the first transcript were read. That is a crude technique: a printable byte after a string sometimes sticks to it, which is why the raw transcript shows names such as GET /hello/{name}0. The names in this post are the strings without that trailing byte.
Renaming the trace id and span id in your logs
Logging frameworks can carry per-thread context in a map called the MDC. When tracing is on, Boot puts the current trace id and span id into it, under the keys traceId and spanId, and the default log pattern prints them so a log line can be matched to a trace. 4.2 lets you rename those keys with management.tracing.mdc.trace-id-key and management.tracing.mdc.span-id-key.
To see which key was really filled, the test sets both new keys and a log pattern that reads both the default and the custom key, then logs one line from a request: MdcAndLongTaskTimerTest.java.
ConfigurableApplicationContext ctx = Apps.start("management.otlp.metrics.export.enabled=false",
"management.tracing.mdc.trace-id-key=trace_id", "management.tracing.mdc.span-id-key=span_id",
"logging.pattern.correlation=default[%X{traceId:-}] custom[%X{trace_id:-}] ");
On 4.2.0-RC1, from 04-mdc-keys.txt (the 32-character trace id is replaced with a placeholder in the file):
management.tracing.mdc.trace-id-key = trace_id
management.tracing.mdc.span-id-key = span_id
log pattern correlation part reads both MDC keys
log line: INFO 3810 --- [boot42-otel] [o-auto-1-exec-1] default[] custom[<32-hex-trace-id>] c.a.o.OtelApplication$HelloController : handling hello for ankur
default key traceId populated: false
custom key trace_id populated: true
The custom key is filled and the default key is empty, so the rename works. On 4.1.2 the picture inverts (04-mdc-keys-on-4.1.2.txt): the default key is filled and the custom one is empty, because the new property is ignored.
If you rename the keys, rename everything that reads them. Log shippers, parsers and saved searches that look fortraceIdfind nothing once the key istrace_id. I ran only the OpenTelemetry bridge (not Brave), and I overrode the log pattern in the test to see both keys at once; I did not check what the default correlation pattern prints after a rename.
The active long task timer is off by default
A long task timer counts requests that are still running, as opposed to ones that have finished. Boot 4.1 created one for HTTP requests, http.server.requests.active, without being asked. In 4.2 it is not created unless you ask. The second test method in the same class measures it with and without management.metrics.observations.include-active-long-task-timer=true:
ConfigurableApplicationContext ctx = setting.startsWith("(")
? Apps.start("management.otlp.metrics.export.enabled=false")
: Apps.start("management.otlp.metrics.export.enabled=false", setting);
On 4.2.0-RC1, from 05-active-long-task-timer.txt:
== (default)
http.server.requests (TIMER)
http.server.requests.active present: false
== management.metrics.observations.include-active-long-task-timer=true
http.server.requests (TIMER)
http.server.requests.active (LONG_TASK_TIMER)
http.server.requests.active present: true
On 4.1.2 the first block already contains http.server.requests.active (05-active-long-task-timer-on-4.1.2.txt), and in the Prometheus listing above it shows up as http_server_requests_active_seconds_*. Any panel on those series, such as a “requests in flight” graph, goes empty on 4.2 until you set the property. The old management.metrics.observations.ignored-meters property was removed in the same release, and as the first post showed, the properties migrator does not report it.
A rollout order that keeps you able to find the cause
- Upgrade Boot first, with the default conventions, and leave
management.observations.conventionsalone. Then the only meter change you should expect is the long task timer. - On a non-production copy, scrape
/actuator/prometheusbefore and after, asPrometheusNamesTest.javadoes, and diff the names. That diff is your dashboard worklist. - Set
include-active-long-task-timer=trueif you have panels on the active series, or rewrite them. - Move trace exporters to the shared
management.opentelemetry.otlp.*settings once every service reading that configuration is on 4.2. Until then, services on 4.1 ignore it and fall back to their defaults. - Rename the MDC keys only if something needs them renamed, and update the log queries in the same change.
- Switch conventions last, on its own deploy, after GA, with every query rewritten and tested.
Should you switch to the OpenTelemetry conventions at all? Only if you have a reason. The default does not change, so doing nothing is safe. The reasons to switch are real: your backend or your other services already use the OpenTelemetry names, and you want one set of queries. The costs are also real: only conventions marked stable apply, the properties that rename meters stop working, every dashboard and alert on HTTP metrics needs rewriting, and on 4.2.0-RC1 the documented value does not start. The shared OTLP settings and the MDC keys are low-risk by comparison. The conventions switch is not.
Further reading
- The companion module: boot42-otel (transcripts in
output/, regenerated byscripts/run-all.sh). - On this site: Spring Framework 7.1 and Spring Boot 4.2: What’s Coming and How to Prepare, A Practical Guide to Monitoring Spring Boot Microservices and Spring Boot versions.
- Spring Boot 4.2 reference: Observability, the source for the documented property names and the OTLP precedence rules.
- Spring Boot 4.2.0-M2 release notes and the M1 to M2 configuration changelog.
No Comments yet!