diff --git a/declarative-config-bridge/README.md b/declarative-config-bridge/README.md index 0e6b7154bb15..f7362a24191c 100644 --- a/declarative-config-bridge/README.md +++ b/declarative-config-bridge/README.md @@ -82,3 +82,106 @@ public class InferredSpansComponentProvider implements ComponentProvider { } } ``` + +## DefaultInstrumentationConfig + +`DefaultInstrumentationConfig` lets distribution authors define instrumentation property defaults +once and have them work in both configuration modes. +First, there is a single defaults object that is unaware of the source of the configuration: + +```java +DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); +defaults.get("micrometer").setDefault("base_time_unit", "s"); +defaults.get("log4j_appender").setDefault("experimental_log_attributes/development", "true"); +defaults.addMapping("acme", "acme.full_name"); +defaults.get("acme").get("full_name").setDefault("preserved", "true"); +``` + +Navigation mirrors `DeclarativeConfigProperties` — reading uses +`config.get("micrometer").getString("base_time_unit")`; writing defaults uses +`defaults.get("micrometer").setDefault("base_time_unit", "s")`, and deeper nested paths can chain +`get(...)` the same way. + +Keys use the same declarative config shape as `DeclarativeConfigProperties`. When producing system +property keys, underscores are translated to hyphens, and keys ending in `/development` are +translated using the bridge's `experimental.` convention. Custom property prefixes can be aligned +with `DeclarativeConfigPropertiesBridgeBuilder` mappings via `defaults.addMapping(...)`. + +The auto configuration **without declarative config** registers the defaults as a properties +supplier, translating them to `otel.instrumentation.*` keys: + +```java +@AutoService(AutoConfigurationCustomizerProvider.class) +public class MyDistroAutoConfig implements AutoConfigurationCustomizerProvider { + private static final DefaultInstrumentationConfig DEFAULTS = createDefaults(); + + @Override + public void customize(AutoConfigurationCustomizer autoConfiguration) { + autoConfiguration.addPropertiesSupplier(DEFAULTS::toConfigProperties); + } + + private static DefaultInstrumentationConfig createDefaults() { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("micrometer").setDefault("base_time_unit", "s"); + defaults.get("log4j_appender").setDefault("experimental_log_attributes/development", "true"); + defaults.addMapping("acme", "acme.full_name"); + defaults.get("acme").get("full_name").setDefault("preserved", "true"); + return defaults; + } +} +``` + +With the `acme` mapping above, the generated properties include: + +```properties +otel.instrumentation.micrometer.base-time-unit=s +otel.instrumentation.log4j-appender.experimental-log-attributes=true +acme.preserved=true +``` + +The auto configuration **with declarative config** registers the defaults as a model customizer, +injecting them under `instrumentation/development.java`. This optional path uses +`DefaultInstrumentationConfigApplier`, so only declarative-config users need the incubator +file-config dependency on their classpath. + +Let's first look at the yaml file that the defaults effectively merge into: + +```yaml +file_format: 1.0 +instrumentation/development: + java: + micrometer: + base_time_unit: s + log4j_appender: + experimental_log_attributes/development: "true" + acme: + full_name: + preserved: "true" +``` + +And now the customizer that applies the defaults to the model: + +```java +@AutoService(DeclarativeConfigurationCustomizerProvider.class) +public class MyDistroDeclarativeConfig implements DeclarativeConfigurationCustomizerProvider { + private static final DefaultInstrumentationConfig DEFAULTS = createDefaults(); + + @Override + public void customize(DeclarativeConfigurationCustomizer customizer) { + customizer.addModelCustomizer( + model -> DefaultInstrumentationConfigApplier.applyToModel(DEFAULTS, model)); + } + + private static DefaultInstrumentationConfig createDefaults() { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("micrometer").setDefault("base_time_unit", "s"); + defaults.get("log4j_appender").setDefault("experimental_log_attributes/development", "true"); + defaults.addMapping("acme", "acme.full_name"); + defaults.get("acme").get("full_name").setDefault("preserved", "true"); + return defaults; + } +} +``` + +Explicit user configuration always takes precedence — defaults are only applied for properties not +already present (`putIfAbsent`). diff --git a/declarative-config-bridge/build.gradle.kts b/declarative-config-bridge/build.gradle.kts index e963ddc91539..3138c2997ff4 100644 --- a/declarative-config-bridge/build.gradle.kts +++ b/declarative-config-bridge/build.gradle.kts @@ -8,6 +8,8 @@ group = "io.opentelemetry.instrumentation" dependencies { compileOnly("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure") + // DefaultInstrumentationConfigApplier exposes declarative config model types in the public API. + api("io.opentelemetry:opentelemetry-sdk-extension-declarative-config") implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi") implementation("io.opentelemetry:opentelemetry-api-incubator") diff --git a/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfig.java b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfig.java new file mode 100644 index 000000000000..684a60e402fb --- /dev/null +++ b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfig.java @@ -0,0 +1,172 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.config.bridge; + +import static java.util.Collections.emptyList; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Defines instrumentation defaults that work with both traditional property-based configuration and + * declarative configuration. + * + *

Navigation mirrors {@link io.opentelemetry.api.incubator.config.DeclarativeConfigProperties}: + * read-side uses {@code config.get(name).getString(key)}; write-side uses {@code + * defaults.get(name).setDefault(key, value)}. + * + *

Usage: + * + *

{@code
+ * DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig();
+ * defaults.get("micrometer").setDefault("base_time_unit", "s");
+ * defaults.get("log4j_appender").setDefault("experimental_log_attributes/development", "true");
+ * defaults.addMapping("acme", "acme.full_name");
+ * defaults.get("acme").get("full_name").setDefault("preserved", "true");
+ *
+ * autoConfiguration.addPropertiesSupplier(defaults::toConfigProperties);
+ * }
+ * + *

For declarative-config model integration, use the optional {@link + * DefaultInstrumentationConfigApplier} helper so the base defaults type stays usable without the + * incubator model classes on the runtime classpath. + */ +public final class DefaultInstrumentationConfig { + + private final Map defaults; + private final List path; + private final Map propertyMappings; + + public DefaultInstrumentationConfig() { + this(new HashMap<>(), emptyList(), new HashMap<>()); + } + + private DefaultInstrumentationConfig( + Map defaults, List path, Map propertyMappings) { + this.defaults = defaults; + this.path = path; + this.propertyMappings = propertyMappings; + } + + /** + * Returns the defaults node for the given child, mirroring {@code + * DeclarativeConfigProperties.get(name)} on the read side. + */ + public DefaultInstrumentationConfig get(String name) { + List newPath = new ArrayList<>(path); + newPath.add(name); + return new DefaultInstrumentationConfig(defaults, newPath, propertyMappings); + } + + /** + * Adds a property prefix mapping, mirroring {@link DeclarativeConfigPropertiesBridgeBuilder + * #addMapping(String, String)} in the opposite direction. + * + *

For example, mapping {@code acme} to {@code acme.full_name} makes {@code + * defaults.get("acme").get("full_name").setDefault("preserved", "true")} produce {@code + * acme.preserved=true}. + */ + @CanIgnoreReturnValue + public DefaultInstrumentationConfig addMapping(String propertyPrefix, String declarativePath) { + propertyMappings.put(propertyPrefix, declarativePath); + return this; + } + + /** + * Sets a default value for a property on the current node. Keys use the declarative config shape + * (e.g. {@code base_time_unit}); when producing config property keys, underscores are translated + * to hyphens and keys ending in {@code /development} follow the same {@code experimental.} + * translation as {@link ConfigPropertiesBackedDeclarativeConfigProperties}. + * + * @return {@code this} for chaining + */ + @CanIgnoreReturnValue + public DefaultInstrumentationConfig setDefault(String key, String value) { + defaults.put(pathWithName(key), value); + return this; + } + + /** + * Translates defaults to config properties for auto-configuration. + * + *

Defaults use {@code otel.instrumentation.*} keys unless a custom mapping overrides the + * property prefix for a declarative path subtree. + */ + public Map toConfigProperties() { + HashMap map = new HashMap<>(); + defaults.forEach((declarativePath, value) -> map.put(toConfigProperty(declarativePath), value)); + return map; + } + + Map getDefaults() { + return defaults; + } + + private String pathWithName(String name) { + if (path.isEmpty()) { + return name; + } + return String.join(".", path) + "." + name; + } + + private String toConfigProperty(String declarativePath) { + String propertyPrefix = null; + String declarativePrefix = null; + for (Map.Entry entry : propertyMappings.entrySet()) { + String candidate = entry.getValue(); + if (!matchesPrefix(declarativePath, candidate)) { + continue; + } + if (declarativePrefix == null || candidate.length() > declarativePrefix.length()) { + declarativePrefix = candidate; + propertyPrefix = entry.getKey(); + } + } + + if (propertyPrefix == null) { + return "otel.instrumentation." + translatePath(declarativePath); + } + if (declarativePrefix == null) { + throw new IllegalStateException("missing declarative prefix for property mapping"); + } + + if (declarativePath.equals(declarativePrefix)) { + return propertyPrefix; + } + + int matchedPrefixLength = declarativePrefix.length(); + return propertyPrefix + "." + translatePath(declarativePath.substring(matchedPrefixLength + 1)); + } + + private static boolean matchesPrefix(String path, String prefix) { + return path.equals(prefix) || path.startsWith(prefix + "."); + } + + private static String translatePath(String path) { + String[] segments = path.split("\\."); + StringBuilder translated = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + if (i > 0) { + translated.append("."); + } + translated.append(translateName(segments[i])); + } + return translated.toString(); + } + + private static String translateName(String name) { + if (name.endsWith("/development")) { + name = name.substring(0, name.length() - "/development".length()); + if (!name.contains("experimental")) { + name = "experimental." + name; + } + } + return name.replace('_', '-'); + } +} diff --git a/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplier.java b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplier.java new file mode 100644 index 000000000000..5a2f53d36197 --- /dev/null +++ b/declarative-config-bridge/src/main/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplier.java @@ -0,0 +1,81 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.config.bridge; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import io.opentelemetry.sdk.declarativeconfig.internal.model.ExperimentalInstrumentationModel; +import io.opentelemetry.sdk.declarativeconfig.internal.model.ExperimentalLanguageSpecificInstrumentationModel; +import io.opentelemetry.sdk.declarativeconfig.internal.model.ExperimentalLanguageSpecificInstrumentationPropertyModel; +import io.opentelemetry.sdk.declarativeconfig.internal.model.OpenTelemetryConfigurationModel; +import java.util.HashMap; +import java.util.Map; + +/** Utility that applies {@link DefaultInstrumentationConfig} defaults to the declarative model. */ +public final class DefaultInstrumentationConfigApplier { + + private DefaultInstrumentationConfigApplier() {} + + /** + * Applies defaults to the declarative configuration model under {@code + * instrumentation/development.java}. Existing values in the model take precedence; defaults are + * only set for properties not already present. + */ + @CanIgnoreReturnValue + public static OpenTelemetryConfigurationModel applyToModel( + DefaultInstrumentationConfig defaults, OpenTelemetryConfigurationModel model) { + if (defaults.getDefaults().isEmpty()) { + return model; + } + + ExperimentalInstrumentationModel instrumentation = model.getInstrumentationDevelopment(); + if (instrumentation == null) { + instrumentation = new ExperimentalInstrumentationModel(); + model.withInstrumentationDevelopment(instrumentation); + } + ExperimentalLanguageSpecificInstrumentationModel java = instrumentation.getJava(); + if (java == null) { + java = new ExperimentalLanguageSpecificInstrumentationModel(); + instrumentation.withJava(java); + } + + Map props = + java.getAdditionalProperties(); + + for (Map.Entry entry : defaults.getDefaults().entrySet()) { + applyDefault(props, entry.getKey(), entry.getValue()); + } + + return model; + } + + private static void applyDefault( + Map props, + String declarativePath, + String value) { + String[] segments = declarativePath.split("\\."); + ExperimentalLanguageSpecificInstrumentationPropertyModel propertyModel = + props.computeIfAbsent( + segments[0], key -> new ExperimentalLanguageSpecificInstrumentationPropertyModel()); + Map target = propertyModel.getAdditionalProperties(); + for (int i = 1; i < segments.length - 1; i++) { + Object child = target.get(segments[i]); + if (child == null) { + Map nested = new HashMap<>(); + target.put(segments[i], nested); + target = nested; + continue; + } + if (!(child instanceof Map)) { + return; + } + // Nested defaults only create string-keyed maps, so this cast is safe here. + @SuppressWarnings("unchecked") + Map nested = (Map) child; + target = nested; + } + target.putIfAbsent(segments[segments.length - 1], value); + } +} diff --git a/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplierTest.java b/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplierTest.java new file mode 100644 index 000000000000..677767e0a11f --- /dev/null +++ b/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigApplierTest.java @@ -0,0 +1,102 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.config.bridge; + +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.sdk.declarativeconfig.internal.model.OpenTelemetryConfigurationModel; +import org.junit.jupiter.api.Test; + +class DefaultInstrumentationConfigApplierTest { + + @Test + void applyToModel() { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("micrometer").setDefault("base_time_unit", "s"); + defaults.get("log4j_appender").setDefault("experimental_log_attributes/development", "true"); + + OpenTelemetryConfigurationModel model = new OpenTelemetryConfigurationModel(); + DefaultInstrumentationConfigApplier.applyToModel(defaults, model); + + assertThat( + model + .getInstrumentationDevelopment() + .getJava() + .getAdditionalProperties() + .get("micrometer") + .getAdditionalProperties()) + .containsEntry("base_time_unit", "s"); + assertThat( + model + .getInstrumentationDevelopment() + .getJava() + .getAdditionalProperties() + .get("log4j_appender") + .getAdditionalProperties()) + .containsEntry("experimental_log_attributes/development", "true"); + } + + @Test + void applyToModelSupportsNestedPaths() { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("acme").get("full_name").setDefault("preserved", "true"); + + OpenTelemetryConfigurationModel model = new OpenTelemetryConfigurationModel(); + DefaultInstrumentationConfigApplier.applyToModel(defaults, model); + + assertThat( + model + .getInstrumentationDevelopment() + .getJava() + .getAdditionalProperties() + .get("acme") + .getAdditionalProperties()) + .containsEntry("full_name", singletonMap("preserved", "true")); + } + + @Test + void applyToModelDoesNotOverrideExisting() { + OpenTelemetryConfigurationModel model = new OpenTelemetryConfigurationModel(); + DefaultInstrumentationConfig seed = new DefaultInstrumentationConfig(); + seed.get("micrometer").setDefault("base_time_unit", "ms"); + DefaultInstrumentationConfigApplier.applyToModel(seed, model); + + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("micrometer").setDefault("base_time_unit", "s"); + DefaultInstrumentationConfigApplier.applyToModel(defaults, model); + + assertThat( + model + .getInstrumentationDevelopment() + .getJava() + .getAdditionalProperties() + .get("micrometer") + .getAdditionalProperties()) + .containsEntry("base_time_unit", "ms"); + } + + @Test + void applyToModelDoesNotOverrideExistingNestedValues() { + OpenTelemetryConfigurationModel model = new OpenTelemetryConfigurationModel(); + DefaultInstrumentationConfig seed = new DefaultInstrumentationConfig(); + seed.get("acme").get("full_name").setDefault("preserved", "true"); + DefaultInstrumentationConfigApplier.applyToModel(seed, model); + + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get("acme").get("full_name").setDefault("preserved", "false"); + DefaultInstrumentationConfigApplier.applyToModel(defaults, model); + + assertThat( + model + .getInstrumentationDevelopment() + .getJava() + .getAdditionalProperties() + .get("acme") + .getAdditionalProperties()) + .containsEntry("full_name", singletonMap("preserved", "true")); + } +} diff --git a/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigTest.java b/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigTest.java new file mode 100644 index 000000000000..1697093aed8e --- /dev/null +++ b/declarative-config-bridge/src/test/java/io/opentelemetry/instrumentation/config/bridge/DefaultInstrumentationConfigTest.java @@ -0,0 +1,77 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.instrumentation.config.bridge; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties; +import io.opentelemetry.sdk.autoconfigure.spi.internal.DefaultConfigProperties; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class DefaultInstrumentationConfigTest { + + private static Stream configPropertyDefaults() { + return Stream.of( + Arguments.of( + "micrometer", "base_time_unit", "s", "otel.instrumentation.micrometer.base-time-unit"), + Arguments.of( + "log4j_appender", + "experimental_log_attributes/development", + "true", + "otel.instrumentation.log4j-appender.experimental-log-attributes"), + Arguments.of( + "spring_scheduling", + "controller_telemetry/development", + "false", + "otel.instrumentation.spring-scheduling.experimental.controller-telemetry"), + Arguments.of( + "grpc", + "experimental_span_attributes/development", + "true", + "otel.instrumentation.grpc.experimental-span-attributes")); + } + + @ParameterizedTest + @MethodSource("configPropertyDefaults") + void toConfigProperties( + String instrumentation, String key, String value, String expectedPropertyKey) { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get(instrumentation).setDefault(key, value); + + Map props = defaults.toConfigProperties(); + + assertThat(props).containsEntry(expectedPropertyKey, value).hasSize(1); + } + + @ParameterizedTest + @MethodSource("configPropertyDefaults") + void toConfigPropertiesRoundTripsThroughBridge( + String instrumentation, String key, String value, String expectedPropertyKey) { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.get(instrumentation).setDefault(key, value); + + DeclarativeConfigProperties config = + ConfigPropertiesBackedDeclarativeConfigProperties.createInstrumentationConfig( + DefaultConfigProperties.createFromMap(defaults.toConfigProperties())); + + assertThat(config.getStructured("java").getStructured(instrumentation).getString(key)) + .isEqualTo(value); + } + + @Test + void toConfigPropertiesWithCustomMapping() { + DefaultInstrumentationConfig defaults = new DefaultInstrumentationConfig(); + defaults.addMapping("acme", "acme.full_name"); + defaults.get("acme").get("full_name").setDefault("preserved", "true"); + + assertThat(defaults.toConfigProperties()).containsEntry("acme.preserved", "true").hasSize(1); + } +}