The Voyage of a Small Environment Variable

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 --> OUT

The 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["&dollar;{VAR:default}<br/>&dollar;{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["&dollar;{VAR:-default}<br/>&dollar;{VAR}<br/>&dollar;{env:VAR:-default}<br/>&dollar;{sys:property:-default}"] --> A2[SDK resolver]
    A2 --> A3["reads:<br/>env vars + system properties"]
  end
  STARTER --> MODEL[same SDK model]
  AGENT --> MODEL

That 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/>+ &dollar;{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 into application.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 right distribution.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 see NoClassDefFoundError: io/opentelemetry/common/ComponentLoader at startup.
  • Durations are milliseconds, as numbers. Use 5000, not 5s.
  • Programmatic customization changes shape. AutoConfigurationCustomizerProvider is replaced by DeclarativeConfigurationCustomizerProvider; SDK components plug in via the ComponentProvider API. 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.