diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/common/ContextAttributes.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/common/ContextAttributes.java new file mode 100644 index 00000000000..5b2b0291a57 --- /dev/null +++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/common/ContextAttributes.java @@ -0,0 +1,58 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.common; + +import static java.util.Objects.requireNonNull; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.ContextKey; + +/** + * Utility for associating {@link Attributes} with a {@link Context} for automatic propagation to + * telemetry signals produced within that context. + */ +public final class ContextAttributes { + + private static final ContextKey CONTEXT_ATTRIBUTES_KEY = + ContextKey.named("opentelemetry-context-attributes"); + + private ContextAttributes() {} + + /** + * Returns a new {@link Context} containing the merged attributes from the given {@code context} + * and {@code attributes}. If the same attribute key is present in both, the value from {@code + * attributes} takes precedence. + */ + public static Context add(Context context, Attributes attributes) { + requireNonNull(context, "context"); + if (attributes == null || attributes.isEmpty()) { + return context; + } + Attributes existing = context.get(CONTEXT_ATTRIBUTES_KEY); + if (existing == null) { + return context.with(CONTEXT_ATTRIBUTES_KEY, attributes); + } + Attributes merged = existing.toBuilder().putAll(attributes).build(); + return context.with(CONTEXT_ATTRIBUTES_KEY, merged); + } + + /** + * Returns the {@link Attributes} associated with the given {@code context}, or {@link + * Attributes#empty()} if none are present. + * + *

This method is intended primarily for telemetry pipeline components such as SDKs, samplers, + * processors, and exporters. Application code should treat context-scoped attributes as + * write-only via {@link #add(Context, Attributes)}. + */ + public static Attributes get(Context context) { + if (context == null) { + return Attributes.empty(); + } + Attributes attributes = context.get(CONTEXT_ATTRIBUTES_KEY); + return attributes != null ? attributes : Attributes.empty(); + } +} diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/common/ContextAttributesTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/common/ContextAttributesTest.java new file mode 100644 index 00000000000..a54a79db2a5 --- /dev/null +++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/common/ContextAttributesTest.java @@ -0,0 +1,82 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.common; + +import static io.opentelemetry.api.common.AttributeKey.longKey; +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import org.junit.jupiter.api.Test; + +class ContextAttributesTest { + + @Test + void emptyContext_returnsEmptyAttributes() { + assertThat(ContextAttributes.get(Context.root())).isEqualTo(Attributes.empty()); + assertThat(ContextAttributes.get(null)).isEqualTo(Attributes.empty()); + } + + @Test + void addNullOrEmptyAttributes_returnsSameContext() { + Context root = Context.root(); + assertThat(ContextAttributes.add(root, null)).isSameAs(root); + assertThat(ContextAttributes.add(root, Attributes.empty())).isSameAs(root); + assertThatThrownBy(() -> ContextAttributes.add(null, Attributes.of(stringKey("k"), "v"))) + .isInstanceOf(NullPointerException.class); + } + + @Test + void addAndGet_singleLayer() { + Attributes attrs = Attributes.of(stringKey("user.id"), "u123", longKey("retry"), 1L); + Context ctx = ContextAttributes.add(Context.root(), attrs); + + assertThat(ContextAttributes.get(ctx)).isEqualTo(attrs); + assertThat(ContextAttributes.get(Context.root())).isEqualTo(Attributes.empty()); + } + + @Test + void addAndGet_nestedContextsMergeAndOverride() { + Context outer = + ContextAttributes.add( + Context.root(), Attributes.of(stringKey("k1"), "v1", stringKey("k2"), "v2")); + Context inner = + ContextAttributes.add( + outer, Attributes.of(stringKey("k2"), "v2-override", stringKey("k3"), "v3")); + + assertThat(ContextAttributes.get(outer)) + .isEqualTo(Attributes.of(stringKey("k1"), "v1", stringKey("k2"), "v2")); + assertThat(ContextAttributes.get(inner)) + .isEqualTo( + Attributes.of( + stringKey("k1"), "v1", stringKey("k2"), "v2-override", stringKey("k3"), "v3")); + } + + @Test + void scopeAttachment_restoresOuterContextAttributesOnClose() { + Context outer = ContextAttributes.add(Context.root(), Attributes.of(stringKey("env"), "prod")); + try (Scope ignoredOuter = outer.makeCurrent()) { + assertThat(ContextAttributes.get(Context.current())) + .isEqualTo(Attributes.of(stringKey("env"), "prod")); + + Context inner = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("env"), "staging", stringKey("req"), "r1")); + try (Scope ignoredInner = inner.makeCurrent()) { + assertThat(ContextAttributes.get(Context.current())) + .isEqualTo(Attributes.of(stringKey("env"), "staging", stringKey("req"), "r1")); + } + + assertThat(ContextAttributes.get(Context.current())) + .isEqualTo(Attributes.of(stringKey("env"), "prod")); + } + assertThat(ContextAttributes.get(Context.current())).isEqualTo(Attributes.empty()); + } +} diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactory.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactory.java index 4cceee77991..78427b02846 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactory.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactory.java @@ -91,6 +91,12 @@ public SdkLoggerProviderBuilder create( SdkLoggerProviderUtil.setLoggerConfigurator(builder, configuratorBuilder.build()); } + Boolean contextScopedAttributes = + LoggerProviderModelAccessor.getContextScopedAttributes(loggerProviderModel); + if (contextScopedAttributes != null) { + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, contextScopedAttributes); + } + return builder; } diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java index 4eb8a061b01..edd45505d84 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactory.java @@ -103,6 +103,11 @@ public SdkMeterProviderBuilder create( ExemplarFilterFactory.getInstance().create(exemplarFilterModel, context)); } + Boolean contextScopedAttributes = MeterProviderModelAccessor.getContextScopedAttributes(model); + if (contextScopedAttributes != null) { + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, contextScopedAttributes); + } + return builder; } diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactory.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactory.java index 961ef5c893f..27a23306693 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactory.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactory.java @@ -99,6 +99,12 @@ public SdkTracerProviderBuilder create( SdkTracerProviderUtil.setTracerConfigurator(builder, configuratorBuilder.build()); } + Boolean contextScopedAttributes = + TracerProviderModelAccessor.getContextScopedAttributes(tracerProviderModel); + if (contextScopedAttributes != null) { + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, contextScopedAttributes); + } + return builder; } diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/LoggerProviderModelAccessor.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/LoggerProviderModelAccessor.java index fb1ab084ec8..07592eb6601 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/LoggerProviderModelAccessor.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/LoggerProviderModelAccessor.java @@ -24,12 +24,14 @@ public final class LoggerProviderModelAccessor { private LoggerProviderModelAccessor() {} static final String LOGGER_CONFIGURATOR = "logger_configurator/development"; + static final String CONTEXT_SCOPED_ATTRIBUTES = "context_scoped_attributes/development"; public static final Map> EXPERIMENTAL_PROPERTIES; static { EXPERIMENTAL_PROPERTIES = new HashMap<>(); EXPERIMENTAL_PROPERTIES.put(LOGGER_CONFIGURATOR, ExperimentalLoggerConfiguratorModel.class); + EXPERIMENTAL_PROPERTIES.put(CONTEXT_SCOPED_ATTRIBUTES, Boolean.class); } @Nullable @@ -47,4 +49,17 @@ public static LoggerProviderModel setLoggerConfigurator( model.setExtensionProperty(LOGGER_CONFIGURATOR, value); return model; } + + @Nullable + public static Boolean getContextScopedAttributes(LoggerProviderModel model) { + return ExtensionPropertyUtil.get( + CONTEXT_SCOPED_ATTRIBUTES, model.getExtensionProperties(), Boolean.class); + } + + public static LoggerProviderModel setContextScopedAttributes( + LoggerProviderModel model, Boolean value) { + requireNonNull(value, "value"); + model.setExtensionProperty(CONTEXT_SCOPED_ATTRIBUTES, value); + return model; + } } diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/MeterProviderModelAccessor.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/MeterProviderModelAccessor.java index 6a7dade4ea2..621475b4321 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/MeterProviderModelAccessor.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/MeterProviderModelAccessor.java @@ -25,6 +25,7 @@ private MeterProviderModelAccessor() {} static final String METER_CONFIGURATOR = "meter_configurator/development"; static final String VIEW_MATCHING_MODE = "view_matching_mode/development"; + static final String CONTEXT_SCOPED_ATTRIBUTES = "context_scoped_attributes/development"; public static final Map> EXPERIMENTAL_PROPERTIES; @@ -32,6 +33,7 @@ private MeterProviderModelAccessor() {} EXPERIMENTAL_PROPERTIES = new HashMap<>(); EXPERIMENTAL_PROPERTIES.put(METER_CONFIGURATOR, ExperimentalMeterConfiguratorModel.class); EXPERIMENTAL_PROPERTIES.put(VIEW_MATCHING_MODE, ExperimentalViewMatchingModeModel.class); + EXPERIMENTAL_PROPERTIES.put(CONTEXT_SCOPED_ATTRIBUTES, Boolean.class); } @Nullable @@ -63,4 +65,17 @@ public static MeterProviderModel setViewMatchingMode( model.setExtensionProperty(VIEW_MATCHING_MODE, value); return model; } + + @Nullable + public static Boolean getContextScopedAttributes(MeterProviderModel model) { + return ExtensionPropertyUtil.get( + CONTEXT_SCOPED_ATTRIBUTES, model.getExtensionProperties(), Boolean.class); + } + + public static MeterProviderModel setContextScopedAttributes( + MeterProviderModel model, Boolean value) { + requireNonNull(value, "value"); + model.setExtensionProperty(CONTEXT_SCOPED_ATTRIBUTES, value); + return model; + } } diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/TracerProviderModelAccessor.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/TracerProviderModelAccessor.java index 691213fcce5..5384604afb8 100644 --- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/TracerProviderModelAccessor.java +++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/TracerProviderModelAccessor.java @@ -24,12 +24,14 @@ public final class TracerProviderModelAccessor { private TracerProviderModelAccessor() {} static final String TRACER_CONFIGURATOR = "tracer_configurator/development"; + static final String CONTEXT_SCOPED_ATTRIBUTES = "context_scoped_attributes/development"; public static final Map> EXPERIMENTAL_PROPERTIES; static { EXPERIMENTAL_PROPERTIES = new HashMap<>(); EXPERIMENTAL_PROPERTIES.put(TRACER_CONFIGURATOR, ExperimentalTracerConfiguratorModel.class); + EXPERIMENTAL_PROPERTIES.put(CONTEXT_SCOPED_ATTRIBUTES, Boolean.class); } @Nullable @@ -47,4 +49,17 @@ public static TracerProviderModel setTracerConfigurator( model.setExtensionProperty(TRACER_CONFIGURATOR, value); return model; } + + @Nullable + public static Boolean getContextScopedAttributes(TracerProviderModel model) { + return ExtensionPropertyUtil.get( + CONTEXT_SCOPED_ATTRIBUTES, model.getExtensionProperties(), Boolean.class); + } + + public static TracerProviderModel setContextScopedAttributes( + TracerProviderModel model, Boolean value) { + requireNonNull(value, "value"); + model.setExtensionProperty(CONTEXT_SCOPED_ATTRIBUTES, value); + return model; + } } diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactoryTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactoryTest.java index 84c7fc2fcb7..878d9e3100b 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactoryTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/LoggerProviderFactoryTest.java @@ -5,11 +5,16 @@ package io.opentelemetry.sdk.autoconfigure.declarativeconfig; +import static io.opentelemetry.sdk.logs.internal.SdkLoggerProviderUtil.setContextScopedAttributesEnabled; import static io.opentelemetry.sdk.logs.internal.SdkLoggerProviderUtil.setLoggerConfigurator; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; import io.opentelemetry.api.logs.Severity; import io.opentelemetry.common.ComponentLoader; +import io.opentelemetry.context.Context; import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; import io.opentelemetry.internal.testing.CleanupExtension; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.AttributeLimitsModel; @@ -29,13 +34,16 @@ import io.opentelemetry.sdk.logs.LogLimits; import io.opentelemetry.sdk.logs.SdkLoggerProvider; import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; import io.opentelemetry.sdk.logs.internal.LoggerConfig; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; import java.io.Closeable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -67,6 +75,34 @@ void create(LoggerProviderAndAttributeLimits model, SdkLoggerProvider expectedPr assertThat(provider.toString()).isEqualTo(expectedProvider.toString()); } + @Test + void create_contextScopedAttributesEnabled() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + cleanup.addCloseable(exporter); + SdkLoggerProvider provider = + LoggerProviderFactory.getInstance() + .create( + LoggerProviderAndAttributeLimits.create( + new AttributeLimitsModel(), + LoggerProviderModelAccessor.setContextScopedAttributes( + new LoggerProviderModel(), true)), + context) + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)) + .build(); + cleanup.addCloseable(provider); + + Context contextWithAttrs = + ContextAttributes.add(Context.root(), Attributes.of(AttributeKey.stringKey("foo"), "bar")); + provider.get("test").logRecordBuilder().setContext(contextWithAttrs).setBody("test").emit(); + + assertThat(exporter.getFinishedLogRecordItems()) + .singleElement() + .satisfies( + log -> + assertThat(log.getAttributes()) + .containsEntry(AttributeKey.stringKey("foo"), "bar")); + } + private static Stream createArguments() { return Stream.of( Arguments.argumentSet( @@ -82,43 +118,48 @@ private static Stream createArguments() { "full configuration", LoggerProviderAndAttributeLimits.create( new AttributeLimitsModel(), - LoggerProviderModelAccessor.setLoggerConfigurator( - new LoggerProviderModel() - .setLimits( - new LogRecordLimitsModel() - .setAttributeCountLimit(1) - .setAttributeValueLengthLimit(2)) - .setProcessors( - Collections.singletonList( - new LogRecordProcessorModel() - .setBatch( - new BatchLogRecordProcessorModel() - .setExporter( - new LogRecordExporterModel() - .setOtlpHttp(new OtlpHttpExporterModel()))))), - new ExperimentalLoggerConfiguratorModel() - .setDefaultConfig(new ExperimentalLoggerConfigModel().setEnabled(false)) - .setLoggers( - Collections.singletonList( - new ExperimentalLoggerMatcherAndConfigModel() - .setName("foo") - .setConfig( - new ExperimentalLoggerConfigModel() - .setEnabled(true) - .setTraceBased(true) - .setMinimumSeverity(SeverityNumberModel.INFO)))))), - setLoggerConfigurator( - SdkLoggerProvider.builder(), - ScopeConfigurator.builder() - .setDefault(LoggerConfig.disabled()) - .addCondition( - ScopeConfiguratorBuilder.nameMatchesGlob("foo"), - LoggerConfig.builder() - .setEnabled(true) - .setTraceBased(true) - .setMinimumSeverity(Severity.INFO) - .build()) - .build()) + LoggerProviderModelAccessor.setContextScopedAttributes( + LoggerProviderModelAccessor.setLoggerConfigurator( + new LoggerProviderModel() + .setLimits( + new LogRecordLimitsModel() + .setAttributeCountLimit(1) + .setAttributeValueLengthLimit(2)) + .setProcessors( + Collections.singletonList( + new LogRecordProcessorModel() + .setBatch( + new BatchLogRecordProcessorModel() + .setExporter( + new LogRecordExporterModel() + .setOtlpHttp( + new OtlpHttpExporterModel()))))), + new ExperimentalLoggerConfiguratorModel() + .setDefaultConfig(new ExperimentalLoggerConfigModel().setEnabled(false)) + .setLoggers( + Collections.singletonList( + new ExperimentalLoggerMatcherAndConfigModel() + .setName("foo") + .setConfig( + new ExperimentalLoggerConfigModel() + .setEnabled(true) + .setTraceBased(true) + .setMinimumSeverity(SeverityNumberModel.INFO))))), + true)), + setContextScopedAttributesEnabled( + setLoggerConfigurator( + SdkLoggerProvider.builder(), + ScopeConfigurator.builder() + .setDefault(LoggerConfig.disabled()) + .addCondition( + ScopeConfiguratorBuilder.nameMatchesGlob("foo"), + LoggerConfig.builder() + .setEnabled(true) + .setTraceBased(true) + .setMinimumSeverity(Severity.INFO) + .build()) + .build()), + true) .setLogLimits( () -> LogLimits.builder() diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java index 584bdfa22f4..afd94d90259 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/MeterProviderFactoryTest.java @@ -5,10 +5,15 @@ package io.opentelemetry.sdk.autoconfigure.declarativeconfig; +import static io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil.setContextScopedAttributesEnabled; import static io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil.setMeterConfigurator; import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; import io.opentelemetry.common.ComponentLoader; +import io.opentelemetry.context.Context; import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; import io.opentelemetry.internal.testing.CleanupExtension; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.ExemplarFilterModel; @@ -32,12 +37,14 @@ import io.opentelemetry.sdk.metrics.View; import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; import io.opentelemetry.sdk.metrics.internal.MeterConfig; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; import java.io.Closeable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -69,49 +76,88 @@ void create(MeterProviderModel model, SdkMeterProvider expectedProvider) { assertThat(provider.toString()).isEqualTo(expectedProvider.toString()); } + @Test + void create_contextScopedAttributesEnabled() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + cleanup.addCloseable(reader); + SdkMeterProvider provider = + MeterProviderFactory.getInstance() + .create( + MeterProviderModelAccessor.setContextScopedAttributes( + new MeterProviderModel(), true), + context) + .registerMetricReader(reader) + .build(); + cleanup.addCloseable(provider); + + Context contextWithAttrs = + ContextAttributes.add(Context.root(), Attributes.of(AttributeKey.stringKey("foo"), "bar")); + provider + .get("test") + .counterBuilder("test") + .build() + .add(1, Attributes.empty(), contextWithAttrs); + + assertThat(reader.collectAllMetrics()) + .singleElement() + .satisfies( + metricData -> + assertThat(metricData.getLongSumData().getPoints()) + .singleElement() + .satisfies( + point -> + assertThat(point.getAttributes()) + .containsEntry(AttributeKey.stringKey("foo"), "bar"))); + } + private static Stream createArguments() { return Stream.of( Arguments.argumentSet( "defaults", new MeterProviderModel(), SdkMeterProvider.builder().build()), Arguments.argumentSet( "with reader view and meter configurator", - MeterProviderModelAccessor.setMeterConfigurator( - new MeterProviderModel() - .setReaders( - Collections.singletonList( - new MetricReaderModel() - .setPeriodic( - new PeriodicMetricReaderModel() - .setExporter( - new PushMetricExporterModel() - .setOtlpHttp( - new OtlpHttpMetricExporterModel()))))) - .setViews( - Collections.singletonList( - new ViewModel() - .setSelector( - new ViewSelectorModel() - .setInstrumentName("instrument-name")) - .setStream( - new ViewStreamModel() - .setName("stream-name") - .setAttributeKeys(null)))), - new ExperimentalMeterConfiguratorModel() - .setDefaultConfig(new ExperimentalMeterConfigModel().setEnabled(false)) - .setMeters( - Collections.singletonList( - new ExperimentalMeterMatcherAndConfigModel() - .setName("foo") - .setConfig( - new ExperimentalMeterConfigModel().setEnabled(true))))) + MeterProviderModelAccessor.setContextScopedAttributes( + MeterProviderModelAccessor.setMeterConfigurator( + new MeterProviderModel() + .setReaders( + Collections.singletonList( + new MetricReaderModel() + .setPeriodic( + new PeriodicMetricReaderModel() + .setExporter( + new PushMetricExporterModel() + .setOtlpHttp( + new OtlpHttpMetricExporterModel()))))) + .setViews( + Collections.singletonList( + new ViewModel() + .setSelector( + new ViewSelectorModel() + .setInstrumentName("instrument-name")) + .setStream( + new ViewStreamModel() + .setName("stream-name") + .setAttributeKeys(null)))), + new ExperimentalMeterConfiguratorModel() + .setDefaultConfig(new ExperimentalMeterConfigModel().setEnabled(false)) + .setMeters( + Collections.singletonList( + new ExperimentalMeterMatcherAndConfigModel() + .setName("foo") + .setConfig( + new ExperimentalMeterConfigModel().setEnabled(true))))), + true) .setExemplarFilter(ExemplarFilterModel.ALWAYS_ON), - setMeterConfigurator( - SdkMeterProvider.builder(), - ScopeConfigurator.builder() - .setDefault(MeterConfig.disabled()) - .addCondition( - ScopeConfiguratorBuilder.nameMatchesGlob("foo"), MeterConfig.enabled()) - .build()) + setContextScopedAttributesEnabled( + setMeterConfigurator( + SdkMeterProvider.builder(), + ScopeConfigurator.builder() + .setDefault(MeterConfig.disabled()) + .addCondition( + ScopeConfiguratorBuilder.nameMatchesGlob("foo"), + MeterConfig.enabled()) + .build()), + true) .setExemplarFilter(ExemplarFilter.alwaysOn()) .registerMetricReader( PeriodicMetricReader.builder( diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactoryTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactoryTest.java index d3b9e1d3900..83db2646dc9 100644 --- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactoryTest.java +++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/TracerProviderFactoryTest.java @@ -8,7 +8,11 @@ import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static io.opentelemetry.sdk.trace.samplers.Sampler.alwaysOn; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; import io.opentelemetry.common.ComponentLoader; +import io.opentelemetry.context.Context; import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; import io.opentelemetry.internal.testing.CleanupExtension; import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.AlwaysOnSamplerModel; @@ -26,10 +30,12 @@ import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.TracerProviderModelAccessor; import io.opentelemetry.sdk.common.internal.ScopeConfigurator; import io.opentelemetry.sdk.common.internal.ScopeConfiguratorBuilder; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; import io.opentelemetry.sdk.trace.SpanLimits; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; import io.opentelemetry.sdk.trace.internal.SdkTracerProviderUtil; import io.opentelemetry.sdk.trace.internal.TracerConfig; import java.io.Closeable; @@ -38,6 +44,7 @@ import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -69,6 +76,34 @@ void create(TracerProviderAndAttributeLimits model, SdkTracerProvider expectedPr assertThat(provider.toString()).isEqualTo(expectedProvider.toString()); } + @Test + void create_contextScopedAttributesEnabled() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + cleanup.addCloseable(exporter); + SdkTracerProvider provider = + TracerProviderFactory.getInstance() + .create( + TracerProviderAndAttributeLimits.create( + new AttributeLimitsModel(), + TracerProviderModelAccessor.setContextScopedAttributes( + new TracerProviderModel(), true)), + context) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + cleanup.addCloseable(provider); + + Context contextWithAttrs = + ContextAttributes.add(Context.root(), Attributes.of(AttributeKey.stringKey("foo"), "bar")); + provider.get("test").spanBuilder("test").setParent(contextWithAttrs).startSpan().end(); + + assertThat(exporter.getFinishedSpanItems()) + .singleElement() + .satisfies( + span -> + assertThat(span.getAttributes()) + .containsEntry(AttributeKey.stringKey("foo"), "bar")); + } + private static Stream createArguments() { return Stream.of( Arguments.argumentSet( @@ -84,33 +119,37 @@ private static Stream createArguments() { "full configuration", TracerProviderAndAttributeLimits.create( new AttributeLimitsModel(), - TracerProviderModelAccessor.setTracerConfigurator( - new TracerProviderModel() - .setLimits( - new SpanLimitsModel() - .setAttributeCountLimit(1) - .setAttributeValueLengthLimit(2) - .setEventCountLimit(3) - .setLinkCountLimit(4) - .setEventAttributeCountLimit(5) - .setLinkAttributeCountLimit(6)) - .setSampler(new SamplerModel().setAlwaysOn(new AlwaysOnSamplerModel())) - .setProcessors( - Collections.singletonList( - new SpanProcessorModel() - .setBatch( - new BatchSpanProcessorModel() - .setExporter( - new SpanExporterModel() - .setOtlpHttp(new OtlpHttpExporterModel()))))), - new ExperimentalTracerConfiguratorModel() - .setDefaultConfig(new ExperimentalTracerConfigModel().setEnabled(false)) - .setTracers( - Collections.singletonList( - new ExperimentalTracerMatcherAndConfigModel() - .setName("foo") - .setConfig( - new ExperimentalTracerConfigModel().setEnabled(true)))))), + TracerProviderModelAccessor.setContextScopedAttributes( + TracerProviderModelAccessor.setTracerConfigurator( + new TracerProviderModel() + .setLimits( + new SpanLimitsModel() + .setAttributeCountLimit(1) + .setAttributeValueLengthLimit(2) + .setEventCountLimit(3) + .setLinkCountLimit(4) + .setEventAttributeCountLimit(5) + .setLinkAttributeCountLimit(6)) + .setSampler(new SamplerModel().setAlwaysOn(new AlwaysOnSamplerModel())) + .setProcessors( + Collections.singletonList( + new SpanProcessorModel() + .setBatch( + new BatchSpanProcessorModel() + .setExporter( + new SpanExporterModel() + .setOtlpHttp( + new OtlpHttpExporterModel()))))), + new ExperimentalTracerConfiguratorModel() + .setDefaultConfig(new ExperimentalTracerConfigModel().setEnabled(false)) + .setTracers( + Collections.singletonList( + new ExperimentalTracerMatcherAndConfigModel() + .setName("foo") + .setConfig( + new ExperimentalTracerConfigModel() + .setEnabled(true))))), + true)), addTracerConfigurator( SdkTracerProvider.builder(), ScopeConfigurator.builder() @@ -138,6 +177,7 @@ private static Stream createArguments() { private static SdkTracerProviderBuilder addTracerConfigurator( SdkTracerProviderBuilder builder, ScopeConfigurator tracerConfigurator) { SdkTracerProviderUtil.setTracerConfigurator(builder, tracerConfigurator); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); return builder; } } diff --git a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java index a5b6a69ddff..6810661f7fd 100644 --- a/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java +++ b/sdk/common/src/main/java/io/opentelemetry/sdk/common/internal/AttributesMap.java @@ -98,6 +98,18 @@ private AttributesMap(long capacity, int lengthLimit) { mask = hashTable.length - 1; } + private AttributesMap(AttributesMap source) { + this.capacity = source.capacity; + this.lengthLimit = source.lengthLimit; + this.totalAddedValues = source.totalAddedValues; + this.size = source.size; + this.entryNames = Arrays.copyOf(source.entryNames, source.entryNames.length); + this.entryKeys = Arrays.copyOf(source.entryKeys, source.entryKeys.length); + this.entryValues = Arrays.copyOf(source.entryValues, source.entryValues.length); + this.hashTable = Arrays.copyOf(source.hashTable, source.hashTable.length); + this.mask = source.mask; + } + /** * Create an instance. * @@ -108,6 +120,14 @@ public static AttributesMap create(long capacity, int lengthLimit) { return new AttributesMap(capacity, lengthLimit); } + /** + * Create a mutable copy of an existing {@link AttributesMap}, preserving all entries, capacity, + * length limit, and total added values count. + */ + public static AttributesMap copyOf(AttributesMap source) { + return new AttributesMap(source); + } + /** * Add the attribute key value pair, applying capacity and length limits. Callers MUST ensure the * {@code value} type matches the type required by {@code key}. diff --git a/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java b/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java index ec840a49ad0..269cbe09725 100644 --- a/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java +++ b/sdk/common/src/test/java/io/opentelemetry/sdk/common/internal/AttributesMapTest.java @@ -365,6 +365,27 @@ void equals_isSymmetricWithOtherAttributesImplementations() { assertThat(attributes).isNotEqualTo(map); } + @Test + void copyOf_preservesStateAndDroppedCounts() { + AttributesMap original = AttributesMap.create(2, 5); + original.put(stringKey("k1"), "val1"); + original.put(stringKey("k2"), "val2"); + original.put(stringKey("k3"), "val3"); // dropped, totalAddedValues = 3, size = 2 + + AttributesMap copy = AttributesMap.copyOf(original); + + assertThat(copy.size()).isEqualTo(2); + assertThat(copy.getTotalAddedValues()).isEqualTo(3); + assertThat(copy.get(stringKey("k1"))).isEqualTo("val1"); + assertThat(copy.get(stringKey("k2"))).isEqualTo("val2"); + assertThat(copy.get(stringKey("k3"))).isNull(); + + // Mutations on copy do not affect original + copy.put(stringKey("k4"), "val4"); // dropped on copy + assertThat(copy.getTotalAddedValues()).isEqualTo(4); + assertThat(original.getTotalAddedValues()).isEqualTo(3); + } + private static List throwingList() { return new AbstractList() { @Override diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/IncubatingUtil.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/IncubatingUtil.java index 8d1278e741c..f2b3c8473ce 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/IncubatingUtil.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/IncubatingUtil.java @@ -5,6 +5,9 @@ package io.opentelemetry.sdk.logs; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.logs.internal.LoggerConfig; @@ -30,4 +33,8 @@ static SdkLogRecordBuilder createExtendedLogRecordBuilder( SdkLogger logger) { return new ExtendedSdkLogRecordBuilder(loggerSharedState, instrumentationScopeInfo, logger); } + + static Attributes getContextAttributes(Context context) { + return ContextAttributes.get(context); + } } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/LoggerSharedState.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/LoggerSharedState.java index 4c4a64ea30f..cc52d084be0 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/LoggerSharedState.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/LoggerSharedState.java @@ -24,6 +24,7 @@ final class LoggerSharedState { private final Clock clock; private final ExceptionAttributeResolver exceptionAttributeResolver; private final SdkLoggerInstrumentation loggerInstrumentation; + private final boolean contextScopedAttributesEnabled; @Nullable private volatile CompletableResultCode shutdownResult = null; LoggerSharedState( @@ -32,13 +33,15 @@ final class LoggerSharedState { LogRecordProcessor logRecordProcessor, Clock clock, ExceptionAttributeResolver exceptionAttributeResolver, - SdkLoggerInstrumentation loggerInstrumentation) { + SdkLoggerInstrumentation loggerInstrumentation, + boolean contextScopedAttributesEnabled) { this.resource = resource; this.logLimitsSupplier = logLimitsSupplier; this.logRecordProcessor = logRecordProcessor; this.clock = clock; this.exceptionAttributeResolver = exceptionAttributeResolver; this.loggerInstrumentation = loggerInstrumentation; + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; } Resource getResource() { @@ -65,6 +68,10 @@ SdkLoggerInstrumentation getLoggerInstrumentation() { return loggerInstrumentation; } + boolean isContextScopedAttributesEnabled() { + return contextScopedAttributesEnabled; + } + boolean hasBeenShutdown() { return shutdownResult != null; } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java index 7f51ad9f4b2..8b67952d2b7 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogRecordBuilder.java @@ -6,6 +6,7 @@ package io.opentelemetry.sdk.logs; import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.common.Value; import io.opentelemetry.api.logs.LogRecordBuilder; import io.opentelemetry.api.logs.Severity; @@ -168,7 +169,26 @@ protected void setExceptionAttribute(AttributeKey key, @Nullable T value) } } + @SuppressWarnings({"rawtypes", "unchecked"}) protected ReadWriteLogRecord createLogRecord(Context context, long observedTimestampEpochNanos) { + AttributesMap recordAttributes = this.attributes; + if (loggerSharedState.isContextScopedAttributesEnabled() && SdkLogger.INCUBATOR_AVAILABLE) { + Attributes contextAttributes = IncubatingUtil.getContextAttributes(context); + if (!contextAttributes.isEmpty()) { + AttributesMap mergedAttributes = + recordAttributes != null + ? AttributesMap.copyOf(recordAttributes) + : AttributesMap.create( + logLimits.getMaxNumberOfAttributes(), logLimits.getMaxAttributeValueLength()); + contextAttributes.forEach( + (key, value) -> { + if (mergedAttributes.get(key) == null) { + mergedAttributes.put((AttributeKey) key, value); + } + }); + recordAttributes = mergedAttributes; + } + } return SdkReadWriteLogRecord.create( loggerSharedState.getLogLimits(), loggerSharedState.getResource(), @@ -179,7 +199,7 @@ protected ReadWriteLogRecord createLogRecord(Context context, long observedTimes severity, severityText, body, - attributes, + recordAttributes, eventName); } } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogger.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogger.java index 20c74169d80..69f181d5774 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogger.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLogger.java @@ -19,7 +19,7 @@ class SdkLogger implements Logger { private static final Logger NOOP_LOGGER = LoggerProvider.noop().get("noop"); - private static final boolean INCUBATOR_AVAILABLE; + static final boolean INCUBATOR_AVAILABLE; static { boolean incubatorAvailable = false; diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProvider.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProvider.java index db658e55bd8..27bd3d672dc 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProvider.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProvider.java @@ -60,7 +60,8 @@ public static SdkLoggerProviderBuilder builder() { Clock clock, ScopeConfigurator loggerConfigurator, ExceptionAttributeResolver exceptionAttributeResolver, - Supplier meterProvider) { + Supplier meterProvider, + boolean contextScopedAttributesEnabled) { LogRecordProcessor logRecordProcessor = LogRecordProcessor.composite(processors); this.sharedState = new LoggerSharedState( @@ -69,7 +70,8 @@ public static SdkLoggerProviderBuilder builder() { logRecordProcessor, clock, exceptionAttributeResolver, - new SdkLoggerInstrumentation(meterProvider)); + new SdkLoggerInstrumentation(meterProvider), + contextScopedAttributesEnabled); this.loggerComponentRegistry = new ComponentRegistry<>( instrumentationScopeInfo -> diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProviderBuilder.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProviderBuilder.java index ccd13cc5e7f..4723e174f0a 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProviderBuilder.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/SdkLoggerProviderBuilder.java @@ -42,6 +42,7 @@ public final class SdkLoggerProviderBuilder { private ExceptionAttributeResolver exceptionAttributeResolver = ExceptionAttributeResolver.getDefault(); private Supplier meterProvider = MeterProvider::noop; + private boolean contextScopedAttributesEnabled; SdkLoggerProviderBuilder() {} @@ -201,6 +202,18 @@ public SdkLoggerProviderBuilder setMeterProvider(Supplier meterPr return this; } + /** + * Sets whether context-scoped attributes are automatically inherited by log records. + * + *

This method is experimental so not public. You may reflectively call it using {@link + * SdkLoggerProviderUtil#setContextScopedAttributesEnabled(SdkLoggerProviderBuilder, boolean)}. + */ + SdkLoggerProviderBuilder setContextScopedAttributesEnabled( + boolean contextScopedAttributesEnabled) { + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; + return this; + } + /** * Create a {@link SdkLoggerProvider} instance. * @@ -214,6 +227,7 @@ public SdkLoggerProvider build() { clock, loggerConfiguratorBuilder.build(), exceptionAttributeResolver, - meterProvider); + meterProvider, + contextScopedAttributesEnabled); } } diff --git a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/internal/SdkLoggerProviderUtil.java b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/internal/SdkLoggerProviderUtil.java index 9d9c911ab5d..07373bc7d91 100644 --- a/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/internal/SdkLoggerProviderUtil.java +++ b/sdk/logs/src/main/java/io/opentelemetry/sdk/logs/internal/SdkLoggerProviderUtil.java @@ -90,4 +90,23 @@ public static void setExceptionAttributeResolver( "Error calling setExceptionAttributeResolver on SdkLoggerProviderBuilder", e); } } + + /** + * Reflectively set whether context-scoped attributes are enabled on the {@link + * SdkLoggerProviderBuilder}. + */ + public static SdkLoggerProviderBuilder setContextScopedAttributesEnabled( + SdkLoggerProviderBuilder sdkLoggerProviderBuilder, boolean contextScopedAttributesEnabled) { + try { + Method method = + SdkLoggerProviderBuilder.class.getDeclaredMethod( + "setContextScopedAttributesEnabled", boolean.class); + method.setAccessible(true); + method.invoke(sdkLoggerProviderBuilder, contextScopedAttributesEnabled); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException( + "Error calling setContextScopedAttributesEnabled on SdkLoggerProviderBuilder", e); + } + return sdkLoggerProviderBuilder; + } } diff --git a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/LoggerSharedStateTest.java b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/LoggerSharedStateTest.java index 1bd98dab57d..87ba1c7a351 100644 --- a/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/LoggerSharedStateTest.java +++ b/sdk/logs/src/test/java/io/opentelemetry/sdk/logs/LoggerSharedStateTest.java @@ -5,6 +5,7 @@ package io.opentelemetry.sdk.logs; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -31,9 +32,35 @@ void shutdown() { logRecordProcessor, Clock.getDefault(), ExceptionAttributeResolver.getDefault(), - new SdkLoggerInstrumentation(MeterProvider::noop)); + new SdkLoggerInstrumentation(MeterProvider::noop), + /* contextScopedAttributesEnabled= */ false); state.shutdown(); state.shutdown(); verify(logRecordProcessor, times(1)).shutdown(); } + + @Test + void contextScopedAttributesEnabled() { + LoggerSharedState stateEnabled = + new LoggerSharedState( + Resource.empty(), + LogLimits::getDefault, + mock(LogRecordProcessor.class), + Clock.getDefault(), + ExceptionAttributeResolver.getDefault(), + new SdkLoggerInstrumentation(MeterProvider::noop), + /* contextScopedAttributesEnabled= */ true); + assertThat(stateEnabled.isContextScopedAttributesEnabled()).isTrue(); + + LoggerSharedState stateDisabled = + new LoggerSharedState( + Resource.empty(), + LogLimits::getDefault, + mock(LogRecordProcessor.class), + Clock.getDefault(), + ExceptionAttributeResolver.getDefault(), + new SdkLoggerInstrumentation(MeterProvider::noop), + /* contextScopedAttributesEnabled= */ false); + assertThat(stateDisabled.isContextScopedAttributesEnabled()).isFalse(); + } } diff --git a/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ContextScopedAttributesLogTest.java b/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ContextScopedAttributesLogTest.java new file mode 100644 index 00000000000..8f1d92304e9 --- /dev/null +++ b/sdk/logs/src/testIncubating/java/io/opentelemetry/sdk/logs/ContextScopedAttributesLogTest.java @@ -0,0 +1,203 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.logs; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.api.logs.LogRecordBuilder; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.logs.internal.SdkLoggerProviderUtil; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import java.util.List; +import org.junit.jupiter.api.Test; + +class ContextScopedAttributesLogTest { + + @Test + void contextScopedAttributes_disabledByDefault() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + SdkLoggerProvider provider = + SdkLoggerProvider.builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)) + .build(); + Logger logger = provider.get("test"); + + Context ctx = ContextAttributes.add(Context.current(), Attributes.of(stringKey("k1"), "v1")); + try (Scope ignored = ctx.makeCurrent()) { + logger.logRecordBuilder().setBody("hello").emit(); + } + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getAttributes()).isEmpty(); + } + + @Test + void contextScopedAttributes_enabled_mergesWithExplicitPrecedence() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + SdkLoggerProviderBuilder builder = + SdkLoggerProvider.builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkLoggerProvider provider = builder.build(); + Logger logger = provider.get("test"); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k1"), "from-context", stringKey("k2"), "from-context")); + try (Scope ignored = ctx.makeCurrent()) { + logger + .logRecordBuilder() + .setAttribute(stringKey("k2"), "explicit") + .setAttribute(stringKey("k3"), "v3") + .setBody("hello") + .emit(); + } + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getAttributes()) + .isEqualTo( + Attributes.of( + stringKey("k1"), + "from-context", + stringKey("k2"), + "explicit", + stringKey("k3"), + "v3")); + } + + @Test + void contextScopedAttributes_usesExplicitContext() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + SdkLoggerProviderBuilder builder = + SdkLoggerProvider.builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkLoggerProvider provider = builder.build(); + Logger logger = provider.get("test"); + + Context explicitCtx = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("ctx"), "explicit")); + Context currentCtx = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("ctx"), "current")); + + try (Scope ignored = currentCtx.makeCurrent()) { + logger.logRecordBuilder().setContext(explicitCtx).setBody("hello").emit(); + } + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getAttributes()).isEqualTo(Attributes.of(stringKey("ctx"), "explicit")); + } + + @Test + void contextScopedAttributes_reemitDoesNotRetainStaleContextAttributes() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + SdkLoggerProviderBuilder builder = + SdkLoggerProvider.builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkLoggerProvider provider = builder.build(); + Logger logger = provider.get("test"); + + Context ctx1 = ContextAttributes.add(Context.root(), Attributes.of(stringKey("scope"), "ctx1")); + Context ctx2 = ContextAttributes.add(Context.root(), Attributes.of(stringKey("scope"), "ctx2")); + + LogRecordBuilder logBuilder = + logger.logRecordBuilder().setAttribute(stringKey("explicit"), "val"); + logBuilder.setContext(ctx1).emit(); + logBuilder.setContext(ctx2).emit(); + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(2); + assertThat(logs.get(0).getAttributes()) + .isEqualTo(Attributes.of(stringKey("explicit"), "val", stringKey("scope"), "ctx1")); + assertThat(logs.get(1).getAttributes()) + .isEqualTo(Attributes.of(stringKey("explicit"), "val", stringKey("scope"), "ctx2")); + } + + @Test + void contextScopedAttributes_logRecordAttributeLimit() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + SdkLoggerProviderBuilder builder = + SdkLoggerProvider.builder() + .setLogLimits(() -> LogLimits.builder().setMaxNumberOfAttributes(1).build()) + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkLoggerProvider provider = builder.build(); + Logger logger = provider.get("test"); + + Context ctx = ContextAttributes.add(Context.root(), Attributes.of(stringKey("scope"), "ctx")); + + try (Scope ignored = ctx.makeCurrent()) { + logger.logRecordBuilder().setAttribute(stringKey("explicit"), "val").emit(); + } + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + LogRecordData record = logs.get(0); + assertThat(record.getAttributes()).isEqualTo(Attributes.of(stringKey("explicit"), "val")); + assertThat(record.getTotalAttributeCount()).isEqualTo(2); + } + + @Test + void + contextScopedAttributes_explicitExceedsLimitAndContextPresent_tracksDroppedAttributesCorrectly() { + InMemoryLogRecordExporter exporter = InMemoryLogRecordExporter.create(); + int maxNumberOfAttributes = 2; + + SdkLoggerProviderBuilder builder = + SdkLoggerProvider.builder() + .setLogLimits( + () -> LogLimits.builder().setMaxNumberOfAttributes(maxNumberOfAttributes).build()) + .addLogRecordProcessor(SimpleLogRecordProcessor.create(exporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkLoggerProvider provider = builder.build(); + Logger logger = provider.get("test"); + + // 2 distinct context attributes + Context ctx = + ContextAttributes.add( + Context.root(), + Attributes.of(stringKey("ctx1"), "ctxVal1", stringKey("ctx2"), "ctxVal2")); + + try (Scope ignored = ctx.makeCurrent()) { + // 3 explicit attributes (1 dropped on builder) + 2 context attributes (both dropped on merge) + logger + .logRecordBuilder() + .setAttribute(stringKey("exp1"), "expVal1") + .setAttribute(stringKey("exp2"), "expVal2") + .setAttribute(stringKey("exp3"), "expVal3") + .setBody("test log with dropped attributes") + .emit(); + } + + List logs = exporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + LogRecordData record = logs.get(0); + + // Explicit attributes take precedence and are retained up to limit (2) + assertThat(record.getAttributes()) + .isEqualTo(Attributes.of(stringKey("exp1"), "expVal1", stringKey("exp2"), "expVal2")); + assertThat(record.getAttributes().size()).isEqualTo(2); + + // Total offered: 3 explicit + 2 context = 5 + assertThat(record.getTotalAttributeCount()).isEqualTo(5); + + // Dropped count: 5 total - 2 retained = 3 dropped + int droppedAttributesCount = record.getTotalAttributeCount() - record.getAttributes().size(); + assertThat(droppedAttributesCount).isEqualTo(3); + } +} diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/AbstractInstrument.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/AbstractInstrument.java index 1c81c222afc..13c0da6d388 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/AbstractInstrument.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/AbstractInstrument.java @@ -16,15 +16,17 @@ abstract class AbstractInstrument { /** * True when the record-path {@link Context} has no observable effect for this instrument, so * parameterless record overloads may substitute {@link Context#root()} for {@link - * Context#current()} and skip a thread-local lookup. The exemplar filter is the sole record-path - * consumer of {@link Context}, so this is true iff exemplars are disabled meter-wide. + * Context#current()} and skip a thread-local lookup. The exemplar filter and context-scoped + * attributes are the record-path consumers of {@link Context}, so this is true iff both are + * disabled meter-wide. */ private final boolean canUseRootContext; // All arguments cannot be null because they are checked in the abstract builder classes. AbstractInstrument(InstrumentDescriptor descriptor, SdkMeter sdkMeter) { this.descriptor = descriptor; - this.canUseRootContext = sdkMeter.isExemplarsAlwaysOff(); + this.canUseRootContext = + sdkMeter.isExemplarsAlwaysOff() && !sdkMeter.isContextScopedAttributesEnabled(); } final InstrumentDescriptor getDescriptor() { diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java index 2455ac66141..13429dfebf0 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeter.java @@ -99,6 +99,10 @@ boolean isExemplarsAlwaysOff() { return meterProviderSharedState.getExemplarFilter() instanceof AlwaysOffExemplarFilter; } + boolean isContextScopedAttributesEnabled() { + return meterProviderSharedState.isContextScopedAttributesEnabled() && INCUBATOR_AVAILABLE; + } + private final Map readerStorageRegistries; private volatile boolean meterEnabled; @@ -300,7 +304,8 @@ WriteableMetricStorage registerSynchronousMetricStorage(InstrumentDescriptor ins meterProviderSharedState.getClock(), instrument, meterProviderSharedState.getExemplarFilter(), - meterEnabled))); + meterEnabled, + isContextScopedAttributesEnabled()))); } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java index 1ec856f8c05..70d26e3169d 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProvider.java @@ -71,7 +71,8 @@ public static SdkMeterProviderBuilder builder() { Clock clock, Resource resource, ExemplarFilterInternal exemplarFilter, - ScopeConfigurator meterConfigurator) { + ScopeConfigurator meterConfigurator, + boolean contextScopedAttributesEnabled) { this.registeredViews = registeredViews; this.registeredReaders = metricReaders.entrySet().stream() @@ -82,7 +83,9 @@ public static SdkMeterProviderBuilder builder() { ViewRegistry.create(entry.getKey(), entry.getValue(), registeredViews))) .collect(toList()); this.metricProducers = metricProducers; - this.sharedState = MeterProviderSharedState.create(clock, resource, exemplarFilter); + this.sharedState = + MeterProviderSharedState.create( + clock, resource, exemplarFilter, contextScopedAttributesEnabled); this.registry = new ComponentRegistry<>( instrumentationLibraryInfo -> diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java index e13b45ceba1..65ce9056be4 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/SdkMeterProviderBuilder.java @@ -49,6 +49,7 @@ public final class SdkMeterProviderBuilder { private ExemplarFilterInternal exemplarFilter = DEFAULT_EXEMPLAR_FILTER; private ScopeConfiguratorBuilder meterConfiguratorBuilder = MeterConfig.configuratorBuilder(); + private boolean contextScopedAttributesEnabled; SdkMeterProviderBuilder() {} @@ -199,6 +200,19 @@ SdkMeterProviderBuilder addMeterConfiguratorCondition( return this; } + /** + * Sets whether context-scoped attributes are automatically inherited by synchronous metric + * measurements. + * + *

This method is experimental so not public. You may reflectively call it using {@link + * SdkMeterProviderUtil#setContextScopedAttributesEnabled(SdkMeterProviderBuilder, boolean)}. + */ + SdkMeterProviderBuilder setContextScopedAttributesEnabled( + boolean contextScopedAttributesEnabled) { + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; + return this; + } + /** Returns an {@link SdkMeterProvider} built with the configuration of this builder. */ public SdkMeterProvider build() { return new SdkMeterProvider( @@ -208,6 +222,7 @@ public SdkMeterProvider build() { clock, resource, exemplarFilter, - meterConfiguratorBuilder.build()); + meterConfiguratorBuilder.build(), + contextScopedAttributesEnabled); } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java index 2507779136d..8de0cc0eb7b 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/SdkMeterProviderUtil.java @@ -83,4 +83,23 @@ public static void resetForTest(SdkMeterProvider sdkMeterProvider) { throw new IllegalStateException("Error calling resetForTest on SdkMeterProvider", e); } } + + /** + * Reflectively set whether context-scoped attributes are enabled on the {@link + * SdkMeterProviderBuilder}. + */ + public static SdkMeterProviderBuilder setContextScopedAttributesEnabled( + SdkMeterProviderBuilder sdkMeterProviderBuilder, boolean contextScopedAttributesEnabled) { + try { + Method method = + SdkMeterProviderBuilder.class.getDeclaredMethod( + "setContextScopedAttributesEnabled", boolean.class); + method.setAccessible(true); + method.invoke(sdkMeterProviderBuilder, contextScopedAttributesEnabled); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException( + "Error calling setContextScopedAttributesEnabled on SdkMeterProviderBuilder", e); + } + return sdkMeterProviderBuilder; + } } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java index 18925109db7..dfbb070af59 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/BoundStorageHandle.java @@ -13,7 +13,9 @@ * *

Records bypass the per-recording attribute processing and series lookup that {@link * WriteableMetricStorage#recordLong} / {@link WriteableMetricStorage#recordDouble} perform, since - * the series is resolved at bind time. Backs bound instruments. + * the series is resolved at bind time. Backs bound instruments. Bound handles record strictly + * against their bound attributes and do not inherit dynamic context-scoped attributes, preserving + * bare-metal performance and isolating infrastructure metrics from runtime cardinality explosion. * *

This class is internal and is hence not for public use. Its APIs are unstable and can change * at any time. diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java index 26fe09fac40..707547c59a3 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/CumulativeSynchronousMetricStorage.java @@ -42,8 +42,16 @@ class CumulativeSynchronousMetricStorage Clock clock, int maxCardinality, boolean enabled, + boolean contextScopedAttributesEnabled, MemoryMode memoryMode) { - super(metricDescriptor, aggregator, attributesFilter, clock, maxCardinality, enabled); + super( + metricDescriptor, + aggregator, + attributesFilter, + clock, + maxCardinality, + enabled, + contextScopedAttributesEnabled); this.memoryMode = memoryMode; } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java index a576341695c..8453292ac2c 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DefaultSynchronousMetricStorage.java @@ -20,6 +20,7 @@ import java.util.function.UnaryOperator; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.Nullable; /** * Stores aggregated {@link MetricData} for synchronous instruments. @@ -35,6 +36,7 @@ public abstract class DefaultSynchronousMetricStorage final ThrottlingLogger logger = new ThrottlingLogger(internalLogger); final UnaryOperator attributesFilter; + private final boolean contextScopedAttributesEnabled; protected final Clock clock; protected final MetricDescriptor metricDescriptor; protected final Aggregator aggregator; @@ -53,13 +55,15 @@ public abstract class DefaultSynchronousMetricStorage UnaryOperator attributesFilter, Clock clock, int maxCardinality, - boolean enabled) { + boolean enabled, + boolean contextScopedAttributesEnabled) { this.metricDescriptor = metricDescriptor; this.aggregator = aggregator; this.attributesFilter = attributesFilter; this.clock = clock; this.maxCardinality = maxCardinality - 1; this.enabled = enabled; + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; } static DefaultSynchronousMetricStorage create( @@ -70,6 +74,26 @@ static DefaultSynchronousMetricStorage create( int maxCardinality, Clock clock, boolean enabled) { + return create( + reader, + descriptor, + aggregator, + attributesFilter, + maxCardinality, + clock, + enabled, + /* contextScopedAttributesEnabled= */ false); + } + + static DefaultSynchronousMetricStorage create( + RegisteredReader reader, + MetricDescriptor descriptor, + Aggregator aggregator, + UnaryOperator attributesFilter, + int maxCardinality, + Clock clock, + boolean enabled, + boolean contextScopedAttributesEnabled) { AggregationTemporality aggregationTemporality = reader.getReader().getAggregationTemporality(descriptor.getSourceInstrument().getType()); return aggregationTemporality == CUMULATIVE @@ -80,9 +104,32 @@ static DefaultSynchronousMetricStorage create( clock, maxCardinality, enabled, + contextScopedAttributesEnabled, reader.getReader().getMemoryMode()) : new DeltaSynchronousMetricStorage<>( - reader, descriptor, aggregator, attributesFilter, clock, maxCardinality, enabled); + reader, + descriptor, + aggregator, + attributesFilter, + clock, + maxCardinality, + enabled, + contextScopedAttributesEnabled); + } + + @Nullable + private Attributes getMergedContextAttributes(Attributes attributes, Context context) { + if (!contextScopedAttributesEnabled) { + return null; + } + Attributes contextAttributes = StateIncubatingUtil.getContextAttributes(context); + if (contextAttributes.isEmpty()) { + return null; + } + if (attributes.isEmpty()) { + return contextAttributes; + } + return contextAttributes.toBuilder().putAll(attributes).build(); } @Override @@ -90,11 +137,17 @@ public void recordLong(long value, Attributes attributes, Context context) { if (!enabled) { return; } - doRecordLong(value, attributes, context); + Attributes merged = getMergedContextAttributes(attributes, context); + doRecordLong(value, merged != null ? merged : attributes, context); } @Override public void recordDouble(double value, Attributes attributes, Context context) { + if (!enabled) { + return; + } + Attributes merged = getMergedContextAttributes(attributes, context); + attributes = merged != null ? merged : attributes; if (!shouldRecordDouble(value, attributes)) { return; } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java index 51edfe0321d..af5b2e10d74 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/DeltaSynchronousMetricStorage.java @@ -54,8 +54,16 @@ class DeltaSynchronousMetricStorage UnaryOperator attributesFilter, Clock clock, int maxCardinality, - boolean enabled) { - super(metricDescriptor, aggregator, attributesFilter, clock, maxCardinality, enabled); + boolean enabled, + boolean contextScopedAttributesEnabled) { + super( + metricDescriptor, + aggregator, + attributesFilter, + clock, + maxCardinality, + enabled, + contextScopedAttributesEnabled); this.instrumentCreationEpochNanos = clock.now(); this.registeredReader = registeredReader; this.memoryMode = registeredReader.getReader().getMemoryMode(); diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/MeterProviderSharedState.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/MeterProviderSharedState.java index 46dace490df..d8c0e2001ec 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/MeterProviderSharedState.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/MeterProviderSharedState.java @@ -24,9 +24,16 @@ public abstract class MeterProviderSharedState { public static MeterProviderSharedState create( Clock clock, Resource resource, ExemplarFilterInternal exemplarFilter) { - MeterProviderSharedState sharedState = - new AutoValue_MeterProviderSharedState(clock, resource, exemplarFilter); - return sharedState; + return create(clock, resource, exemplarFilter, /* contextScopedAttributesEnabled= */ false); + } + + public static MeterProviderSharedState create( + Clock clock, + Resource resource, + ExemplarFilterInternal exemplarFilter, + boolean contextScopedAttributesEnabled) { + return new AutoValue_MeterProviderSharedState( + clock, resource, exemplarFilter, contextScopedAttributesEnabled); } MeterProviderSharedState() {} @@ -39,4 +46,10 @@ public static MeterProviderSharedState create( /** Returns the {@link ExemplarFilterInternal} for remembering measurements. */ public abstract ExemplarFilterInternal getExemplarFilter(); + + /** + * Returns {@code true} if context-scoped attributes should be automatically inherited by + * synchronous metric measurements. + */ + public abstract boolean isContextScopedAttributesEnabled(); } diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/StateIncubatingUtil.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/StateIncubatingUtil.java new file mode 100644 index 00000000000..f89af4a1368 --- /dev/null +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/StateIncubatingUtil.java @@ -0,0 +1,19 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics.internal.state; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.context.Context; + +final class StateIncubatingUtil { + + static Attributes getContextAttributes(Context context) { + return ContextAttributes.get(context); + } + + private StateIncubatingUtil() {} +} diff --git a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/SynchronousMetricStorage.java b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/SynchronousMetricStorage.java index 8858bab04a5..2d12ee8fc7e 100644 --- a/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/SynchronousMetricStorage.java +++ b/sdk/metrics/src/main/java/io/opentelemetry/sdk/metrics/internal/state/SynchronousMetricStorage.java @@ -30,6 +30,29 @@ static SynchronousMetricStorage empty() { return EmptyMetricStorage.INSTANCE; } + /** + * Constructs metric storage for a given synchronous instrument and view. + * + * @return The storage, or {@link EmptyMetricStorage#empty()} if the instrument should not be + * recorded. + */ + static SynchronousMetricStorage create( + RegisteredReader registeredReader, + RegisteredView registeredView, + Clock clock, + InstrumentDescriptor instrumentDescriptor, + ExemplarFilterInternal exemplarFilter, + boolean enabled) { + return create( + registeredReader, + registeredView, + clock, + instrumentDescriptor, + exemplarFilter, + enabled, + /* contextScopedAttributesEnabled= */ false); + } + /** * Constructs metric storage for a given synchronous instrument and view. * @@ -43,7 +66,8 @@ static SynchronousMetricStorage create( Clock clock, InstrumentDescriptor instrumentDescriptor, ExemplarFilterInternal exemplarFilter, - boolean enabled) { + boolean enabled, + boolean contextScopedAttributesEnabled) { View view = registeredView.getView(); MetricDescriptor metricDescriptor = MetricDescriptor.create(view, registeredView.getViewSourceInfo(), instrumentDescriptor); @@ -62,6 +86,7 @@ static SynchronousMetricStorage create( registeredView.getAttributesFilter(), registeredView.getCardinalityLimit(), clock, - enabled); + enabled, + contextScopedAttributesEnabled); } } diff --git a/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/ContextScopedAttributesMetricTest.java b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/ContextScopedAttributesMetricTest.java new file mode 100644 index 00000000000..fdf35345593 --- /dev/null +++ b/sdk/metrics/src/testIncubating/java/io/opentelemetry/sdk/metrics/ContextScopedAttributesMetricTest.java @@ -0,0 +1,482 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.metrics; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.attributeEntry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.api.incubator.metrics.BoundDoubleCounter; +import io.opentelemetry.api.incubator.metrics.BoundLongCounter; +import io.opentelemetry.api.incubator.metrics.ExtendedDoubleCounter; +import io.opentelemetry.api.incubator.metrics.ExtendedLongCounter; +import io.opentelemetry.api.metrics.DoubleCounter; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class ContextScopedAttributesMetricTest { + + @Test + void contextScopedAttributes_disabledByDefault() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + Meter meter = provider.get("test"); + LongCounter counter = meter.counterBuilder("counter").build(); + + Context ctx = ContextAttributes.add(Context.current(), Attributes.of(stringKey("k1"), "v1")); + try (Scope ignored = ctx.makeCurrent()) { + counter.add(1); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("counter") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> point.hasValue(1).hasAttributes(Attributes.empty())))); + } + + @Test + void contextScopedAttributes_enabled_mergesWithExplicitPrecedenceAndAppliesViewFilter() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = + SdkMeterProvider.builder() + .registerMetricReader(reader) + .registerView( + InstrumentSelector.builder().setName("filtered_counter").build(), + View.builder() + .setAttributeFilter(Collections.singleton("allowed_ctx_key")) + .build()); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + LongCounter counter = meter.counterBuilder("counter").build(); + LongCounter filteredCounter = meter.counterBuilder("filtered_counter").build(); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of( + stringKey("k1"), + "from-context", + stringKey("k2"), + "from-context", + stringKey("allowed_ctx_key"), + "allowed")); + try (Scope ignored = ctx.makeCurrent()) { + counter.add(5, Attributes.of(stringKey("k2"), "explicit", stringKey("k3"), "v3")); + filteredCounter.add(3, Attributes.of(stringKey("unallowed_explicit"), "dropped")); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactlyInAnyOrder( + metric -> + assertThat(metric) + .hasName("counter") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(5) + .hasAttributes( + attributeEntry("k1", "from-context"), + attributeEntry("k2", "explicit"), + attributeEntry("k3", "v3"), + attributeEntry("allowed_ctx_key", "allowed")))), + metric -> + assertThat(metric) + .hasName("filtered_counter") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(3) + .hasAttributes( + attributeEntry("allowed_ctx_key", "allowed"))))); + } + + @Test + void contextScopedAttributes_boundCounter_cumulative() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedLongCounter counter = + (ExtendedLongCounter) meter.counterBuilder("bound_counter").build(); + BoundLongCounter bound = + counter.bind( + Attributes.of(stringKey("k_bound"), "bound", stringKey("k_clash"), "bound-win")); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k_ctx"), "ctx", stringKey("k_clash"), "ctx-lose")); + try (Scope ignored = ctx.makeCurrent()) { + bound.add(10); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_counter") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(10) + .hasAttributes( + attributeEntry("k_bound", "bound"), + attributeEntry("k_clash", "bound-win"))))); + } + + @Test + void contextScopedAttributes_boundCounter_delta() { + InMemoryMetricReader reader = InMemoryMetricReader.createDelta(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedLongCounter counter = + (ExtendedLongCounter) meter.counterBuilder("bound_counter_delta").build(); + BoundLongCounter bound = + counter.bind( + Attributes.of(stringKey("k_bound"), "bound", stringKey("k_clash"), "bound-win")); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k_ctx"), "ctx", stringKey("k_clash"), "ctx-lose")); + try (Scope ignored = ctx.makeCurrent()) { + bound.add(7); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_counter_delta") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(7) + .hasAttributes( + attributeEntry("k_bound", "bound"), + attributeEntry("k_clash", "bound-win"))))); + } + + @Test + void contextScopedAttributes_doubleCounter_explicitContext() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + DoubleCounter counter = meter.counterBuilder("double_counter").ofDoubles().build(); + + Context explicitCtx = + ContextAttributes.add( + Context.root(), + Attributes.of(stringKey("k_ctx"), "ctx", stringKey("k_clash"), "ctx-lose")); + + counter.add( + 2.5, + Attributes.of(stringKey("k_explicit"), "val", stringKey("k_clash"), "explicit-win"), + explicitCtx); + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("double_counter") + .hasDoubleSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(2.5) + .hasAttributes( + attributeEntry("k_explicit", "val"), + attributeEntry("k_clash", "explicit-win"), + attributeEntry("k_ctx", "ctx"))))); + } + + @Test + void contextScopedAttributes_boundCounter_cumulative_noContextAttributes() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedLongCounter counter = + (ExtendedLongCounter) meter.counterBuilder("bound_counter_no_ctx").build(); + BoundLongCounter bound = counter.bind(Attributes.of(stringKey("k_bound"), "bound")); + + bound.add(10); + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_counter_no_ctx") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(10) + .hasAttributes(attributeEntry("k_bound", "bound"))))); + } + + @Test + void contextScopedAttributes_boundCounter_delta_noContextAttributes() { + InMemoryMetricReader reader = InMemoryMetricReader.createDelta(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedLongCounter counter = + (ExtendedLongCounter) meter.counterBuilder("bound_counter_delta_no_ctx").build(); + BoundLongCounter bound = counter.bind(Attributes.of(stringKey("k_bound"), "bound")); + + bound.add(7); + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_counter_delta_no_ctx") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(7) + .hasAttributes(attributeEntry("k_bound", "bound"))))); + } + + @Test + void contextScopedAttributes_boundDoubleCounter_cumulative() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedDoubleCounter counter = + (ExtendedDoubleCounter) meter.counterBuilder("bound_double_cumulative").ofDoubles().build(); + BoundDoubleCounter bound = + counter.bind( + Attributes.of(stringKey("k_bound"), "bound", stringKey("k_clash"), "bound-win")); + + bound.add(1.5); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k_ctx"), "ctx", stringKey("k_clash"), "ctx-lose")); + try (Scope ignored = ctx.makeCurrent()) { + bound.add(2.5); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_double_cumulative") + .hasDoubleSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(4.0) + .hasAttributes( + attributeEntry("k_bound", "bound"), + attributeEntry("k_clash", "bound-win"))))); + } + + @Test + void contextScopedAttributes_boundDoubleCounter_delta() { + InMemoryMetricReader reader = InMemoryMetricReader.createDelta(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + ExtendedDoubleCounter counter = + (ExtendedDoubleCounter) meter.counterBuilder("bound_double_delta").ofDoubles().build(); + BoundDoubleCounter bound = + counter.bind( + Attributes.of(stringKey("k_bound"), "bound", stringKey("k_clash"), "bound-win")); + + bound.add(1.5); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k_ctx"), "ctx", stringKey("k_clash"), "ctx-lose")); + try (Scope ignored = ctx.makeCurrent()) { + bound.add(2.5); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("bound_double_delta") + .hasDoubleSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(4.0) + .hasAttributes( + attributeEntry("k_bound", "bound"), + attributeEntry("k_clash", "bound-win"))))); + } + + @Test + void contextScopedAttributes_boundCounter_droppedInstrument() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = + SdkMeterProvider.builder() + .registerMetricReader(reader) + .registerView( + InstrumentSelector.builder().setName("dropped_*").build(), + View.builder().setAggregation(Aggregation.drop()).build()); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + ExtendedLongCounter longCounter = + (ExtendedLongCounter) meter.counterBuilder("dropped_long").build(); + BoundLongCounter boundLong = longCounter.bind(Attributes.empty()); + boundLong.add(10); + + ExtendedDoubleCounter doubleCounter = + (ExtendedDoubleCounter) meter.counterBuilder("dropped_double").ofDoubles().build(); + BoundDoubleCounter boundDouble = doubleCounter.bind(Attributes.empty()); + boundDouble.add(10.5); + + assertThat(reader.collectAllMetrics()).isEmpty(); + } + + @Test + void contextScopedAttributes_unboundCounter_emptyAttributesUsesContextAttributesDirectly() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + LongCounter counter = meter.counterBuilder("counter_empty_attrs").build(); + + Context ctx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("k_ctx"), "ctx")); + try (Scope ignored = ctx.makeCurrent()) { + counter.add(1, Attributes.empty()); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("counter_empty_attrs") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(1) + .hasAttributes(attributeEntry("k_ctx", "ctx"))))); + } + + @Test + void contextScopedAttributes_doubleCounter_nanDropped() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + DoubleCounter counter = meter.counterBuilder("double_nan").ofDoubles().build(); + + Context ctx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("k_ctx"), "ctx")); + try (Scope ignored = ctx.makeCurrent()) { + counter.add(Double.NaN, Attributes.empty()); + } + + ExtendedDoubleCounter extendedCounter = (ExtendedDoubleCounter) counter; + BoundDoubleCounter bound = extendedCounter.bind(Attributes.empty()); + try (Scope ignored = ctx.makeCurrent()) { + bound.add(Double.NaN); + } + + assertThat(reader.collectAllMetrics()).isEmpty(); + } + + @Test + void contextScopedAttributes_boundAndUnbound_isolationInSameContext() { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProviderBuilder builder = SdkMeterProvider.builder().registerMetricReader(reader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkMeterProvider provider = builder.build(); + Meter meter = provider.get("test"); + + LongCounter unboundCounter = meter.counterBuilder("unbound_requests").build(); + ExtendedLongCounter extendedCounter = + (ExtendedLongCounter) meter.counterBuilder("bound_infra").build(); + BoundLongCounter boundCounter = + extendedCounter.bind(Attributes.of(stringKey("pool"), "shared-infra")); + + Context ctx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("tenant.id"), "acme")); + + try (Scope ignored = ctx.makeCurrent()) { + unboundCounter.add(1, Attributes.of(stringKey("route"), "/api")); + boundCounter.add(1); + } + + assertThat(reader.collectAllMetrics()) + .satisfiesExactlyInAnyOrder( + metric -> + assertThat(metric) + .hasName("unbound_requests") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(1) + .hasAttributes( + attributeEntry("route", "/api"), + attributeEntry("tenant.id", "acme")))), + metric -> + assertThat(metric) + .hasName("bound_infra") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(1) + .hasAttributes(attributeEntry("pool", "shared-infra"))))); + } +} diff --git a/sdk/testing/src/test/java/io/opentelemetry/sdk/testing/context/ContextScopedAttributesIntegrationTest.java b/sdk/testing/src/test/java/io/opentelemetry/sdk/testing/context/ContextScopedAttributesIntegrationTest.java new file mode 100644 index 00000000000..4f6caf9f877 --- /dev/null +++ b/sdk/testing/src/test/java/io/opentelemetry/sdk/testing/context/ContextScopedAttributesIntegrationTest.java @@ -0,0 +1,245 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.testing.context; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.attributeEntry; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.SdkLoggerProviderBuilder; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.SimpleLogRecordProcessor; +import io.opentelemetry.sdk.logs.internal.SdkLoggerProviderUtil; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.SdkMeterProviderBuilder; +import io.opentelemetry.sdk.metrics.internal.SdkMeterProviderUtil; +import io.opentelemetry.sdk.testing.exporter.InMemoryLogRecordExporter; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.internal.SdkTracerProviderUtil; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ContextScopedAttributesIntegrationTest { + + private InMemorySpanExporter spanExporter; + private InMemoryLogRecordExporter logExporter; + private InMemoryMetricReader metricReader; + + private Tracer tracer; + private Logger logger; + private LongCounter counter; + + @BeforeEach + void setUp() { + spanExporter = InMemorySpanExporter.create(); + logExporter = InMemoryLogRecordExporter.create(); + metricReader = InMemoryMetricReader.create(); + + SdkTracerProviderBuilder tracerProviderBuilder = + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(spanExporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(tracerProviderBuilder, true); + + SdkLoggerProviderBuilder loggerProviderBuilder = + SdkLoggerProvider.builder() + .addLogRecordProcessor(SimpleLogRecordProcessor.create(logExporter)); + SdkLoggerProviderUtil.setContextScopedAttributesEnabled(loggerProviderBuilder, true); + + SdkMeterProviderBuilder meterProviderBuilder = + SdkMeterProvider.builder().registerMetricReader(metricReader); + SdkMeterProviderUtil.setContextScopedAttributesEnabled(meterProviderBuilder, true); + + OpenTelemetrySdk sdk = + OpenTelemetrySdk.builder() + .setTracerProvider(tracerProviderBuilder.build()) + .setLoggerProvider(loggerProviderBuilder.build()) + .setMeterProvider(meterProviderBuilder.build()) + .build(); + + tracer = sdk.getTracer("integration-test"); + logger = sdk.getLogsBridge().get("integration-test"); + Meter meter = sdk.getMeter("integration-test"); + counter = meter.counterBuilder("orders_total").build(); + } + + @Test + void multiSignalPropagation_inheritsContextAttributesAcrossAllSignals() { + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("tenant.id"), "t-123", stringKey("environment"), "production")); + + try (Scope ignored = ctx.makeCurrent()) { + Span span = + tracer + .spanBuilder("process-checkout") + .setAttribute("operation.type", "checkout") + .startSpan(); + span.end(); + + logger + .logRecordBuilder() + .setBody("Order placed") + .setAttribute(stringKey("order.status"), "SUCCESS") + .emit(); + + counter.add(1, Attributes.of(stringKey("payment.method"), "CREDIT_CARD")); + } + + // 1. Verify Trace Span + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasAttributes( + Attributes.of( + stringKey("tenant.id"), + "t-123", + stringKey("environment"), + "production", + stringKey("operation.type"), + "checkout")); + + // 2. Verify Log Record + List logs = logExporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getBodyValue().asString()).isEqualTo("Order placed"); + assertThat(logs.get(0).getAttributes()) + .isEqualTo( + Attributes.of( + stringKey("tenant.id"), + "t-123", + stringKey("environment"), + "production", + stringKey("order.status"), + "SUCCESS")); + + // 3. Verify Metric Measurement + assertThat(metricReader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("orders_total") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(1) + .hasAttributes( + attributeEntry("tenant.id", "t-123"), + attributeEntry("environment", "production"), + attributeEntry("payment.method", "CREDIT_CARD"))))); + } + + @Test + void multiSignalPropagation_explicitAttributesOverrideContextAttributes() { + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of( + stringKey("tenant.id"), "context-tenant", stringKey("shared.key"), "from-context")); + + try (Scope ignored = ctx.makeCurrent()) { + tracer + .spanBuilder("span-with-override") + .setAttribute("shared.key", "span-explicit") + .startSpan() + .end(); + + logger + .logRecordBuilder() + .setBody("log-with-override") + .setAttribute(stringKey("shared.key"), "log-explicit") + .emit(); + + counter.add(5, Attributes.of(stringKey("shared.key"), "metric-explicit")); + } + + // Trace: span-explicit wins + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)) + .hasAttributes( + Attributes.of( + stringKey("tenant.id"), + "context-tenant", + stringKey("shared.key"), + "span-explicit")); + + // Log: log-explicit wins + List logs = logExporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getAttributes()) + .isEqualTo( + Attributes.of( + stringKey("tenant.id"), "context-tenant", stringKey("shared.key"), "log-explicit")); + + // Metric: metric-explicit wins + assertThat(metricReader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("orders_total") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> + point + .hasValue(5) + .hasAttributes( + attributeEntry("tenant.id", "context-tenant"), + attributeEntry("shared.key", "metric-explicit"))))); + } + + @Test + void outsideScope_signalsDoNotInheritContextAttributes() { + Context ctx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("tenant.id"), "t-999")); + + try (Scope ignored = ctx.makeCurrent()) { + // inside scope + } + + // Outside scope: emitted signals must NOT inherit "tenant.id" + tracer.spanBuilder("outside-span").startSpan().end(); + logger.logRecordBuilder().setBody("outside-log").emit(); + counter.add(1); + + List spans = spanExporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0).getAttributes()).isEmpty(); + + List logs = logExporter.getFinishedLogRecordItems(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0).getAttributes()).isEmpty(); + + assertThat(metricReader.collectAllMetrics()) + .satisfiesExactly( + metric -> + assertThat(metric) + .hasName("orders_total") + .hasLongSumSatisfying( + sum -> + sum.hasPointsSatisfying( + point -> point.hasValue(1).hasAttributes(Attributes.empty())))); + } +} diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/IncubatingUtil.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/IncubatingUtil.java index c7ab15e9c2e..d8304f652af 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/IncubatingUtil.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/IncubatingUtil.java @@ -5,6 +5,9 @@ package io.opentelemetry.sdk.trace; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.context.Context; import io.opentelemetry.sdk.common.InstrumentationScopeInfo; import io.opentelemetry.sdk.trace.internal.TracerConfig; @@ -32,4 +35,8 @@ static SdkSpanBuilder createExtendedSpanBuilder( return new ExtendedSdkSpanBuilder( spanName, instrumentationScopeInfo, tracerSharedState, spanLimits); } + + static Attributes getContextAttributes(Context context) { + return ContextAttributes.get(context); + } } diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java index 050067db815..6aa85745481 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkSpanBuilder.java @@ -55,6 +55,7 @@ class SdkSpanBuilder implements SpanBuilder { private final SpanLimits spanLimits; @Nullable private Context parent; // null means: Use current context. + private boolean noParent = false; private SpanKind spanKind = SpanKind.INTERNAL; @Nullable private AttributesMap attributes; @Nullable private List links; @@ -78,12 +79,14 @@ public SpanBuilder setParent(Context context) { return this; } this.parent = context; + this.noParent = false; return this; } @Override public SpanBuilder setNoParent() { this.parent = Context.root(); + this.noParent = true; return this; } @@ -181,6 +184,15 @@ public SpanBuilder setStartTimestamp(long startTimestamp, TimeUnit unit) { @SuppressWarnings({"rawtypes", "ReferenceEquality"}) public Span startSpan() { Context parentContext = parent == null ? Context.current() : parent; + if (noParent + && tracerSharedState.isContextScopedAttributesEnabled() + && SdkTracer.INCUBATOR_AVAILABLE) { + Context current = Context.current(); + parentContext = + Span.fromContext(current) == Span.getInvalid() + ? current + : current.with(Span.getInvalid()); + } Span parentSpan = Span.fromContext(parentContext); SpanContext parentSpanContext = parentSpan.getSpanContext(); String traceId; @@ -215,6 +227,18 @@ public Span startSpan() { // Avoid any possibility to modify the links list by adding links to the Builder after the // startSpan is called. If that happens all the links will be added in a new list. links = null; + if (tracerSharedState.isContextScopedAttributesEnabled() && SdkTracer.INCUBATOR_AVAILABLE) { + Attributes contextAttributes = IncubatingUtil.getContextAttributes(parentContext); + if (!contextAttributes.isEmpty()) { + AttributesMap spanAttributes = attributes(); + contextAttributes.forEach( + (key, value) -> { + if (spanAttributes.get(key) == null) { + spanAttributes.put((AttributeKey) key, value); + } + }); + } + } Attributes immutableAttributes = attributes == null ? Attributes.empty() : attributes; SamplingResult samplingResult = tracerSharedState diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracer.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracer.java index bac55710884..e68a5332720 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracer.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracer.java @@ -15,7 +15,7 @@ class SdkTracer implements Tracer { static final String FALLBACK_SPAN_NAME = ""; private static final Tracer NOOP_TRACER = TracerProvider.noop().get("noop"); - private static final boolean INCUBATOR_AVAILABLE; + static final boolean INCUBATOR_AVAILABLE; static { boolean incubatorAvailable = false; diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProvider.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProvider.java index 84d9230bc6e..c4ff1b53ae3 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProvider.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProvider.java @@ -56,7 +56,8 @@ public static SdkTracerProviderBuilder builder() { List spanProcessors, ScopeConfigurator tracerConfigurator, ExceptionAttributeResolver exceptionAttributeResolver, - Supplier meterProvider) { + Supplier meterProvider, + boolean contextScopedAttributesEnabled) { this.sharedState = new TracerSharedState( clock, @@ -66,7 +67,8 @@ public static SdkTracerProviderBuilder builder() { sampler, spanProcessors, exceptionAttributeResolver, - new SdkTracerInstrumentation(meterProvider)); + new SdkTracerInstrumentation(meterProvider), + contextScopedAttributesEnabled); this.tracerSdkComponentRegistry = new ComponentRegistry<>( instrumentationScopeInfo -> diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProviderBuilder.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProviderBuilder.java index b0e30b13c52..4ffdad43b63 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProviderBuilder.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/SdkTracerProviderBuilder.java @@ -40,6 +40,7 @@ public final class SdkTracerProviderBuilder { private ExceptionAttributeResolver exceptionAttributeResolver = ExceptionAttributeResolver.getDefault(); private Supplier meterProvider = MeterProvider::noop; + private boolean contextScopedAttributesEnabled; /** * Assign a {@link Clock}. {@link Clock} will be used each time a {@link Span} is started, ended @@ -247,6 +248,18 @@ public SdkTracerProviderBuilder setMeterProvider(Supplier meterPr return this; } + /** + * Sets whether context-scoped attributes are automatically inherited by spans. + * + *

This method is experimental so not public. You may reflectively call it using {@link + * SdkTracerProviderUtil#setContextScopedAttributesEnabled(SdkTracerProviderBuilder, boolean)}. + */ + SdkTracerProviderBuilder setContextScopedAttributesEnabled( + boolean contextScopedAttributesEnabled) { + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; + return this; + } + /** * Create a new {@link SdkTracerProvider} instance with the configuration. * @@ -262,7 +275,8 @@ public SdkTracerProvider build() { spanProcessors, tracerConfiguratorBuilder.build(), exceptionAttributeResolver, - meterProvider); + meterProvider, + contextScopedAttributesEnabled); } SdkTracerProviderBuilder() {} diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/TracerSharedState.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/TracerSharedState.java index 6c2e32a34dd..4dfc8ccbf95 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/TracerSharedState.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/TracerSharedState.java @@ -30,6 +30,7 @@ final class TracerSharedState { private final SpanProcessor activeSpanProcessor; private final ExceptionAttributeResolver exceptionAttributeResolver; private final SdkTracerInstrumentation tracerInstrumentation; + private final boolean contextScopedAttributesEnabled; private final boolean noSpanProcessor; @Nullable private volatile CompletableResultCode shutdownResult = null; @@ -42,7 +43,8 @@ final class TracerSharedState { Sampler sampler, List spanProcessors, ExceptionAttributeResolver exceptionAttributeResolver, - SdkTracerInstrumentation tracerInstrumentation) { + SdkTracerInstrumentation tracerInstrumentation, + boolean contextScopedAttributesEnabled) { this.clock = clock; this.idGenerator = idGenerator; this.idGeneratorSafeToSkipIdValidation = idGenerator instanceof RandomIdGenerator; @@ -52,6 +54,7 @@ final class TracerSharedState { this.activeSpanProcessor = SpanProcessor.composite(spanProcessors); this.exceptionAttributeResolver = exceptionAttributeResolver; this.tracerInstrumentation = tracerInstrumentation; + this.contextScopedAttributesEnabled = contextScopedAttributesEnabled; // Note: This relies on SpanProcessor.composite(..) returning NoopSpanProcessor unwrapped // when given an empty list. If SpanProcessor.composite(..) behavior changes, this instanceof // check must change too @@ -115,6 +118,10 @@ SdkTracerInstrumentation getTracerInstrumentation() { return tracerInstrumentation; } + boolean isContextScopedAttributesEnabled() { + return contextScopedAttributesEnabled; + } + /** * Stops tracing, including shutting down processors and sampler and set to {@code true} {@link * #hasBeenShutdown()}. diff --git a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/internal/SdkTracerProviderUtil.java b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/internal/SdkTracerProviderUtil.java index b9a5a357f98..e33c26673d2 100644 --- a/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/internal/SdkTracerProviderUtil.java +++ b/sdk/trace/src/main/java/io/opentelemetry/sdk/trace/internal/SdkTracerProviderUtil.java @@ -89,4 +89,22 @@ public static void setExceptionAttributeResolver( "Error calling setExceptionAttributeResolver on SdkTracerProviderBuilder", e); } } + + /** + * Reflectively set whether context-scoped attributes are enabled on the {@link + * SdkTracerProviderBuilder}. + */ + public static void setContextScopedAttributesEnabled( + SdkTracerProviderBuilder sdkTracerProviderBuilder, boolean contextScopedAttributesEnabled) { + try { + Method method = + SdkTracerProviderBuilder.class.getDeclaredMethod( + "setContextScopedAttributesEnabled", boolean.class); + method.setAccessible(true); + method.invoke(sdkTracerProviderBuilder, contextScopedAttributesEnabled); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new IllegalStateException( + "Error calling setContextScopedAttributesEnabled on SdkTracerProviderBuilder", e); + } + } } diff --git a/sdk/trace/src/testIncubating/java/io/opentelemetry/sdk/trace/ContextScopedAttributesSpanTest.java b/sdk/trace/src/testIncubating/java/io/opentelemetry/sdk/trace/ContextScopedAttributesSpanTest.java new file mode 100644 index 00000000000..724919f5f01 --- /dev/null +++ b/sdk/trace/src/testIncubating/java/io/opentelemetry/sdk/trace/ContextScopedAttributesSpanTest.java @@ -0,0 +1,299 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.sdk.trace; + +import static io.opentelemetry.api.common.AttributeKey.stringKey; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.incubator.common.ContextAttributes; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.data.LinkData; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import io.opentelemetry.sdk.trace.internal.SdkTracerProviderUtil; +import io.opentelemetry.sdk.trace.samplers.Sampler; +import io.opentelemetry.sdk.trace.samplers.SamplingResult; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ContextScopedAttributesSpanTest { + + @Test + void contextScopedAttributes_disabledByDefault() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + SdkTracerProvider provider = + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)).build(); + Tracer tracer = provider.get("test"); + + Context ctx = ContextAttributes.add(Context.current(), Attributes.of(stringKey("k1"), "v1")); + try (Scope ignored = ctx.makeCurrent()) { + Span span = tracer.spanBuilder("span").startSpan(); + span.end(); + } + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0).getAttributes()).isEmpty(); + } + + @Test + void contextScopedAttributes_enabled_mergesBeforeSamplerWithExplicitPrecedence() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + AtomicReference sampledAttributes = new AtomicReference<>(); + Sampler inspectSampler = + new Sampler() { + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + sampledAttributes.set(attributes); + return SamplingResult.recordAndSample(); + } + + @Override + public String getDescription() { + return "inspectSampler"; + } + }; + + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder() + .setSampler(inspectSampler) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Context ctx = + ContextAttributes.add( + Context.current(), + Attributes.of(stringKey("k1"), "from-context", stringKey("k2"), "from-context")); + try (Scope ignored = ctx.makeCurrent()) { + Span span = + tracer + .spanBuilder("span") + .setAttribute("k2", "explicit") + .setAttribute("k3", "v3") + .startSpan(); + span.end(); + } + + Attributes expected = + Attributes.of( + stringKey("k1"), "from-context", stringKey("k2"), "explicit", stringKey("k3"), "v3"); + assertThat(sampledAttributes.get().asMap()).isEqualTo(expected.asMap()); + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)).hasAttributes(expected); + } + + @Test + void contextScopedAttributes_usesExplicitParentContext() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Context explicitParent = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("parent"), "explicit")); + Context currentCtx = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("parent"), "current")); + + try (Scope ignored = currentCtx.makeCurrent()) { + Span span = tracer.spanBuilder("span").setParent(explicitParent).startSpan(); + span.end(); + } + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + assertThat(spans.get(0)).hasAttributes(Attributes.of(stringKey("parent"), "explicit")); + } + + @Test + void contextScopedAttributes_setNoParent_preservesAmbientContextAttributes() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Span parent = tracer.spanBuilder("parent").startSpan(); + try (Scope parentScope = parent.makeCurrent()) { + Context currentCtx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("scope"), "current")); + + try (Scope ignored = currentCtx.makeCurrent()) { + Span span = tracer.spanBuilder("span").setNoParent().startSpan(); + span.end(); + } + } finally { + parent.end(); + } + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(2); + SpanData child = spans.get(0); + assertThat(child).hasAttributes(Attributes.of(stringKey("scope"), "current")); + assertThat(child.getParentSpanContext().isValid()).isFalse(); + assertThat(child.getSpanContext().getTraceId()) + .isNotEqualTo(parent.getSpanContext().getTraceId()); + } + + @Test + void contextScopedAttributes_explicitParentRoot_dropsContextAttributes() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder().addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Context currentCtx = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("scope"), "current")); + + try (Scope ignored = currentCtx.makeCurrent()) { + Span span = tracer.spanBuilder("span").setParent(Context.root()).startSpan(); + span.end(); + } + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + SpanData spanData = spans.get(0); + assertThat(spanData.getAttributes()).isEmpty(); + assertThat(spanData.getParentSpanContext().isValid()).isFalse(); + } + + @Test + void contextScopedAttributes_spanAttributeCountLimit() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder() + .setSpanLimits(SpanLimits.builder().setMaxNumberOfAttributes(1).build()) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Context ctx = + ContextAttributes.add(Context.root(), Attributes.of(stringKey("ctx_attr"), "ctx")); + + try (Scope ignored = ctx.makeCurrent()) { + Span span = tracer.spanBuilder("span").setAttribute("explicit_attr", "explicit").startSpan(); + span.end(); + } + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(1); + SpanData spanData = spans.get(0); + assertThat(spanData).hasAttributes(Attributes.of(stringKey("explicit_attr"), "explicit")); + assertThat(spanData.getTotalAttributeCount()).isEqualTo(2); + } + + @Test + void contextScopedAttributes_setNoParent_preservesContextForSamplerAndProcessor() { + InMemorySpanExporter exporter = InMemorySpanExporter.create(); + AtomicReference samplerParentContext = new AtomicReference<>(); + AtomicReference processorParentContext = new AtomicReference<>(); + + Sampler testSampler = + new Sampler() { + @Override + public SamplingResult shouldSample( + Context parentContext, + String traceId, + String name, + SpanKind spanKind, + Attributes attributes, + List parentLinks) { + samplerParentContext.set(parentContext); + return SamplingResult.recordAndSample(); + } + + @Override + public String getDescription() { + return "testSampler"; + } + }; + + SpanProcessor testProcessor = + new SpanProcessor() { + @Override + public void onStart(Context parentContext, ReadWriteSpan span) { + processorParentContext.set(parentContext); + } + + @Override + public boolean isStartRequired() { + return true; + } + + @Override + public void onEnd(ReadableSpan span) {} + + @Override + public boolean isEndRequired() { + return false; + } + }; + + SdkTracerProviderBuilder builder = + SdkTracerProvider.builder() + .setSampler(testSampler) + .addSpanProcessor(testProcessor) + .addSpanProcessor(SimpleSpanProcessor.create(exporter)); + SdkTracerProviderUtil.setContextScopedAttributesEnabled(builder, true); + SdkTracerProvider provider = builder.build(); + Tracer tracer = provider.get("test"); + + Span parent = tracer.spanBuilder("parent").startSpan(); + try (Scope parentScope = parent.makeCurrent()) { + Context currentCtx = + ContextAttributes.add(Context.current(), Attributes.of(stringKey("tenant.id"), "acme")); + + try (Scope ignored = currentCtx.makeCurrent()) { + Span span = tracer.spanBuilder("span").setNoParent().startSpan(); + span.end(); + } + } finally { + parent.end(); + } + + Context capturedSamplerCtx = samplerParentContext.get(); + assertThat(capturedSamplerCtx).isNotNull(); + assertThat(ContextAttributes.get(capturedSamplerCtx)) + .isEqualTo(Attributes.of(stringKey("tenant.id"), "acme")); + assertThat(Span.fromContext(capturedSamplerCtx).getSpanContext().isValid()).isFalse(); + + Context capturedProcessorCtx = processorParentContext.get(); + assertThat(capturedProcessorCtx).isNotNull(); + assertThat(ContextAttributes.get(capturedProcessorCtx)) + .isEqualTo(Attributes.of(stringKey("tenant.id"), "acme")); + assertThat(Span.fromContext(capturedProcessorCtx).getSpanContext().isValid()).isFalse(); + + List spans = exporter.getFinishedSpanItems(); + assertThat(spans).hasSize(2); + SpanData child = spans.get(0); + assertThat(child).hasAttributes(Attributes.of(stringKey("tenant.id"), "acme")); + assertThat(child.getParentSpanContext().isValid()).isFalse(); + assertThat(child.getSpanContext().getTraceId()) + .isNotEqualTo(parent.getSpanContext().getTraceId()); + } +}