BugCI/CD

OpenTelemetry Java SDK: Spans Report isRecording() == true When No TracerProvider Is Configured, Causing Unnecessary Resource Consumption

When OpenTelemetrySdk.builder() is built without an explicit TracerProvider — or with setTracerProvider(null) — the resulting SDK does not fall back to a true no-op tracer.

Rootlock SRE Engine 6 min read
Diagnostic brief

At a Glance

When OpenTelemetrySdk.builder() is built without an explicit TracerProvider — or with setTracerProvider(null) — the resulting SDK does not fall back to a true no-op tracer.

Severity Not rated
Confidence High
Frequency Unknown
Impact See analysis

Summary

When OpenTelemetrySdk.builder() is built without an explicit TracerProvider — or with setTracerProvider(null) — the resulting SDK does not fall back to a true no-op tracer. Instead, spans created by that SDK report isRecording() == true, causing all span attribute construction code guarded by if (!span.isRecording()) to execute and waste CPU and memory on data that will never be exported. The issue affects opentelemetry-sdk-trace:1.62.0 and manifests in any deployment that intentionally omits tracing while retaining other signals such as logging or metrics.

Root-Cause Analysis

Confirmed evidence:

  • Calling OpenTelemetrySdk.builder().build() and OpenTelemetrySdk.builder().setTracerProvider(null).build() are equivalent: both produce an OpenTelemetrySdk instance that does not use GlobalOpenTelemetry.noop() or the API-level no-op TracerProvider.
  • The SDK builder, when no SdkTracerProvider is set, defaults to an internal provider that does not disable span recording. The SdkTracer it produces starts spans with isRecording() == true.
  • The failing assertion in the reproduction case confirms that the SDK-backed span is fully live even though no SpanExporter or SpanProcessor has been registered.

Reasonable inference:

  • The SdkTracerProvider default configuration includes a sampler (likely ParentBased(AlwaysOn)) and an empty processor pipeline. With AlwaysOn sampling, SdkReadWriteSpan marks itself as recording even though the processor list is empty and no exporter will ever consume the span data.
  • The root cause is a design gap: the SDK builder treats an absent TracerProvider as "use a default SDK provider" rather than "use the API no-op provider." This violates the principle of least surprise for operators who configure only a subset of signals.

What additional evidence would fully confirm the internal sampler:

  • Inspecting the SdkTracerProvider constructed by the builder (e.g., via reflection or a debug log) to read the active Sampler and SpanProcessor list would confirm whether AlwaysOnSampler is the culprit.

Alternative cause:

  • It is also possible that setTracerProvider(null) is silently ignored (i.e., treated as "argument not supplied") rather than mapped to a no-op provider, meaning null does not propagate through to disable the default. Both interpretations produce the same observable behavior.

Resolution Steps

Apply the most appropriate workaround for your situation in order of preference:

If you have neither tracing nor any other signal configured, use the global no-op singleton directly instead of constructing an OpenTelemetrySdk:

  1. Use the API-level no-op OpenTelemetry when no signals are needed at all.
   OpenTelemetry openTelemetry = OpenTelemetry.noop();

Spans created from this instance are guaranteed no-ops and isRecording() returns false.

Build, then immediately shut down, the SdkTracerProvider before passing it to the SDK builder. A shut-down provider causes all subsequently started spans to report isRecording() == false:

  1. Explicitly provide a shut-down SdkTracerProvider when you need SDK features for other signals but not tracing.
   SdkTracerProvider noopTracerProvider = SdkTracerProvider.builder().build();
   noopTracerProvider.close(); // transitions the provider to shut-down state

   OpenTelemetry openTelemetry = OpenTelemetrySdk.builder()
       .setTracerProvider(noopTracerProvider)
       // .setMeterProvider(yourMeterProvider)
       // .setLoggerProvider(yourLoggerProvider)
       .build();

This is the most reliable workaround when you must compose an OpenTelemetrySdk with real providers for other signals.

An AlwaysOff sampler prevents span recording without shutting down the provider. However, note that SdkReadWriteSpan initialization code still executes before the sampler decision is applied, so this is slightly less efficient than approach 2 but avoids the shutdown semantics:

  1. Use AlwaysOff sampler as a lighter alternative if provider shutdown side-effects are a concern.
   SdkTracerProvider noopTracerProvider = SdkTracerProvider.builder()
       .setSampler(Sampler.alwaysOff())
       .build();

   OpenTelemetry openTelemetry = OpenTelemetrySdk.builder()
       .setTracerProvider(noopTracerProvider)
       .build();
  1. Track the upstream SDK fix. This is a confirmed bug (SDK issue #8740). Monitor the opentelemetry-java release notes for a patch where OpenTelemetrySdk.builder().build() (or setTracerProvider(null)) correctly falls back to the API no-op tracer, causing isRecording() to return false.

CLI Commands

Confirm the artifact version in use before applying a fix:

# Maven
mvn dependency:tree -Dincludes=io.opentelemetry:opentelemetry-sdk-trace

# Gradle
./gradlew dependencies --configuration runtimeClasspath | grep opentelemetry-sdk-trace

Configuration Snippets

Spring Boot / autoconfiguration — disable tracing signal entirely:

If you use opentelemetry-spring-boot-starter, you can disable trace export without touching Java code:

otel:
  traces:
    exporter: none

This instructs the autoconfiguration to install a no-op exporter, but verify whether isRecording() still returns true in your version; the SDK-level bug may still apply depending on how the starter initializes the provider.

Maven — pin to a patched version once available:

<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-sdk-trace</artifactId>
  <version><PATCHED_VERSION></version>
</dependency>

Gradle:

implementation("io.opentelemetry:opentelemetry-sdk-trace:<PATCHED_VERSION>")

Verification

After applying the workaround, verify that spans are truly no-ops:

OpenTelemetry openTelemetry = /* your chosen workaround */;

Tracer tracer = openTelemetry.getTracer("verification-test");
Span span = tracer.spanBuilder("verify-noop").startSpan();

assert !span.isRecording() : "Span must NOT be recording when tracing is disabled";
span.end();

Expected behavior:

  • span.isRecording() returns false.
  • span.getClass().getSimpleName() is either "PropagatedSpan", "NoopSpan", or otherwise indicates a non-recording implementation — not "SdkReadWriteSpan".
  • Any code block guarded by if (span.isRecording()) { ... } is skipped entirely.

Rollback indicator: If span.isRecording() still returns true after applying the workaround, the provider is not in the expected state. Re-check that noopTracerProvider.close() was called before passing the provider to the builder, or switch to OpenTelemetry.noop() if no other signals require the SDK.

Prevention

  1. Integration test for recording state. Add a test to your CI pipeline that asserts span.isRecording() == false whenever tracing is intentionally disabled in your service configuration. Catch regressions before they reach production:
   @Test
   void tracingDisabledSpansMustBeNoop() {
       OpenTelemetry ot = buildOpenTelemetryWithTracingDisabled();
       Span span = ot.getTracer("ci-guard").spanBuilder("guard").startSpan();
       assertThat(span.isRecording()).isFalse();
   }
  1. Encapsulate SDK construction. Create a single factory method or Spring @Bean responsible for building OpenTelemetry. This prevents ad-hoc OpenTelemetrySdk.builder().build() calls scattered across the codebase that may silently enable recording:
   public static OpenTelemetry buildOpenTelemetry(boolean tracingEnabled, ...) {
       if (!tracingEnabled) {
           return OpenTelemetry.noop();
       }
       // ... configure real providers
   }
  1. Validate provider configuration at startup. Log or expose a health/info endpoint that reports whether the active TracerProvider is a no-op or a live SDK provider. Unexpected live providers in non-tracing environments surface immediately.
  1. Pin SDK versions in a BOM and watch release notes. Use io.opentelemetry:opentelemetry-bom to keep all OpenTelemetry artifacts version-aligned, and subscribe to release notes to detect when the upstream fix lands:
   <dependencyManagement>
     <dependencies>
       <dependency>
         <groupId>io.opentelemetry</groupId>
         <artifactId>opentelemetry-bom</artifactId>
         <version><CURRENT_VERSION></version>
         <type>pom</type>
         <scope>import</scope>
       </dependency>
     </dependencies>
   </dependencyManagement>
  1. Monitor CPU overhead from span construction. Add a JVM profiling stage to your performance CI (e.g., async-profiler) that flags unexpected hot paths inside io.opentelemetry.sdk.trace when tracing is supposed to be disabled. This acts as a safety net even if the isRecording() assertion is not caught.
Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.