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);
+ }
+}