diff --git a/README.md b/README.md
index c7e749bc279..4d1b5c244f3 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,8 @@ Extensions to the OpenTelemetry SDK.
| [SDK Autoconfigure](./sdk-extensions/autoconfigure) | Autoconfigure OpenTelemetry SDK from env vars, system properties, and SPI | `opentelemetry-sdk-extension-autoconfigure` | 1.68.0 | [](https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-autoconfigure) |
| [SDK Autoconfigure SPI](./sdk-extensions/autoconfigure-spi) | Service Provider Interface (SPI) definitions for autoconfigure | `opentelemetry-sdk-extension-autoconfigure-spi` | 1.68.0 | [](https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-autoconfigure-spi) |
| [SDK Declarative Config](./sdk-extensions/declarative-config) | Declarative config implementation for YAML-based SDK configuration | `opentelemetry-sdk-extension-declarative-config` | 1.68.0-alpha | [](https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-declarative-config) |
+| [SDK Declarative Config Jackson 2 Mapper](./sdk-extensions/declarative-config-jackson2) | Jackson 2 model mapper for declarative config | `opentelemetry-sdk-extension-declarative-config-jackson2` | TODO | TODO |
+| [SDK Declarative Config SnakeYAML Engine 2 Parser](./sdk-extensions/declarative-config-snakeyaml-engine2) | SnakeYAML Engine 2 YAML parser for declarative config | `opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2` | TODO | TODO |
| [SDK Jaeger Remote Sampler Extension](./sdk-extensions/jaeger-remote-sampler) | Sampler which obtains sampling configuration from remote Jaeger server | `opentelemetry-sdk-extension-jaeger-remote-sampler` | 1.68.0 | [](https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-jaeger-remote-sampler) |
| [SDK Incubator](./sdk-extensions/incubator) | SDK incubator, including YAML based view configuration, LeakDetectingSpanProcessor | `opentelemetry-sdk-extension-incubator` | 1.68.0-alpha | [](https://www.javadoc.io/doc/io.opentelemetry/opentelemetry-sdk-extension-incubator) |
diff --git a/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java b/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java
index 98fd2426e2b..cc1ce240c4e 100644
--- a/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java
+++ b/all/src/test/java/io/opentelemetry/all/NoSharedInternalCodeTest.java
@@ -45,6 +45,8 @@ class NoSharedInternalCodeTest {
"opentelemetry-sdk-trace",
"opentelemetry-sdk-extension-autoconfigure",
"opentelemetry-sdk-extension-declarative-config",
+ "opentelemetry-sdk-extension-declarative-config-jackson2",
+ "opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2",
"opentelemetry-sdk-extension-incubator",
"opentelemetry-sdk-extension-jaeger-remote-sampler",
"opentelemetry-exporter-otlp",
diff --git a/integration-tests/osgi/build.gradle.kts b/integration-tests/osgi/build.gradle.kts
index 9a4dac688f0..3db229d7b2c 100644
--- a/integration-tests/osgi/build.gradle.kts
+++ b/integration-tests/osgi/build.gradle.kts
@@ -285,7 +285,7 @@ val autoconfigureSuiteTask = registerOsgiSuite(
}
// Suite: autoconfigure + declarative-config. Same as above but with declarative-config on the
-// classpath, additionally exercising declarative-config bundle OSGi metadata.
+// classpath, additionally exercising its SnakeYAML parser and Jackson mapper OSGi metadata.
val autoconfigureDeclarativeConfigSuiteTask = registerOsgiSuite(
"autoconfigureDeclarativeConfig",
extraRunrequires = listOf(
@@ -293,6 +293,8 @@ val autoconfigureDeclarativeConfigSuiteTask = registerOsgiSuite(
"opentelemetry-exporter-otlp",
"opentelemetry-extension-trace-propagators",
"opentelemetry-sdk-extension-declarative-config",
+ "opentelemetry-sdk-extension-declarative-config-jackson2",
+ "opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2",
),
serviceLoaderProvides = listOf(
"io.opentelemetry.sdk.autoconfigure.declarativeconfig.DeclarativeConfigurationCustomizerProvider",
diff --git a/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationMapper.java b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationMapper.java
new file mode 100644
index 00000000000..2803c9c940d
--- /dev/null
+++ b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationMapper.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.spi.internal;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * Converts parsed configuration values to declarative configuration models and generic maps.
+ *
+ *
Implementations must preserve the models' mapping semantics, including extension properties
+ * and null values.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public interface DeclarativeConfigurationMapper {
+
+ /**
+ * Convert a value to an instance of {@code targetType}.
+ *
+ * @param value the value to convert
+ * @param targetType the target type
+ * @param the target type
+ * @return the converted value
+ * @throws DeclarativeConfigException if the value cannot be converted to {@code targetType}
+ */
+ T convert(Object value, Class targetType);
+
+ /**
+ * Convert a model or parsed configuration value to a map with string keys.
+ *
+ * @param value the value to convert
+ * @return the converted map, or null if the input value is null
+ * @throws DeclarativeConfigException if the value cannot be converted to a map
+ */
+ @Nullable
+ Map toMap(Object value);
+}
diff --git a/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationYamlParser.java b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationYamlParser.java
new file mode 100644
index 00000000000..9b975cde82b
--- /dev/null
+++ b/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/internal/DeclarativeConfigurationYamlParser.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.spi.internal;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import java.io.InputStream;
+import java.util.Map;
+
+/**
+ * Parses a declarative configuration YAML stream into generic Java objects.
+ *
+ * Implementations must apply environment variable and system property substitution according to
+ * the OpenTelemetry Configuration Data Model specification. Substitution is performed on scalar
+ * values, not mapping keys. For unquoted scalar values, substituted text must be parsed using the
+ * same YAML scalar rules as the original text.
+ *
+ *
This class is internal and is hence not for public use. Its APIs are unstable and can change
+ * at any time.
+ */
+public interface DeclarativeConfigurationYamlParser {
+
+ /**
+ * Parse a YAML stream.
+ *
+ * @param configuration the YAML input stream
+ * @param environmentVariables environment variables used for substitution
+ * @param systemProperties system properties used for substitution
+ * @return the parsed configuration
+ * @throws DeclarativeConfigException if the YAML cannot be parsed
+ */
+ Object parse(
+ InputStream configuration,
+ Map environmentVariables,
+ Map systemProperties);
+}
diff --git a/sdk-extensions/declarative-config-jackson2/README.md b/sdk-extensions/declarative-config-jackson2/README.md
new file mode 100644
index 00000000000..041479e874b
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/README.md
@@ -0,0 +1,9 @@
+# Declarative Configuration Jackson 2 Mapper
+
+This extension provides a Jackson 2 implementation of the `DeclarativeConfigurationMapper` SPI.
+It is included as a runtime dependency by `opentelemetry-sdk-extension-declarative-config` from
+the `opentelemetry-sdk-extension-declarative-config-jackson2` artifact.
+
+Projects using another mapper can exclude this artifact from the declarative-config dependency and
+register their own `DeclarativeConfigurationMapper` provider. For example, a project using Jackson 3
+can implement the SPI with its Jackson 3 `ObjectMapper`.
diff --git a/sdk-extensions/declarative-config-jackson2/build.gradle.kts b/sdk-extensions/declarative-config-jackson2/build.gradle.kts
new file mode 100644
index 00000000000..abac59989a5
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ id("otel.java-conventions")
+ id("otel.publish-conventions")
+ id("otel.animalsniffer-conventions")
+}
+
+description = "OpenTelemetry SDK Declarative Config Jackson 2 Mapper"
+otelJava.moduleName.set("io.opentelemetry.sdk.autoconfigure.declarativeconfig.jackson2")
+otelJava.requireSuppressWarningsExplanation.set(false)
+otelJava.osgiServiceLoaderProvides.set(listOf(
+ "io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper",
+))
+
+dependencies {
+ api(project(":sdk-extensions:autoconfigure-spi"))
+ implementation("com.fasterxml.jackson.core:jackson-databind")
+}
diff --git a/sdk-extensions/declarative-config-jackson2/gradle.properties b/sdk-extensions/declarative-config-jackson2/gradle.properties
new file mode 100644
index 00000000000..4476ae57e31
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/gradle.properties
@@ -0,0 +1 @@
+otel.release=alpha
diff --git a/sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapper.java b/sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapper.java
new file mode 100644
index 00000000000..e546a4e9762
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapper.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.jackson2.internal;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * This class is internal and is hence not for public use. Its APIs are unstable and can change at
+ * any time.
+ */
+public final class JacksonDeclarativeConfigurationMapper implements DeclarativeConfigurationMapper {
+
+ @Override
+ public T convert(Object value, Class targetType) {
+ try {
+ return JacksonModelMapper.MAPPER.convertValue(value, targetType);
+ } catch (IllegalArgumentException e) {
+ throw new DeclarativeConfigException("Unable to convert value to " + targetType, e);
+ }
+ }
+
+ @Nullable
+ @Override
+ public Map toMap(Object value) {
+ try {
+ return JacksonModelMapper.MAPPER.convertValue(
+ value, new TypeReference>() {});
+ } catch (IllegalArgumentException e) {
+ throw new DeclarativeConfigException("Unable to convert value to a map", e);
+ }
+ }
+}
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ModelMapper.java b/sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonModelMapper.java
similarity index 61%
rename from sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ModelMapper.java
rename to sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonModelMapper.java
index 722c2e69f82..11444cf9d98 100644
--- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ModelMapper.java
+++ b/sdk-extensions/declarative-config-jackson2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonModelMapper.java
@@ -3,37 +3,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
-package io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal;
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.jackson2.internal;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
- * Canonical {@link ObjectMapper} configuration for parsing declarative configuration YAML. {@link
- * io.opentelemetry.sdk.autoconfigure.declarativeconfig.DeclarativeConfiguration#MAPPER} delegates
- * to this instance.
- *
- * This class is internal and experimental. Its APIs are unstable and can change at any time. Its
- * APIs (or a version of them) may be promoted to the public stable API in the future, but no
- * guarantees are made.
+ * This class is internal and is hence not for public use. Its APIs are unstable and can change at
+ * any time.
*/
-public final class ModelMapper {
+public final class JacksonModelMapper {
public static final ObjectMapper MAPPER;
static {
MAPPER =
new ObjectMapper()
- // Create empty object instances for keys which are present but have null values
+ // Create empty object instances for keys which are present but have null values.
.setDefaultSetterInfo(JsonSetter.Value.forValueNulls(Nulls.AS_EMPTY));
// Boxed primitives which are present but have null values should be set to null, rather than
- // empty instances
+ // empty instances.
MAPPER.configOverride(String.class).setSetterInfo(JsonSetter.Value.forValueNulls(Nulls.SET));
MAPPER.configOverride(Integer.class).setSetterInfo(JsonSetter.Value.forValueNulls(Nulls.SET));
MAPPER.configOverride(Double.class).setSetterInfo(JsonSetter.Value.forValueNulls(Nulls.SET));
MAPPER.configOverride(Boolean.class).setSetterInfo(JsonSetter.Value.forValueNulls(Nulls.SET));
}
- private ModelMapper() {}
+ private JacksonModelMapper() {}
}
diff --git a/sdk-extensions/declarative-config-jackson2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper b/sdk-extensions/declarative-config-jackson2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper
new file mode 100644
index 00000000000..08b2e458c0a
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper
@@ -0,0 +1 @@
+io.opentelemetry.sdk.autoconfigure.declarativeconfig.jackson2.internal.JacksonDeclarativeConfigurationMapper
diff --git a/sdk-extensions/declarative-config-jackson2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapperTest.java b/sdk-extensions/declarative-config-jackson2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapperTest.java
new file mode 100644
index 00000000000..c04b0064282
--- /dev/null
+++ b/sdk-extensions/declarative-config-jackson2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/jackson2/internal/JacksonDeclarativeConfigurationMapperTest.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.jackson2.internal;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import org.junit.jupiter.api.Test;
+
+class JacksonDeclarativeConfigurationMapperTest {
+
+ private final JacksonDeclarativeConfigurationMapper mapper =
+ new JacksonDeclarativeConfigurationMapper();
+
+ @Test
+ void convertWrapsJacksonException() {
+ assertThatThrownBy(() -> mapper.convert("not-an-integer", Integer.class))
+ .isInstanceOf(DeclarativeConfigException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void toMapWrapsJacksonException() {
+ assertThatThrownBy(() -> mapper.toMap(42))
+ .isInstanceOf(DeclarativeConfigException.class)
+ .hasCauseInstanceOf(IllegalArgumentException.class);
+ }
+}
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/README.md b/sdk-extensions/declarative-config-snakeyaml-engine2/README.md
new file mode 100644
index 00000000000..2f31842550d
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/README.md
@@ -0,0 +1,9 @@
+# SnakeYAML Engine 2 Declarative Configuration Parser
+
+This module provides the default `DeclarativeConfigurationYamlParser` implementation using
+SnakeYAML Engine 2. It is included as a runtime dependency by
+`opentelemetry-sdk-extension-declarative-config`.
+
+Applications that provide a custom YAML parser can exclude this module from the declarative config
+dependency and register their parser with `ServiceLoader`. If neither this adapter nor another
+parser provider is present, YAML parsing fails with a `DeclarativeConfigException`.
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/build.gradle.kts b/sdk-extensions/declarative-config-snakeyaml-engine2/build.gradle.kts
new file mode 100644
index 00000000000..99d83cfe73a
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ id("otel.java-conventions")
+ id("otel.publish-conventions")
+ id("otel.animalsniffer-conventions")
+}
+
+description = "OpenTelemetry SDK Declarative Config SnakeYAML Engine 2 Parser"
+otelJava.moduleName.set("io.opentelemetry.sdk.autoconfigure.declarativeconfig.snakeyamlengine2")
+otelJava.requireSuppressWarningsExplanation.set(false)
+otelJava.osgiServiceLoaderProvides.set(listOf(
+ "io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser",
+))
+
+dependencies {
+ api(project(":sdk-extensions:autoconfigure-spi"))
+ implementation("org.snakeyaml:snakeyaml-engine")
+}
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/gradle.properties b/sdk-extensions/declarative-config-snakeyaml-engine2/gradle.properties
new file mode 100644
index 00000000000..4476ae57e31
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/gradle.properties
@@ -0,0 +1 @@
+otel.release=alpha
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParser.java b/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParser.java
new file mode 100644
index 00000000000..d16a514eb92
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParser.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.snakeyamlengine2.internal;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser;
+import java.io.InputStream;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.MatchResult;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+import org.snakeyaml.engine.v2.api.Load;
+import org.snakeyaml.engine.v2.api.LoadSettings;
+import org.snakeyaml.engine.v2.api.YamlUnicodeReader;
+import org.snakeyaml.engine.v2.common.ScalarStyle;
+import org.snakeyaml.engine.v2.composer.Composer;
+import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
+import org.snakeyaml.engine.v2.nodes.MappingNode;
+import org.snakeyaml.engine.v2.nodes.Node;
+import org.snakeyaml.engine.v2.nodes.ScalarNode;
+import org.snakeyaml.engine.v2.nodes.Tag;
+import org.snakeyaml.engine.v2.parser.ParserImpl;
+import org.snakeyaml.engine.v2.resolver.ScalarResolver;
+import org.snakeyaml.engine.v2.scanner.StreamReader;
+import org.snakeyaml.engine.v2.schema.CoreSchema;
+
+/**
+ * This class is internal and is hence not for public use. Its APIs are unstable and can change at
+ * any time.
+ */
+public final class SnakeYamlDeclarativeConfigurationParser
+ implements DeclarativeConfigurationYamlParser {
+
+ // Matches ${VAR_NAME}, ${env:VAR_NAME}, or ${sys:property.name} with optional :-default
+ private static final Pattern ENV_VARIABLE_REFERENCE =
+ Pattern.compile("\\$\\{(?:(env|sys):)?([a-zA-Z_][a-zA-Z0-9_.]*)(?::-([^\\n}]*))?}");
+
+ private static final String ESCAPE_SEQUENCE = "$$";
+ private static final int ESCAPE_SEQUENCE_LENGTH = ESCAPE_SEQUENCE.length();
+ private static final char ESCAPE_SEQUENCE_REPLACEMENT = '$';
+
+ @Override
+ public Object parse(
+ InputStream configuration,
+ Map environmentVariables,
+ Map systemProperties) {
+ LoadSettings settings = LoadSettings.builder().setSchema(new CoreSchema()).build();
+ Load yaml = new EnvLoad(settings, environmentVariables, systemProperties);
+ try {
+ return yaml.loadFromInputStream(configuration);
+ } catch (YamlEngineException e) {
+ throw new DeclarativeConfigException("Unable to parse configuration input stream", e);
+ }
+ }
+
+ private static final class EnvLoad extends Load {
+
+ private final LoadSettings settings;
+ private final Map environmentVariables;
+ private final Map systemProperties;
+
+ private EnvLoad(
+ LoadSettings settings,
+ Map environmentVariables,
+ Map systemProperties) {
+ super(settings);
+ this.settings = settings;
+ this.environmentVariables = environmentVariables;
+ this.systemProperties = systemProperties;
+ }
+
+ @Override
+ public Object loadFromInputStream(InputStream yamlStream) {
+ Objects.requireNonNull(yamlStream, "InputStream cannot be null");
+ return loadOne(
+ new EnvComposer(
+ settings,
+ new ParserImpl(
+ settings, new StreamReader(settings, new YamlUnicodeReader(yamlStream))),
+ environmentVariables,
+ systemProperties));
+ }
+ }
+
+ /**
+ * A YAML Composer that performs environment variable and system property substitution according
+ * to the OpenTelemetry Configuration Data Model specification.
+ */
+ private static final class EnvComposer extends Composer {
+
+ private final Load load;
+ private final Map environmentVariables;
+ private final Map systemProperties;
+ private final ScalarResolver scalarResolver;
+
+ private EnvComposer(
+ LoadSettings settings,
+ ParserImpl parser,
+ Map environmentVariables,
+ Map systemProperties) {
+ super(settings, parser);
+ this.load = new Load(settings);
+ this.environmentVariables = environmentVariables;
+ this.systemProperties = systemProperties;
+ this.scalarResolver = settings.getSchema().getScalarResolver();
+ }
+
+ @Override
+ protected Node composeValueNode(MappingNode node) {
+ Node itemValue = super.composeValueNode(node);
+ if (!(itemValue instanceof ScalarNode)) {
+ // Only apply environment variable substitution to ScalarNodes
+ return itemValue;
+ }
+ ScalarNode scalarNode = (ScalarNode) itemValue;
+ String envSubstitution = envSubstitution(scalarNode.getValue());
+
+ // If the environment variable substitution does not change the value, do not modify the node
+ if (envSubstitution.equals(scalarNode.getValue())) {
+ return itemValue;
+ }
+
+ Object envSubstitutionObj = load.loadFromString(envSubstitution);
+ Tag tag = itemValue.getTag();
+ ScalarStyle scalarStyle = scalarNode.getScalarStyle();
+
+ Tag resolvedTag =
+ envSubstitutionObj == null
+ ? Tag.NULL
+ : scalarResolver.resolve(envSubstitutionObj.toString(), true);
+
+ // Only non-quoted substituted scalars can have their tag changed
+ if (!itemValue.getTag().equals(resolvedTag)
+ && scalarStyle != ScalarStyle.SINGLE_QUOTED
+ && scalarStyle != ScalarStyle.DOUBLE_QUOTED) {
+ tag = resolvedTag;
+ }
+
+ boolean resolved = true;
+ return new ScalarNode(
+ tag,
+ resolved,
+ envSubstitution,
+ scalarStyle,
+ itemValue.getStartMark(),
+ itemValue.getEndMark());
+ }
+
+ private String envSubstitution(String val) {
+ // Substitute each portion between escaped dollar signs independently. Each "$$" becomes
+ // one literal "$" and prevents substitution in the adjacent portion.
+ int lastEscapeIndexEnd = 0;
+ StringBuilder newVal = null;
+ while (true) {
+ int escapeIndex = val.indexOf(ESCAPE_SEQUENCE, lastEscapeIndexEnd);
+ int substitutionEndIndex = escapeIndex == -1 ? val.length() : escapeIndex;
+ newVal = envVarSubstitution(newVal, val, lastEscapeIndexEnd, substitutionEndIndex);
+ if (escapeIndex == -1) {
+ break;
+ } else {
+ newVal.append(ESCAPE_SEQUENCE_REPLACEMENT);
+ }
+ lastEscapeIndexEnd = escapeIndex + ESCAPE_SEQUENCE_LENGTH;
+ if (lastEscapeIndexEnd >= val.length()) {
+ break;
+ }
+ }
+
+ return newVal.toString();
+ }
+
+ private StringBuilder envVarSubstitution(
+ @Nullable StringBuilder newVal, String source, int startIndex, int endIndex) {
+ String val = source.substring(startIndex, endIndex);
+ Matcher matcher = ENV_VARIABLE_REFERENCE.matcher(val);
+
+ if (!matcher.find()) {
+ return newVal == null ? new StringBuilder(val) : newVal.append(val);
+ }
+
+ if (newVal == null) {
+ newVal = new StringBuilder();
+ }
+
+ int offset = 0;
+ do {
+ MatchResult matchResult = matcher.toMatchResult();
+ String prefix = matcher.group(1); // "env", "sys", or null
+ String key = matcher.group(2); // variable/property name
+ String defaultValue = matcher.group(3);
+ if (defaultValue == null) {
+ defaultValue = "";
+ }
+
+ String replacement;
+ if ("sys".equals(prefix)) {
+ // System property substitution
+ Object sysProp = systemProperties.get(key);
+ replacement = sysProp != null ? sysProp.toString() : defaultValue;
+ } else {
+ // Environment variable substitution (default or explicit "env:" prefix)
+ replacement = environmentVariables.getOrDefault(key, defaultValue);
+ }
+
+ newVal.append(val, offset, matchResult.start()).append(replacement);
+ offset = matchResult.end();
+ } while (matcher.find());
+ if (offset != val.length()) {
+ newVal.append(val, offset, val.length());
+ }
+
+ return newVal;
+ }
+ }
+}
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser b/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser
new file mode 100644
index 00000000000..0f77cf3d548
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/src/main/resources/META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser
@@ -0,0 +1 @@
+io.opentelemetry.sdk.autoconfigure.declarativeconfig.snakeyamlengine2.internal.SnakeYamlDeclarativeConfigurationParser
diff --git a/sdk-extensions/declarative-config-snakeyaml-engine2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParserTest.java b/sdk-extensions/declarative-config-snakeyaml-engine2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParserTest.java
new file mode 100644
index 00000000000..35eb5ce29b2
--- /dev/null
+++ b/sdk-extensions/declarative-config-snakeyaml-engine2/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/snakeyamlengine2/internal/SnakeYamlDeclarativeConfigurationParserTest.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.snakeyamlengine2.internal;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+import org.snakeyaml.engine.v2.exceptions.YamlEngineException;
+
+class SnakeYamlDeclarativeConfigurationParserTest {
+
+ @Test
+ void wrapsSnakeYamlException() {
+ ByteArrayInputStream input =
+ new ByteArrayInputStream("key: [unclosed".getBytes(StandardCharsets.UTF_8));
+ assertThatThrownBy(() ->
+ new SnakeYamlDeclarativeConfigurationParser()
+ .parse(input, Collections.emptyMap(), Collections.emptyMap()))
+ .isInstanceOf(DeclarativeConfigException.class)
+ .hasCauseInstanceOf(YamlEngineException.class);
+ }
+}
diff --git a/sdk-extensions/declarative-config/README.md b/sdk-extensions/declarative-config/README.md
index 874f5f7bd6a..0bc14aa7a6e 100644
--- a/sdk-extensions/declarative-config/README.md
+++ b/sdk-extensions/declarative-config/README.md
@@ -16,6 +16,8 @@ try (FileInputStream yamlConfigFileInputStream = new FileInputStream("/path/to/c
Notes:
* Environment variable substitution is supported as [defined in the spec](https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/configuration/data-model.md#environment-variable-substitution)
* Customization is limited to customizing the in-memory config model, or exporters. See `DeclarativeConfigurationCustomizer` SPI for details.
+* The parser and mapper are internal, unstable SPIs in `opentelemetry-sdk-extension-autoconfigure-spi` (`io.opentelemetry.sdk.autoconfigure.spi.internal`). `ServiceLoader` loads providers; when several are present, the first is used with a warning unless selected through the corresponding system property or environment variable.
+* The SnakeYAML parser and Jackson 2 mapper adapters are included by default. Exclude an adapter to use a custom provider. If no provider is available, the operation fails with a `DeclarativeConfigException`.
* Custom SDK extension components which reference the [ComponentProvider](https://github.com/open-telemetry/opentelemetry-java/blob/main/sdk-extensions/autoconfigure-spi/src/main/java/io/opentelemetry/sdk/autoconfigure/spi/declarativeconfig/ComponentProvider.java) SPI can be referenced in declarative configuration. Supported types include:
* `Resource`
* `SpanExporter`
@@ -26,3 +28,106 @@ Notes:
* `TextMapPropagator`
* `Sampler`
* You can use declarative configuration with [autoconfigure](https://opentelemetry.io/docs/languages/java/configuration/#declarative-configuration) to specify a configuration file via environment variable, e.g. `OTEL_CONFIG_FILE=/path/to/config.yaml`.
+
+## Use a custom mapper
+
+To use a mapper such as Jackson 3, exclude the default
+`opentelemetry-sdk-extension-declarative-config-jackson2` adapter from the declarative config
+dependency and add Jackson 3 Databind. This Gradle example uses Jackson 3.2.3:
+
+```kotlin
+dependencies {
+ implementation("io.opentelemetry:opentelemetry-sdk-extension-declarative-config:$version") {
+ exclude(
+ group = "io.opentelemetry",
+ module = "opentelemetry-sdk-extension-declarative-config-jackson2",
+ )
+ }
+ implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:$version")
+ implementation("tools.jackson.core:jackson-databind:3.2.3")
+}
+```
+
+Implement `DeclarativeConfigurationMapper` and override these methods:
+
+* `convert(Object value, Class targetType)` converts parsed configuration values to a model.
+* `toMap(Object value)` converts a model or configuration value to a map with string keys. Return
+ `null` when the input is `null`.
+
+Mapper methods and `DeclarativeConfigurationYamlParser.parse` report conversion and parsing
+failures with `DeclarativeConfigException`. The declarative configuration implementation wraps
+other runtime exceptions thrown by providers in this exception.
+
+Configure your mapper to preserve the null and extension-property behavior expected by the models.
+
+Register the class with `ServiceLoader` by adding this file to your application or mapper module:
+
+```text
+META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper
+```
+
+Put your provider's fully qualified class name in that file:
+
+```text
+your.package.YourMapper
+```
+
+With the service file on the runtime classpath, declarative config discovers your mapper.
+
+## Select a mapper
+
+If exactly one mapper provider is on the classpath, it is used. If multiple providers are present,
+select one by setting the following system property or environment variable to the provider's fully
+qualified class name:
+
+| Configuration source | Name |
+| --- | --- |
+| System property | `io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper` |
+| Environment variable | `IO_OPENTELEMETRY_SDK_AUTOCONFIGURE_SPI_INTERNAL_DECLARATIVECONFIGURATIONMAPPER` |
+
+When multiple providers are present and neither setting is set, the first discovered provider is
+used and a warning is logged. If the setting names a provider that is not present, declarative
+configuration fails with a `DeclarativeConfigException`.
+
+## Use a custom YAML parser
+
+To replace the default SnakeYAML parser, exclude the
+`opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2` adapter from the declarative config
+dependency:
+
+```kotlin
+dependencies {
+ implementation("io.opentelemetry:opentelemetry-sdk-extension-declarative-config:$version") {
+ exclude(
+ group = "io.opentelemetry",
+ module = "opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2",
+ )
+ }
+ implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:$version")
+}
+```
+
+Implement `DeclarativeConfigurationYamlParser` and register it with `ServiceLoader` by adding this
+file to your application or parser module:
+
+```text
+META-INF/services/io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser
+```
+
+Put your parser's fully qualified class name in the service file. The parser receives the YAML
+stream and the environment variables and system properties to use for substitution.
+
+## Select a YAML parser
+
+If exactly one YAML parser provider is on the classpath, it is used. If multiple providers are
+present, select one by setting the following system property or environment variable to the
+provider's fully qualified class name:
+
+| Configuration source | Name |
+| --- | --- |
+| System property | `io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser` |
+| Environment variable | `IO_OPENTELEMETRY_SDK_AUTOCONFIGURE_SPI_INTERNAL_DECLARATIVECONFIGURATIONYAMLPARSER` |
+
+When multiple providers are present and neither setting is set, the first discovered provider is
+used and a warning is logged. If the setting names a provider that is not present, declarative
+configuration fails with a `DeclarativeConfigException`.
diff --git a/sdk-extensions/declarative-config/build.gradle.kts b/sdk-extensions/declarative-config/build.gradle.kts
index b7bcdb96223..3b4e36ec043 100644
--- a/sdk-extensions/declarative-config/build.gradle.kts
+++ b/sdk-extensions/declarative-config/build.gradle.kts
@@ -17,23 +17,25 @@ otelJava.osgiOptionalPackages.set(listOf("io.opentelemetry.sdk.autoconfigure.spi
otelJava.osgiServiceLoaderProvides.set(listOf(
"io.opentelemetry.sdk.autoconfigure.spi.declarativeconfig.ComponentProvider",
))
-// declarative-config discovers customizer providers at runtime via ServiceLoader
+// declarative-config discovers these providers at runtime via ServiceLoader
otelJava.osgiServiceLoaderRequires.set(listOf(
"io.opentelemetry.sdk.autoconfigure.declarativeconfig.DeclarativeConfigurationCustomizerProvider",
+ "io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser",
+ "io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper",
))
dependencies {
api(project(":sdk:all"))
api(project(":api:incubator"))
compileOnly(project(":sdk-extensions:autoconfigure-spi"))
+ runtimeOnly(project(":sdk-extensions:declarative-config-jackson2"))
+ runtimeOnly(project(":sdk-extensions:declarative-config-snakeyaml-engine2"))
// Needed for composable samplers
implementation(project(":sdk-extensions:incubator"))
annotationProcessor("com.google.auto.value:auto-value")
api("com.fasterxml.jackson.core:jackson-annotations")
- implementation("org.snakeyaml:snakeyaml-engine")
- implementation("com.fasterxml.jackson.core:jackson-databind")
testImplementation(project(":sdk:testing"))
testImplementation(project(":sdk-extensions:autoconfigure-spi"))
@@ -46,6 +48,8 @@ dependencies {
testImplementation("io.opentelemetry.contrib:opentelemetry-aws-xray-propagator")
testImplementation("io.opentelemetry.instrumentation:opentelemetry-resources")
testImplementation("com.linecorp.armeria:armeria-junit5")
+ testRuntimeOnly(project(":sdk-extensions:declarative-config-jackson2"))
+ testRuntimeOnly(project(":sdk-extensions:declarative-config-snakeyaml-engine2"))
//
testImplementation("com.google.guava:guava-testlib")
testImplementation("nl.jqno.equalsverifier:equalsverifier")
@@ -108,8 +112,8 @@ val syncPojoModelsToSrc = tasks.register("syncPojoModelsToSrc") {
val modelSrcDir = File(projectDir, "src/main/java")
doLast {
val modelDir = File(modelSrcDir, modelPackage.replace('.', '/'))
- // Delete only @Generated files so hand-written files (ModelMapper, ExtensionPropertyUtil)
- // in model.internal survive the regeneration cycle.
+ // Delete only @Generated files so hand-written files (ExtensionPropertyUtil) in model.internal
+ // survive the regeneration cycle.
modelDir.walkTopDown()
.filter { it.isFile && it.extension == "java" }
.filter { it.readText().contains("@Generated(") }
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfiguration.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfiguration.java
index d2aa64c3aa9..d0c80d36a2c 100644
--- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfiguration.java
+++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/DeclarativeConfiguration.java
@@ -5,18 +5,19 @@
package io.opentelemetry.sdk.autoconfigure.declarativeconfig;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
import io.opentelemetry.api.config.DeclarativeConfigException;
import io.opentelemetry.api.config.DeclarativeConfigProperties;
import io.opentelemetry.common.ComponentLoader;
import io.opentelemetry.sdk.OpenTelemetrySdk;
+import io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal.DeclarativeConfigProviderLoader;
+import io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal.DeclarativeConfigurationMapperUtil;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.OpenTelemetryConfigurationModel;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.SamplerModel;
-import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ModelMapper;
import io.opentelemetry.sdk.autoconfigure.spi.Ordered;
import io.opentelemetry.sdk.autoconfigure.spi.declarativeconfig.ComponentProvider;
import io.opentelemetry.sdk.autoconfigure.spi.internal.AutoConfigureListener;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser;
import io.opentelemetry.sdk.trace.samplers.Sampler;
import java.io.Closeable;
import java.io.IOException;
@@ -26,23 +27,6 @@
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
-import java.util.regex.MatchResult;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import javax.annotation.Nullable;
-import org.snakeyaml.engine.v2.api.Load;
-import org.snakeyaml.engine.v2.api.LoadSettings;
-import org.snakeyaml.engine.v2.api.YamlUnicodeReader;
-import org.snakeyaml.engine.v2.common.ScalarStyle;
-import org.snakeyaml.engine.v2.composer.Composer;
-import org.snakeyaml.engine.v2.nodes.MappingNode;
-import org.snakeyaml.engine.v2.nodes.Node;
-import org.snakeyaml.engine.v2.nodes.ScalarNode;
-import org.snakeyaml.engine.v2.nodes.Tag;
-import org.snakeyaml.engine.v2.parser.ParserImpl;
-import org.snakeyaml.engine.v2.resolver.ScalarResolver;
-import org.snakeyaml.engine.v2.scanner.StreamReader;
-import org.snakeyaml.engine.v2.schema.CoreSchema;
/**
* Configure {@link OpenTelemetrySdk} using For Implementers
*
- * External consumers needing to parse OpenTelemetry YAML configuration files should use the same
- * Jackson {@code ObjectMapper} configuration for compatibility. See {@link
- * io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ModelMapper#MAPPER}.
+ *
YAML parsing and model mapping can be replaced by implementations of {@link
+ * DeclarativeConfigurationYamlParser} and {@link DeclarativeConfigurationMapper}.
*/
public final class DeclarativeConfiguration {
private static final Logger logger = Logger.getLogger(DeclarativeConfiguration.class.getName());
- // Matches ${VAR_NAME}, ${env:VAR_NAME}, or ${sys:property.name} with optional :-default
- private static final Pattern ENV_VARIABLE_REFERENCE =
- Pattern.compile("\\$\\{(?:(env|sys):)?([a-zA-Z_][a-zA-Z0-9_.]*)(?::-([^\\n}]*))?}");
private static final ComponentLoader DEFAULT_COMPONENT_LOADER =
ComponentLoader.forClassLoader(DeclarativeConfigProperties.class.getClassLoader());
- // Visible for testing
- static final ObjectMapper MAPPER = ModelMapper.MAPPER;
-
private DeclarativeConfiguration() {}
/**
@@ -141,6 +118,8 @@ private static DeclarativeConfigResult create(
public static OpenTelemetryConfigurationModel parse(InputStream configuration) {
try {
return parse(configuration, System.getenv(), System.getProperties());
+ } catch (DeclarativeConfigException e) {
+ throw e;
} catch (RuntimeException e) {
throw new DeclarativeConfigException("Unable to parse configuration input stream", e);
}
@@ -152,7 +131,15 @@ static OpenTelemetryConfigurationModel parse(
Map environmentVariables,
Map systemProperties) {
Object yamlObj = loadYaml(configuration, environmentVariables, systemProperties);
- return MAPPER.convertValue(yamlObj, OpenTelemetryConfigurationModel.class);
+ OpenTelemetryConfigurationModel model;
+ try {
+ model =
+ DeclarativeConfigurationMapperUtil.convert(
+ yamlObj, OpenTelemetryConfigurationModel.class);
+ } catch (DeclarativeConfigException e) {
+ throw new DeclarativeConfigException("Unable to parse configuration input stream", e);
+ }
+ return Objects.requireNonNull(model, "Unable to map configuration to model");
}
// Visible for testing
@@ -160,9 +147,14 @@ static Object loadYaml(
InputStream inputStream,
Map environmentVariables,
Map systemProperties) {
- LoadSettings settings = LoadSettings.builder().setSchema(new CoreSchema()).build();
- Load yaml = new EnvLoad(settings, environmentVariables, systemProperties);
- return yaml.loadFromInputStream(inputStream);
+ try {
+ return DeclarativeConfigProviderLoader.getYamlParser()
+ .parse(inputStream, environmentVariables, systemProperties);
+ } catch (DeclarativeConfigException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new DeclarativeConfigException("Unable to parse configuration input stream", e);
+ }
}
/**
@@ -170,6 +162,7 @@ static Object loadYaml(
*
* @param model the configuration model
* @return a generic {@link DeclarativeConfigProperties} representation of the model
+ * @throws DeclarativeConfigException if unable to convert
*/
public static DeclarativeConfigProperties toConfigProperties(Object model) {
return toConfigProperties(model, DEFAULT_COMPONENT_LOADER);
@@ -180,6 +173,7 @@ public static DeclarativeConfigProperties toConfigProperties(Object model) {
*
* @param configuration configuration YAML
* @return a generic {@link DeclarativeConfigProperties} representation of the model
+ * @throws DeclarativeConfigException if unable to parse or convert
*/
public static DeclarativeConfigProperties toConfigProperties(InputStream configuration) {
Object yamlObj = loadYaml(configuration, System.getenv(), System.getProperties());
@@ -188,8 +182,7 @@ public static DeclarativeConfigProperties toConfigProperties(InputStream configu
static DeclarativeConfigProperties toConfigProperties(
Object model, ComponentLoader componentLoader) {
- Map configurationMap =
- MAPPER.convertValue(model, new TypeReference>() {});
+ Map configurationMap = DeclarativeConfigurationMapperUtil.toMap(model);
if (configurationMap == null) {
configurationMap = Collections.emptyMap();
}
@@ -210,8 +203,11 @@ public static Sampler createSampler(DeclarativeConfigProperties genericSamplerMo
YamlDeclarativeConfigProperties yamlDeclarativeConfigProperties =
requireYamlDeclarativeConfigProperties(genericSamplerModel);
SamplerModel samplerModel =
- MAPPER.convertValue(
- DeclarativeConfigProperties.toMap(yamlDeclarativeConfigProperties), SamplerModel.class);
+ Objects.requireNonNull(
+ DeclarativeConfigurationMapperUtil.convert(
+ DeclarativeConfigProperties.toMap(yamlDeclarativeConfigProperties),
+ SamplerModel.class),
+ "Unable to map sampler configuration to model");
return createAndMaybeCleanup(
SamplerFactory.getInstance(),
new DeclarativeConfigContext(yamlDeclarativeConfigProperties.getComponentLoader()),
@@ -248,192 +244,6 @@ static R createAndMaybeCleanup(
}
}
- private static final class EnvLoad extends Load {
-
- private final LoadSettings settings;
- private final Map environmentVariables;
- private final Map systemProperties;
-
- private EnvLoad(
- LoadSettings settings,
- Map environmentVariables,
- Map systemProperties) {
- super(settings);
- this.settings = settings;
- this.environmentVariables = environmentVariables;
- this.systemProperties = systemProperties;
- }
-
- @Override
- public Object loadFromInputStream(InputStream yamlStream) {
- Objects.requireNonNull(yamlStream, "InputStream cannot be null");
- return loadOne(
- new EnvComposer(
- settings,
- new ParserImpl(
- settings, new StreamReader(settings, new YamlUnicodeReader(yamlStream))),
- environmentVariables,
- systemProperties));
- }
- }
-
- /**
- * A YAML Composer that performs environment variable and system property substitution according
- * to the
- * OpenTelemetry Configuration Data Model specification .
- *
- * This composer supports:
- *
- *
- * Environment variable references: {@code ${ENV_VAR}} or {@code ${env:ENV_VAR}}
- * System property references: {@code ${sys:property.name}}
- * Default values: {@code ${ENV_VAR:-default_value}}
- * Escape sequences: {@code $$} is replaced with a single {@code $}
- *
- *
- * Substitution only applies to scalar values. Mapping keys are not candidates for
- * substitution. Referenced variables that are undefined, null, or empty are replaced with empty
- * values unless a default value is provided.
- *
- *
The {@code $} character serves as an escape sequence where {@code $$} in the input is
- * translated to a single {@code $} in the output. This prevents substitution for the escaped
- * content.
- */
- private static final class EnvComposer extends Composer {
-
- private final Load load;
- private final Map environmentVariables;
- private final Map systemProperties;
- private final ScalarResolver scalarResolver;
-
- private static final String ESCAPE_SEQUENCE = "$$";
- private static final int ESCAPE_SEQUENCE_LENGTH = ESCAPE_SEQUENCE.length();
- private static final char ESCAPE_SEQUENCE_REPLACEMENT = '$';
-
- private EnvComposer(
- LoadSettings settings,
- ParserImpl parser,
- Map environmentVariables,
- Map systemProperties) {
- super(settings, parser);
- this.load = new Load(settings);
- this.environmentVariables = environmentVariables;
- this.systemProperties = systemProperties;
- this.scalarResolver = settings.getSchema().getScalarResolver();
- }
-
- @Override
- protected Node composeValueNode(MappingNode node) {
- Node itemValue = super.composeValueNode(node);
- if (!(itemValue instanceof ScalarNode)) {
- // Only apply environment variable substitution to ScalarNodes
- return itemValue;
- }
- ScalarNode scalarNode = (ScalarNode) itemValue;
- String envSubstitution = envSubstitution(scalarNode.getValue());
-
- // If the environment variable substitution does not change the value, do not modify the node
- if (envSubstitution.equals(scalarNode.getValue())) {
- return itemValue;
- }
-
- Object envSubstitutionObj = load.loadFromString(envSubstitution);
- Tag tag = itemValue.getTag();
- ScalarStyle scalarStyle = scalarNode.getScalarStyle();
-
- Tag resolvedTag =
- envSubstitutionObj == null
- ? Tag.NULL
- : scalarResolver.resolve(envSubstitutionObj.toString(), true);
-
- // Only non-quoted substituted scalars can have their tag changed
- if (!itemValue.getTag().equals(resolvedTag)
- && scalarStyle != ScalarStyle.SINGLE_QUOTED
- && scalarStyle != ScalarStyle.DOUBLE_QUOTED) {
- tag = resolvedTag;
- }
-
- boolean resolved = true;
- return new ScalarNode(
- tag,
- resolved,
- envSubstitution,
- scalarStyle,
- itemValue.getStartMark(),
- itemValue.getEndMark());
- }
-
- private String envSubstitution(String val) {
- // Iterate through val left to right, search for escape sequence "$$"
- // For the substring of val between the last escape sequence and the next found, perform
- // environment variable substitution
- // Add the escape replacement character '$' in place of each escape sequence found
-
- int lastEscapeIndexEnd = 0;
- StringBuilder newVal = null;
- while (true) {
- int escapeIndex = val.indexOf(ESCAPE_SEQUENCE, lastEscapeIndexEnd);
- int substitutionEndIndex = escapeIndex == -1 ? val.length() : escapeIndex;
- newVal = envVarSubstitution(newVal, val, lastEscapeIndexEnd, substitutionEndIndex);
- if (escapeIndex == -1) {
- break;
- } else {
- newVal.append(ESCAPE_SEQUENCE_REPLACEMENT);
- }
- lastEscapeIndexEnd = escapeIndex + ESCAPE_SEQUENCE_LENGTH;
- if (lastEscapeIndexEnd >= val.length()) {
- break;
- }
- }
-
- return newVal.toString();
- }
-
- private StringBuilder envVarSubstitution(
- @Nullable StringBuilder newVal, String source, int startIndex, int endIndex) {
- String val = source.substring(startIndex, endIndex);
- Matcher matcher = ENV_VARIABLE_REFERENCE.matcher(val);
-
- if (!matcher.find()) {
- return newVal == null ? new StringBuilder(val) : newVal.append(val);
- }
-
- if (newVal == null) {
- newVal = new StringBuilder();
- }
-
- int offset = 0;
- do {
- MatchResult matchResult = matcher.toMatchResult();
- String prefix = matcher.group(1); // "env", "sys", or null
- String key = matcher.group(2); // variable/property name
- String defaultValue = matcher.group(3);
- if (defaultValue == null) {
- defaultValue = "";
- }
-
- String replacement;
- if ("sys".equals(prefix)) {
- // System property substitution
- Object sysProp = systemProperties.get(key);
- replacement = sysProp != null ? sysProp.toString() : defaultValue;
- } else {
- // Environment variable substitution (default or explicit "env:" prefix)
- replacement = environmentVariables.getOrDefault(key, defaultValue);
- }
-
- newVal.append(val, offset, matchResult.start()).append(replacement);
- offset = matchResult.end();
- } while (matcher.find());
- if (offset != val.length()) {
- newVal.append(val, offset, val.length());
- }
-
- return newVal;
- }
- }
-
// Visible for testing
static void callAutoConfigureListeners(
DeclarativeConfigContext context, OpenTelemetrySdk openTelemetrySdk) {
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/YamlDeclarativeConfigProperties.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/YamlDeclarativeConfigProperties.java
index 7adfc15891d..1d57b78886d 100644
--- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/YamlDeclarativeConfigProperties.java
+++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/YamlDeclarativeConfigProperties.java
@@ -67,9 +67,8 @@ private YamlDeclarativeConfigProperties(
/**
* Create a {@link YamlDeclarativeConfigProperties} from the {@code properties} map.
*
- * {@code properties} is expected to be the output of YAML parsing (i.e. with Jackson {@code
- * com.fasterxml.jackson.databind.ObjectMapper}), and have values which are scalars, lists of
- * scalars, lists of maps, and maps.
+ *
{@code properties} is expected to have values which are scalars, lists of scalars, lists of
+ * maps, and maps.
*
* @see DeclarativeConfiguration#toConfigProperties(Object)
*/
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoader.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoader.java
new file mode 100644
index 00000000000..14cda43af5d
--- /dev/null
+++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoader.java
@@ -0,0 +1,124 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal;
+
+import static java.util.Objects.requireNonNull;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import io.opentelemetry.api.config.DeclarativeConfigProperties;
+import io.opentelemetry.common.ComponentLoader;
+import io.opentelemetry.common.impl.ConfigUtil;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationMapper;
+import io.opentelemetry.sdk.autoconfigure.spi.internal.DeclarativeConfigurationYamlParser;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This class is internal and is hence not for public use. Its APIs are unstable and can change at
+ * any time.
+ */
+public final class DeclarativeConfigProviderLoader {
+
+ private static final Logger LOGGER =
+ Logger.getLogger(DeclarativeConfigProviderLoader.class.getName());
+
+ // TODO(jack-berg): Allow callers to supply the ComponentLoader used to resolve parser and mapper
+ // providers.
+ private static final ComponentLoader COMPONENT_LOADER =
+ ComponentLoader.forClassLoader(DeclarativeConfigProperties.class.getClassLoader());
+
+ private static final AtomicReference MAPPER =
+ new AtomicReference<>();
+ private static final AtomicReference YAML_PARSER =
+ new AtomicReference<>();
+
+ private DeclarativeConfigProviderLoader() {}
+
+ /** Returns the cached model mapper, resolving it on first use. */
+ public static DeclarativeConfigurationMapper getMapper() {
+ return resolveProvider(
+ MAPPER,
+ DeclarativeConfigurationMapper.class,
+ COMPONENT_LOADER,
+ "No DeclarativeConfigurationMapper provider is available. Add the optional "
+ + "opentelemetry-sdk-extension-declarative-config-jackson2 artifact or register a "
+ + "custom mapper provider.");
+ }
+
+ /** Returns the cached YAML parser, resolving it on first use. */
+ public static DeclarativeConfigurationYamlParser getYamlParser() {
+ return resolveProvider(
+ YAML_PARSER,
+ DeclarativeConfigurationYamlParser.class,
+ COMPONENT_LOADER,
+ "No DeclarativeConfigurationYamlParser provider is available. Add the optional "
+ + "opentelemetry-sdk-extension-declarative-config-snakeyaml-engine2 artifact or register a "
+ + "custom parser provider.");
+ }
+
+ static T resolveProvider(
+ AtomicReference providerRef,
+ Class spiClass,
+ ComponentLoader componentLoader,
+ String missingProviderMessage) {
+ T provider = providerRef.get();
+ if (provider != null) {
+ return provider;
+ }
+ String configProperty = spiClass.getName();
+ T candidate =
+ loadFirstOrConfigured(
+ spiClass,
+ missingProviderMessage,
+ configProperty,
+ componentLoader,
+ ConfigUtil.getString(configProperty, ""));
+ return providerRef.compareAndSet(null, candidate)
+ ? candidate
+ : requireNonNull(providerRef.get());
+ }
+
+ private static T loadFirstOrConfigured(
+ Class spiClass,
+ String missingProviderMessage,
+ String configProperty,
+ ComponentLoader componentLoader,
+ String configuredProvider) {
+ List providers = ComponentLoader.loadList(componentLoader, spiClass);
+ if (providers.isEmpty()) {
+ throw new DeclarativeConfigException(missingProviderMessage);
+ }
+ if (providers.size() == 1) {
+ return providers.get(0);
+ }
+
+ if (configuredProvider.isEmpty()) {
+ if (LOGGER.isLoggable(Level.WARNING)) {
+ LOGGER.log(
+ Level.WARNING,
+ "Multiple {0} providers found. Please include only one, or specify preference "
+ + "setting {1} to the FQCN of the preferred provider.",
+ new Object[] {spiClass.getSimpleName(), configProperty});
+ }
+ return providers.get(0);
+ }
+
+ for (T provider : providers) {
+ if (provider.getClass().getName().equals(configuredProvider)) {
+ return provider;
+ }
+ }
+ throw new IllegalStateException(
+ "No "
+ + spiClass.getSimpleName()
+ + " matched configured "
+ + configProperty
+ + ": "
+ + configuredProvider);
+ }
+}
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigurationMapperUtil.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigurationMapperUtil.java
new file mode 100644
index 00000000000..5b4c449588a
--- /dev/null
+++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigurationMapperUtil.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal;
+
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * This class is internal and is hence not for public use. Its APIs are unstable and can change at
+ * any time.
+ */
+public final class DeclarativeConfigurationMapperUtil {
+
+ private DeclarativeConfigurationMapperUtil() {}
+
+ /**
+ * Convert a value to an instance of {@code targetType} using the selected mapper.
+ *
+ * @param value the value to convert
+ * @param targetType the target type
+ * @param the target type
+ * @return the converted value
+ */
+ public static T convert(Object value, Class targetType) {
+ try {
+ return DeclarativeConfigProviderLoader.getMapper().convert(value, targetType);
+ } catch (DeclarativeConfigException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new DeclarativeConfigException("Unable to convert value to " + targetType, e);
+ }
+ }
+
+ /**
+ * Convert a model or parsed configuration value to a generic map using the selected mapper.
+ *
+ * @param value the value to convert
+ * @return the converted map, or null if the mapper returns null
+ */
+ @Nullable
+ public static Map toMap(Object value) {
+ try {
+ return DeclarativeConfigProviderLoader.getMapper().toMap(value);
+ } catch (DeclarativeConfigException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new DeclarativeConfigException("Unable to convert value to a map", e);
+ }
+ }
+}
diff --git a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ExtensionPropertyUtil.java b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ExtensionPropertyUtil.java
index 1f9f3ce0b44..80d972e2a9c 100644
--- a/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ExtensionPropertyUtil.java
+++ b/sdk-extensions/declarative-config/src/main/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/model/internal/ExtensionPropertyUtil.java
@@ -5,6 +5,7 @@
package io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal;
+import io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal.DeclarativeConfigurationMapperUtil;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
@@ -48,7 +49,8 @@ public static void handleAnySetter(
LOGGER.warning(
"Property '" + name + "' has been stabilized. Use '" + candidate + "' instead.");
extensionProperties.put(
- name, value == null ? null : ModelMapper.MAPPER.convertValue(value, stableType));
+ name,
+ value == null ? null : DeclarativeConfigurationMapperUtil.convert(value, stableType));
return;
}
}
@@ -56,7 +58,10 @@ public static void handleAnySetter(
Class> experimentalType = experimentalPropertyTypes.get(name);
if (experimentalType != null) {
extensionProperties.put(
- name, value == null ? null : ModelMapper.MAPPER.convertValue(value, experimentalType));
+ name,
+ value == null
+ ? null
+ : DeclarativeConfigurationMapperUtil.convert(value, experimentalType));
return;
}
@@ -72,7 +77,7 @@ public static void handleAnySetter(
* Returns the extension property value for {@code key}, cast to {@code type}.
*
* Values are guaranteed to be the correct type at store time: {@link #handleAnySetter}
- * converts known experimental properties via {@code ModelMapper} before storing, and the
+ * converts known experimental properties via the configured mapper before storing, and the
* generated accessor builders enforce the type at the call site. A {@link ClassCastException}
* here indicates a contract violation (e.g. writing directly to the extension properties map with
* the wrong type).
diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ExtensionPropertyUtilTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ExtensionPropertyUtilTest.java
index 6cad1c2a064..078b3fd08e7 100644
--- a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ExtensionPropertyUtilTest.java
+++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/ExtensionPropertyUtilTest.java
@@ -7,13 +7,13 @@
import static org.assertj.core.api.Assertions.assertThat;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.netmikey.logunit.api.LogCapturer;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.LogRecordLimitsModel;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.LoggerProviderModel;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.SamplerModel;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExperimentalComposableSamplerModel;
import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ExtensionPropertyUtil;
-import io.opentelemetry.sdk.autoconfigure.declarativeconfig.model.internal.ModelMapper;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -24,6 +24,8 @@
class ExtensionPropertyUtilTest {
+ private final ObjectMapper mapper = new ObjectMapper();
+
@RegisterExtension
LogCapturer logs =
LogCapturer.create().captureForLogger(ExtensionPropertyUtil.class.getName(), Level.TRACE);
@@ -181,20 +183,20 @@ void filterSerializable_dropsGraduatedKeys_preservesOthers() {
@Test
void stableModel_roundTripsGraduatedKey_emitsStableKeyOnly() throws Exception {
String json = "{\"limits/development\":{}}";
- LoggerProviderModel model = ModelMapper.MAPPER.readValue(json, LoggerProviderModel.class);
+ LoggerProviderModel model = mapper.readValue(json, LoggerProviderModel.class);
assertThat(model.getLimits()).isNotNull().isInstanceOf(LogRecordLimitsModel.class);
- String reserialized = ModelMapper.MAPPER.writeValueAsString(model);
+ String reserialized = mapper.writeValueAsString(model);
assertThat(reserialized).contains("\"limits\":").doesNotContain("limits/development");
}
@Test
void stableModel_roundTripsNonGraduatedDevelopmentKey_verbatim() throws Exception {
String json = "{\"composite/development\":{}}";
- SamplerModel model = ModelMapper.MAPPER.readValue(json, SamplerModel.class);
+ SamplerModel model = mapper.readValue(json, SamplerModel.class);
- String reserialized = ModelMapper.MAPPER.writeValueAsString(model);
+ String reserialized = mapper.writeValueAsString(model);
assertThat(reserialized).contains("composite/development");
}
}
diff --git a/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoaderTest.java b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoaderTest.java
new file mode 100644
index 00000000000..19e12424531
--- /dev/null
+++ b/sdk-extensions/declarative-config/src/test/java/io/opentelemetry/sdk/autoconfigure/declarativeconfig/internal/DeclarativeConfigProviderLoaderTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.github.netmikey.logunit.api.LogCapturer;
+import io.opentelemetry.api.config.DeclarativeConfigException;
+import io.opentelemetry.common.ComponentLoader;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junitpioneer.jupiter.ClearSystemProperty;
+
+@ClearSystemProperty(
+ key =
+ "io.opentelemetry.sdk.autoconfigure.declarativeconfig.internal.DeclarativeConfigProviderLoaderTest$TestSpi")
+class DeclarativeConfigProviderLoaderTest {
+
+ private static final TestSpi FIRST = new FirstProvider();
+ private static final TestSpi SECOND = new SecondProvider();
+
+ @RegisterExtension
+ LogCapturer logs = LogCapturer.create().captureForType(DeclarativeConfigProviderLoader.class);
+
+ private final ComponentLoader loader = mock(ComponentLoader.class);
+
+ @Test
+ void cachesSelectedProviders() {
+ assertThat(DeclarativeConfigProviderLoader.getMapper())
+ .isSameAs(DeclarativeConfigProviderLoader.getMapper());
+ assertThat(DeclarativeConfigProviderLoader.getYamlParser())
+ .isSameAs(DeclarativeConfigProviderLoader.getYamlParser());
+ }
+
+ @Test
+ void retriesFailedResolutionAndCachesSuccess() {
+ AtomicReference ref = new AtomicReference<>();
+ when(loader.load(TestSpi.class))
+ .thenThrow(new IllegalStateException("lookup failed"))
+ .thenReturn(Collections.singletonList(FIRST));
+
+ assertThatThrownBy(() ->
+ DeclarativeConfigProviderLoader.resolveProvider(ref, TestSpi.class, loader, "missing"))
+ .isInstanceOf(IllegalStateException.class);
+ assertThat(ref.get()).isNull();
+ assertThat(
+ DeclarativeConfigProviderLoader.resolveProvider(ref, TestSpi.class, loader, "missing"))
+ .isSameAs(FIRST);
+ assertThat(
+ DeclarativeConfigProviderLoader.resolveProvider(ref, TestSpi.class, loader, "missing"))
+ .isSameAs(FIRST);
+ verify(loader, times(2)).load(TestSpi.class);
+ }
+
+ @Test
+ void rejectsMissingProvider() {
+ when(loader.load(TestSpi.class)).thenReturn(Collections.emptyList());
+
+ assertThatThrownBy(() ->
+ DeclarativeConfigProviderLoader.resolveProvider(
+ new AtomicReference<>(), TestSpi.class, loader, "missing"))
+ .isInstanceOf(DeclarativeConfigException.class)
+ .hasMessage("missing");
+ }
+
+ @Test
+ void selectsSingleProvider() {
+ when(loader.load(TestSpi.class)).thenReturn(Collections.singletonList(FIRST));
+
+ assertThat(
+ DeclarativeConfigProviderLoader.resolveProvider(
+ new AtomicReference<>(), TestSpi.class, loader, "missing"))
+ .isSameAs(FIRST);
+ }
+
+ @Test
+ void selectsFirstProviderAndWarns() {
+ when(loader.load(TestSpi.class)).thenReturn(Arrays.asList(FIRST, SECOND));
+
+ assertThat(
+ DeclarativeConfigProviderLoader.resolveProvider(
+ new AtomicReference<>(), TestSpi.class, loader, "missing"))
+ .isSameAs(FIRST);
+ logs.assertContains(
+ event -> event.getMessage().startsWith("Multiple {0} providers found."),
+ "Expected multiple-provider warning");
+ }
+
+ @Test
+ void selectsConfiguredProvider() {
+ System.setProperty(TestSpi.class.getName(), SECOND.getClass().getName());
+ when(loader.load(TestSpi.class)).thenReturn(Arrays.asList(FIRST, SECOND));
+
+ assertThat(
+ DeclarativeConfigProviderLoader.resolveProvider(
+ new AtomicReference<>(), TestSpi.class, loader, "missing"))
+ .isSameAs(SECOND);
+ }
+
+ @Test
+ void rejectsUnknownConfiguredProvider() {
+ System.setProperty(TestSpi.class.getName(), "missing.Provider");
+ when(loader.load(TestSpi.class)).thenReturn(Arrays.asList(FIRST, SECOND));
+
+ assertThatThrownBy(() ->
+ DeclarativeConfigProviderLoader.resolveProvider(
+ new AtomicReference<>(), TestSpi.class, loader, "missing"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("missing.Provider");
+ }
+
+ private interface TestSpi {}
+
+ private static final class FirstProvider implements TestSpi {}
+
+ private static final class SecondProvider implements TestSpi {}
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index fa7ccdcfcf3..69757129954 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -69,6 +69,8 @@ include(":sdk:trace-shaded-deps")
include(":sdk-extensions:autoconfigure")
include(":sdk-extensions:autoconfigure-spi")
include(":sdk-extensions:declarative-config")
+include(":sdk-extensions:declarative-config-jackson2")
+include(":sdk-extensions:declarative-config-snakeyaml-engine2")
include(":sdk-extensions:incubator")
include(":sdk-extensions:jaeger-remote-sampler")
include(":testing-internal")