Skip to main content

Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys (Tested on RC1)

What Spring Boot 4.2 changes about OpenTelemetry, run on 4.2.0-RC1 against 4.1.2: meter, tag and span names under the new conventions switch (the documented value fails to start on RC1), one shared OTLP endpoint checked against a local receiver, renamable MDC keys, the long task timer that is off by default, and what to change in dashboards.

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.

One HTTP requestGET /hello/ankurOne observationrecorded by BootMicrometer conventions (the default)meter: http.server.requeststags: method, status, uri, exception, error, outcomespan: http get /hello/{name}OpenTelemetry conventions (stable ones only)meter: http.server.request.durationtags: http.request.method, http.response.status_code,http.route, url.scheme, error.type, error, outcomespan: GET /hello/{name}Same request, same code. The names a query asks for are different, so this switch is a dashboard change before it is a code change.

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
Meterhttp.server.requestshttp.server.request.duration
Prometheus namehttp_server_requests_seconds_counthttp_server_request_duration_seconds_count
Methodmethodhttp.request.method (http_request_method)
Statusstatushttp.response.status_code (http_response_status_code)
Routeurihttp.route (http_route)
Exception nameexceptionno tag; error.type is new
Kepterror, outcomeerror, outcome
Newurl.scheme
Span namehttp 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
Beforesum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
Aftersum(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.

management.opentelemetry.otlpendpoint = <base URL>headers.Authorization = Bearer …compression = gzipPOST /v1/traces (observed)POST /v1/metrics (observed)logs: Boot does not export them for youtracing.export.otlp.endpoint= <base URL>/custom/spansPOST /custom/spans, used as writtenThe signal path (/v1/traces, /v1/metrics) is added only when the shared endpoint is the fallback. A per-signal URL wins and is not touched.

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_COMPRESSION and OTEL_EXPORTER_OTLP_HEADERS map to the shared properties; signal-specific variables such as OTEL_EXPORTER_OTLP_TRACES_ENDPOINT take 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/logs or v1/metrics is 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 for traceId find nothing once the key is trace_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

  1. Upgrade Boot first, with the default conventions, and leave management.observations.conventions alone. Then the only meter change you should expect is the long task timer.
  2. On a non-production copy, scrape /actuator/prometheus before and after, as PrometheusNamesTest.java does, and diff the names. That diff is your dashboard worklist.
  3. Set include-active-long-task-timer=true if you have panels on the active series, or rewrite them.
  4. 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.
  5. Rename the MDC keys only if something needs them renamed, and update the log queries in the same change.
  6. 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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.