The OpenTelemetry
Spring Boot starter gained
declarative-configuration support starting in version 2.26.0 — the same YAML
schema the Java agent introduced in late 2025,
now embedded inside application.yaml. This post traces what one env var,
OTEL_SERVICE_NAME=petclinic, does in that new world, and where the seams are.
For years, environment variables (and their JVM -D cousins) were the only way
to configure the OpenTelemetry SDK: every exporter, every sampler, every
captured header, expressed as a flat list of OTEL_* variables.
Since version 2.26.0 of the OpenTelemetry Spring Boot starter, that list has a new sibling. The SDK declarative-configuration schema is a YAML tree that can describe an entire telemetry pipeline (every processor, every exporter, every nested option) in the same shape the SDK actually runs.
For things the env var could not say, Spring starter users needed to write a
@Bean. Java agent users had to write a full
extension and package it in a separate
jar to ship alongside the agent — which can be prohibitive.
With these new changes, the schema moves into your application.yaml, under a
single otel: key. Env vars still work, but in a narrower role:
OTEL_SERVICE_NAME lands as a resource attribute because a service-name
detector reads it at boot, and any ${VAR:default} placeholder you write into
the YAML pulls one in by name. Otherwise, the YAML is the source of truth.
A YAML file inside your YAML file
otel:
file_format: '1.0'
resource:
attributes:
- name: service.name
value: petclinic
tracer_provider:
processors:
- batch:
exporter:
otlp_http:
endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:http://localhost:4318/v1/traces}
The block under otel: is the OpenTelemetry SDK schema (list of processors,
each holding an exporter, each holding configuration) inside Spring’s
application.yaml. The presence of otel.file_format is the switch. Everything
beneath it is parsed against the SDK schema. Spring does not need to know what
any of it means.
The wall env vars alone could not climb
Env vars cover a fixed list of built-in choices: a
fixed set of samplers
via OTEL_TRACES_SAMPLER, the standard OTLP exporters via
OTEL_EXPORTER_OTLP_*, and the usual signal-toggle flags. Anything outside that
catalog (a custom rule-based sampler, a second OTLP exporter on a debug
pipeline, a baggage processor, any nested option the SDK exposes) was outside
the env-var model. Declarative config unlocks the rest of the tree.
The docs page for the starter has a small example most teams need on day one:
exclude actuator endpoints from tracing.
Previously, that was handled via a @Configuration class:
@Configuration
public class FilterPaths {
@Bean
public AutoConfigurationCustomizerProvider otelCustomizer() {
return p ->
p.addSamplerCustomizer(
(fallback, config) ->
RuleBasedRoutingSampler.builder(SpanKind.SERVER, fallback)
.drop(UrlAttributes.URL_PATH, "^/actuator")
.build());
}
}
Today it is a YAML block in application.yaml:
otel:
tracer_provider:
sampler:
parent_based:
root:
rule_based_routing:
fallback_sampler:
always_on:
span_kind: SERVER
rules:
- action: DROP
attribute: url.path
pattern: /actuator.*
Both versions run the same Java code — the agent and the starter already bundle
the opentelemetry-samplers contrib jar. What changes is who writes the wiring.
The rest of this post follows OTEL_SERVICE_NAME through three stages on its
way into the SDK.
Stage one: arriving at Spring’s property stack
Spring’s property loader stacks every source the app sees — application.yaml,
every active profile’s overlay, the JVM -D flags, the --key=value
command-line args, environment variables — into a single addressable property
universe. OTEL_SERVICE_NAME lives in that stack alongside SERVER_PORT and
SPRING_PROFILES_ACTIVE. Spring does not know which of these belong to
OpenTelemetry; that is the starter’s job, at the end of the line.
flowchart LR
S["Spring resolves<br/>all properties"] --> W["starter walks otel.* keys"]
W --> SEAM{"key sits under<br/>a list index?"}
SEAM -- no --> OUT["un-flatten the whole map<br/>→ Jackson (once)<br/>→ SDK model"]
SEAM -- yes --> RE["recompute env-var name<br/>re-read environment"]
RE --> OUTThe starter walks every property Spring exposes, picks out the otel.* keys,
and hands the assembled map to Jackson — once, for the whole tree, not per
element. The diamond is the seam this post is about: an extra step for keys that
sit under a list index, which the next stage explains.
Stage two: the env var Spring almost lost
Most otel.* env vars travel light. This one does not:
OTEL_TRACER_PROVIDER_PROCESSORS_0_BATCH_EXPORTER_OTLP_HTTP_ENDPOINT=http://collector:4318/v1/traces
It gets through, but only because sixteen lines deep inside the starter we go hunting for it by name. The diamond in the diagram above is where they live.
Stage three: two substituters, one syntax
Both application.yaml and the SDK’s standalone YAML use ${...} placeholders.
They mean almost, but not quite, the same thing. Spring will happily resolve a
chained fallback like
${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4318}}/v1/traces,
chasing the outer placeholder into the inner so you can prefer a signal-specific
override, fall back to a general one, and ultimately to a literal. The SDK’s
substituter is a single non-recursive regular-expression pass; the same
expression in otel-config.yaml would not parse.
flowchart LR
subgraph STARTER["application.yaml — starter"]
direction TB
S1["${VAR:default}<br/>${VAR}"] --> S2[Spring resolver]
S2 --> S3["reads:<br/>env, sys props, args,<br/>profiles, all yaml"]
end
subgraph AGENT["otel-config.yaml — agent"]
direction TB
A1["${VAR:-default}<br/>${VAR}<br/>${env:VAR:-default}<br/>${sys:property:-default}"] --> A2[SDK resolver]
A2 --> A3["reads:<br/>env vars + system properties"]
end
STARTER --> MODEL[same SDK model]
AGENT --> MODELThat is also why all the Spring-native config tricks (profiles, command-line
--key=value, @Value-style externalization, even external config servers)
work transparently for OTel config. The starter never implemented any of them.
Spring’s resolver did, and the starter just reads properties.
The biggest practical consequence: in the starter, any env var named on the same
canonical key path as a YAML leaf overrides it automatically — no extra wiring.
The agent’s standalone YAML cannot do that. There, an env-var override has to be
wired into the YAML as a ${VAR} placeholder ahead of time, or it does nothing.
Arrival: the resolved tree the SDK actually boots from
By the time the SDK boots, every otel.* value has been resolved, relaxed,
normalized, and joined with every other into a single flat map. The starter
un-flattens that map back into the tree the SDK expects, hands it to Jackson,
and Jackson produces the OpenTelemetryConfigurationModel the SDK boots from.
Whatever wrote which value — yaml, env var, profile overlay, command-line arg —
the SDK only ever sees the resolved result.
flowchart TD
A[application.yaml] --> S
B[application-prod.yaml] --> S
C[env vars] --> S
D[JVM system properties] --> S
E[command-line args] --> S
S["Spring property loader<br/>+ ${VAR} resolution"] --> F["flat property map<br/>otel.foo.bar[0].baz = ..."]
F --> X["starter: EmbeddedConfigFile<br/>walks otel.* keys, un-flattens"]
X --> J[Jackson → OpenTelemetryConfigurationModel]
J --> SDK[SDK runtime]Spring owns the front door. The SDK never sees a raw ${VAR}, a profile name,
or a property file — only a fully-resolved tree, handed over once at boot.
Why “experimental” is the best reason to try declarative config now
Declarative configuration is the schema OpenTelemetry is converging on across every language. It is not finished. The Spring Boot starter’s support for it is marked experimental, exactly because it has not seen enough real applications yet to know which corners to tighten.
That is not a warning. It is an invitation. Now, before the schema freezes, is
the highest-leverage moment to put declarative config into a real
application.yaml and see what breaks. Your friction is what shapes the schema
that lands.
Getting there in 60 seconds
Two starting points, both already there:
- You already have an
application.properties? Paste it into the interactive converter on the doc page. Out comes the YAML, ready to drop intoapplication.yaml. - Greenfield? The
OpenTelemetry Ecosystem Explorer
generates declarative-config YAML interactively: pick exporters, samplers,
instrumentations, and copy the result. A new Spring Boot starter target mode
wraps the output under
otel:and uses the rightdistribution.spring_starter.*keys.
The fine print
- Dependency management is required on Spring Boot 3.5+. Spring Boot 3.5
ships its own OpenTelemetry version pin that conflicts with what the starter
needs. Import the OTel instrumentation BOM in
dependencyManagement(see the docs). Skip it and you will seeNoClassDefFoundError: io/opentelemetry/common/ComponentLoaderat startup. - Durations are milliseconds, as numbers. Use
5000, not5s. - Programmatic customization changes shape.
AutoConfigurationCustomizerProvideris replaced byDeclarativeConfigurationCustomizerProvider; SDK components plug in via theComponentProviderAPI. The agent extension API docs apply to the starter unchanged.
If you migrate a real application onto this and hit something off, please file an issue: opentelemetry-java-instrumentation for code, opentelemetry.io for docs.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.