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.
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.
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()andOpenTelemetrySdk.builder().setTracerProvider(null).build()are equivalent: both produce anOpenTelemetrySdkinstance that does not useGlobalOpenTelemetry.noop()or the API-level no-opTracerProvider. - The SDK builder, when no
SdkTracerProvideris set, defaults to an internal provider that does not disable span recording. TheSdkTracerit produces starts spans withisRecording() == true. - The failing assertion in the reproduction case confirms that the SDK-backed span is fully live even though no
SpanExporterorSpanProcessorhas been registered.
Reasonable inference:
- The
SdkTracerProviderdefault configuration includes a sampler (likelyParentBased(AlwaysOn)) and an empty processor pipeline. WithAlwaysOnsampling,SdkReadWriteSpanmarks 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
TracerProvideras "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
SdkTracerProviderconstructed by the builder (e.g., via reflection or a debug log) to read the activeSamplerandSpanProcessorlist would confirm whetherAlwaysOnSampleris 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:
- Use the API-level no-op
OpenTelemetrywhen 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:
- Explicitly provide a shut-down
SdkTracerProviderwhen 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:
- Use
AlwaysOffsampler 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();- Track the upstream SDK fix. This is a confirmed bug (SDK issue #8740). Monitor the
opentelemetry-javarelease notes for a patch whereOpenTelemetrySdk.builder().build()(orsetTracerProvider(null)) correctly falls back to the API no-op tracer, causingisRecording()to returnfalse.
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-traceConfiguration 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: noneThis 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()returnsfalse.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
- Integration test for recording state. Add a test to your CI pipeline that asserts
span.isRecording() == falsewhenever 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();
}- Encapsulate SDK construction. Create a single factory method or Spring
@Beanresponsible for buildingOpenTelemetry. This prevents ad-hocOpenTelemetrySdk.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
}- Validate provider configuration at startup. Log or expose a health/info endpoint that reports whether the active
TracerProvideris a no-op or a live SDK provider. Unexpected live providers in non-tracing environments surface immediately.
- Pin SDK versions in a BOM and watch release notes. Use
io.opentelemetry:opentelemetry-bomto 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>- 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.tracewhen tracing is supposed to be disabled. This acts as a safety net even if theisRecording()assertion is not caught.