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 | [![Javadocs](https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-autoconfigure.svg)](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 | [![Javadocs](https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-autoconfigure-spi.svg)](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 | [![Javadocs](https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-declarative-config.svg)](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 | [![Javadocs](https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-jaeger-remote-sampler.svg)](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 | [![Javadocs](https://www.javadoc.io/badge/io.opentelemetry/opentelemetry-sdk-extension-incubator.svg)](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: - * - *

- * - *

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")