diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeListener.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeListener.java
new file mode 100644
index 00000000000..44833713f8a
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeListener.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.config;
+
+/** Listener notified when declarative configuration changes. */
+@FunctionalInterface
+public interface ConfigChangeListener {
+
+ /**
+ * Called when the watched path changes.
+ *
+ *
{@code path} is the changed declarative configuration path, for example {@code
+ * .instrumentation/development.general.http} or {@code
+ * .instrumentation/development.java.methods}.
+ *
+ *
{@code newConfig} is never null. If the watched node is unset or cleared, {@code newConfig}
+ * is {@link DeclarativeConfigProperties#empty()}.
+ *
+ * @param path the declarative configuration path that changed
+ * @param newConfig the updated configuration for the changed path
+ */
+ void onChange(String path, DeclarativeConfigProperties newConfig);
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeRegistration.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeRegistration.java
new file mode 100644
index 00000000000..6e499ba11ad
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigChangeRegistration.java
@@ -0,0 +1,18 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.config;
+
+/** Registration handle returned by {@link ConfigProvider#addConfigChangeListener}. */
+@FunctionalInterface
+public interface ConfigChangeRegistration {
+
+ /**
+ * Unregister the listener associated with this registration.
+ *
+ *
Subsequent calls have no effect.
+ */
+ void close();
+}
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigProvider.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigProvider.java
index dcf6c7bd082..715eaa25d2a 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigProvider.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/ConfigProvider.java
@@ -65,6 +65,60 @@ default DeclarativeConfigProperties getGeneralInstrumentationConfig() {
return getInstrumentationConfig().get("general");
}
+ /**
+ * Registers a {@link ConfigChangeListener} for changes to a specific declarative configuration
+ * path.
+ *
+ *
Example paths include {@code .instrumentation/development.general.http} and {@code
+ * .instrumentation/development.java.methods}.
+ *
+ *
When a watched path changes, {@link ConfigChangeListener#onChange(String,
+ * DeclarativeConfigProperties)} is invoked with the changed path and updated configuration for
+ * that path.
+ *
+ *
The default implementation performs no registration and returns a no-op handle.
+ *
+ * @param path the declarative configuration path to watch
+ * @param listener the listener to notify when the watched path changes
+ * @return a {@link ConfigChangeRegistration} that can be closed to unregister the listener
+ */
+ default ConfigChangeRegistration addConfigChangeListener(
+ String path, ConfigChangeListener listener) {
+ return () -> {};
+ }
+
+ /**
+ * Updates the declarative configuration subtree at the given path.
+ *
+ *
The path uses {@code .} as a separator (e.g., {@code
+ * ".instrumentation/development.java.myLib"}). The subtree at that path is replaced with {@code
+ * newSubtree}, and any registered {@link ConfigChangeListener}s watching affected paths are
+ * notified.
+ *
+ *
The default implementation is a no-op.
+ *
+ * @param path the declarative configuration path to update
+ * @param newSubtree the new configuration subtree to set at the path
+ */
+ default void updateConfig(String path, DeclarativeConfigProperties newSubtree) {}
+
+ /**
+ * Sets a single scalar configuration property at the given path.
+ *
+ *
The path uses {@code .} as a separator (e.g., {@code
+ * ".instrumentation/development.java.myLib"}). The property identified by {@code key} within that
+ * path is set to {@code value}, and any registered {@link ConfigChangeListener}s watching
+ * affected paths are notified.
+ *
+ *
The default implementation is a no-op.
+ *
+ * @param path the declarative configuration path containing the property
+ * @param key the property key within the path
+ * @param value the new value for the property (must be a scalar: String, Boolean, Long, Double,
+ * Integer, or a List of scalars)
+ */
+ default void setConfigProperty(String path, String key, Object value) {}
+
/** Returns a no-op {@link ConfigProvider}. */
static ConfigProvider noop() {
return DeclarativeConfigProperties::empty;
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/DeclarativeConfigProperties.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/DeclarativeConfigProperties.java
index 425a3d768be..4c63d677e68 100644
--- a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/DeclarativeConfigProperties.java
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/DeclarativeConfigProperties.java
@@ -43,6 +43,22 @@ static Map toMap(DeclarativeConfigProperties declarativeConfigPr
return DeclarativeConfigPropertyUtil.toMap(declarativeConfigProperties);
}
+ /**
+ * Create a {@link DeclarativeConfigProperties} from a {@code Map}.
+ *
+ * This is the inverse of {@link #toMap(DeclarativeConfigProperties)}. Values in the map are
+ * expected to follow the same conventions: scalars, lists of scalars, nested maps, and lists of
+ * maps.
+ *
+ * @param map the map to wrap
+ * @param componentLoader the component loader to use
+ * @return a {@link DeclarativeConfigProperties} backed by the map
+ */
+ static DeclarativeConfigProperties fromMap(
+ Map map, ComponentLoader componentLoader) {
+ return new MapBackedDeclarativeConfigProperties(map, componentLoader);
+ }
+
/**
* Returns a {@link String} configuration property.
*
diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigProperties.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigProperties.java
new file mode 100644
index 00000000000..3effc4f84d3
--- /dev/null
+++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigProperties.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.config;
+
+import io.opentelemetry.common.ComponentLoader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+
+/**
+ * A {@link DeclarativeConfigProperties} implementation backed by a {@code Map}.
+ *
+ * This is the inverse of {@link DeclarativeConfigProperties#toMap(DeclarativeConfigProperties)}.
+ * Values in the map are expected to follow the same conventions as YAML parsing output: scalars
+ * (String, Boolean, Long, Double, Integer), lists of scalars, maps (structured children), and lists
+ * of maps (structured lists).
+ */
+final class MapBackedDeclarativeConfigProperties implements DeclarativeConfigProperties {
+
+ private final Map values;
+ private final ComponentLoader componentLoader;
+
+ MapBackedDeclarativeConfigProperties(
+ Map values, ComponentLoader componentLoader) {
+ this.values = values;
+ this.componentLoader = componentLoader;
+ }
+
+ @Nullable
+ @Override
+ public String getString(String name) {
+ Object value = values.get(name);
+ return value instanceof String ? (String) value : null;
+ }
+
+ @Nullable
+ @Override
+ public Boolean getBoolean(String name) {
+ Object value = values.get(name);
+ return value instanceof Boolean ? (Boolean) value : null;
+ }
+
+ @Nullable
+ @Override
+ public Integer getInt(String name) {
+ Object value = values.get(name);
+ if (value instanceof Integer) {
+ return (Integer) value;
+ }
+ if (value instanceof Long) {
+ return ((Long) value).intValue();
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Long getLong(String name) {
+ Object value = values.get(name);
+ if (value instanceof Long) {
+ return (Long) value;
+ }
+ if (value instanceof Integer) {
+ return ((Integer) value).longValue();
+ }
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public Double getDouble(String name) {
+ Object value = values.get(name);
+ if (value instanceof Double) {
+ return (Double) value;
+ }
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ @Override
+ public List getScalarList(String name, Class scalarType) {
+ Object value = values.get(name);
+ if (!(value instanceof List)) {
+ return null;
+ }
+ List raw = (List) value;
+ List casted = new ArrayList<>(raw.size());
+ for (Object element : raw) {
+ if (!scalarType.isInstance(element)) {
+ return null;
+ }
+ casted.add(scalarType.cast(element));
+ }
+ return casted;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ @Override
+ public DeclarativeConfigProperties getStructured(String name) {
+ Object value = values.get(name);
+ if (!(value instanceof Map)) {
+ return null;
+ }
+ return new MapBackedDeclarativeConfigProperties((Map) value, componentLoader);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ @Override
+ public List getStructuredList(String name) {
+ Object value = values.get(name);
+ if (!(value instanceof List)) {
+ return null;
+ }
+ List raw = (List) value;
+ List result = new ArrayList<>(raw.size());
+ for (Object element : raw) {
+ if (!(element instanceof Map)) {
+ return null;
+ }
+ result.add(
+ new MapBackedDeclarativeConfigProperties((Map) element, componentLoader));
+ }
+ return result;
+ }
+
+ @Override
+ public Set getPropertyKeys() {
+ return values.keySet();
+ }
+
+ @Override
+ public ComponentLoader getComponentLoader() {
+ return componentLoader;
+ }
+}
diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/ConfigProviderTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/ConfigProviderTest.java
index b5fdca1cdf6..496d2409595 100644
--- a/api/incubator/src/test/java/io/opentelemetry/api/incubator/ConfigProviderTest.java
+++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/ConfigProviderTest.java
@@ -6,8 +6,11 @@
package io.opentelemetry.api.incubator;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import io.opentelemetry.api.incubator.config.ConfigChangeRegistration;
import io.opentelemetry.api.incubator.config.ConfigProvider;
+import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import org.junit.jupiter.api.Test;
class ConfigProviderTest {
@@ -24,5 +27,23 @@ void instrumentationConfigFallback() {
assertThat(configProvider.getInstrumentationConfig()).isNotNull();
assertThat(configProvider.getInstrumentationConfig("servlet")).isNotNull();
assertThat(configProvider.getGeneralInstrumentationConfig()).isNotNull();
+ ConfigChangeRegistration listenerRegistration =
+ configProvider.addConfigChangeListener(
+ ".instrumentation/development.java.servlet", (path, newConfig) -> {});
+ assertThatCode(listenerRegistration::close).doesNotThrowAnyException();
+ }
+
+ @Test
+ void defaultUpdateConfig_isNoop() {
+ ConfigProvider configProvider = ConfigProvider.noop();
+ assertThatCode(() -> configProvider.updateConfig(".foo", DeclarativeConfigProperties.empty()))
+ .doesNotThrowAnyException();
+ }
+
+ @Test
+ void defaultSetConfigProperty_isNoop() {
+ ConfigProvider configProvider = ConfigProvider.noop();
+ assertThatCode(() -> configProvider.setConfigProperty(".foo", "key", "value"))
+ .doesNotThrowAnyException();
}
}
diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/ExtendedOpenTelemetryTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/ExtendedOpenTelemetryTest.java
index 017e691946d..fea6659a711 100644
--- a/api/incubator/src/test/java/io/opentelemetry/api/incubator/ExtendedOpenTelemetryTest.java
+++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/ExtendedOpenTelemetryTest.java
@@ -31,6 +31,7 @@
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -111,6 +112,32 @@ void instrumentationConfig() {
.isEqualTo(Arrays.asList("client-request-header1", "client-request-header2"));
}
+ @Test
+ void close_shutsDownConfigProvider() {
+ String configYaml =
+ "instrumentation/development:\n"
+ + " general:\n"
+ + " http:\n"
+ + " enabled: \"false\"";
+ SdkConfigProvider configProvider =
+ SdkConfigProvider.create(
+ DeclarativeConfiguration.toConfigProperties(
+ new ByteArrayInputStream(configYaml.getBytes(StandardCharsets.UTF_8))));
+ ExtendedOpenTelemetrySdk sdk =
+ ExtendedOpenTelemetrySdk.create(OpenTelemetrySdk.builder().build(), configProvider);
+
+ AtomicInteger callbackCount = new AtomicInteger();
+ configProvider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+
+ sdk.close();
+
+ configProvider.setConfigProperty(
+ ".instrumentation/development.general.http", "enabled", "true");
+ assertThat(callbackCount.get()).isEqualTo(0);
+ }
+
@Test
void instrumentationConfigFallback() {
ConfigProvider configProvider = ConfigProvider.noop();
diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigPropertiesTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigPropertiesTest.java
new file mode 100644
index 00000000000..d966e586a11
--- /dev/null
+++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/config/MapBackedDeclarativeConfigPropertiesTest.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.api.incubator.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import io.opentelemetry.common.ComponentLoader;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class MapBackedDeclarativeConfigPropertiesTest {
+
+ private static final ComponentLoader COMPONENT_LOADER =
+ ComponentLoader.forClassLoader(
+ MapBackedDeclarativeConfigPropertiesTest.class.getClassLoader());
+
+ @Test
+ void getString() {
+ DeclarativeConfigProperties config = fromMap(mapOf("key", "value", "notString", 42));
+
+ assertThat(config.getString("key")).isEqualTo("value");
+ assertThat(config.getString("missing")).isNull();
+ assertThat(config.getString("notString")).isNull();
+ }
+
+ @Test
+ void getBoolean() {
+ DeclarativeConfigProperties config = fromMap(mapOf("key", true, "notBoolean", "true"));
+
+ assertThat(config.getBoolean("key")).isTrue();
+ assertThat(config.getBoolean("missing")).isNull();
+ assertThat(config.getBoolean("notBoolean")).isNull();
+ }
+
+ @Test
+ void getInt() {
+ DeclarativeConfigProperties config = fromMap(mapOf("intVal", 42, "longVal", 100L, "str", "x"));
+
+ assertThat(config.getInt("intVal")).isEqualTo(42);
+ assertThat(config.getInt("longVal")).isEqualTo(100);
+ assertThat(config.getInt("missing")).isNull();
+ assertThat(config.getInt("str")).isNull();
+ }
+
+ @Test
+ void getLong() {
+ DeclarativeConfigProperties config = fromMap(mapOf("longVal", 100L, "intVal", 42, "str", "x"));
+
+ assertThat(config.getLong("longVal")).isEqualTo(100L);
+ assertThat(config.getLong("intVal")).isEqualTo(42L);
+ assertThat(config.getLong("missing")).isNull();
+ assertThat(config.getLong("str")).isNull();
+ }
+
+ @Test
+ void getDouble() {
+ DeclarativeConfigProperties config =
+ fromMap(mapOf("doubleVal", 3.14, "intVal", 42, "str", "x"));
+
+ assertThat(config.getDouble("doubleVal")).isEqualTo(3.14);
+ assertThat(config.getDouble("intVal")).isEqualTo(42.0);
+ assertThat(config.getDouble("missing")).isNull();
+ assertThat(config.getDouble("str")).isNull();
+ }
+
+ @Test
+ void getScalarList() {
+ DeclarativeConfigProperties config =
+ fromMap(mapOf("strings", Arrays.asList("a", "b"), "mixed", Arrays.asList("a", 1)));
+
+ assertThat(config.getScalarList("strings", String.class)).containsExactly("a", "b");
+ assertThat(config.getScalarList("mixed", String.class)).isNull();
+ assertThat(config.getScalarList("missing", String.class)).isNull();
+ }
+
+ @Test
+ void getScalarList_nonListReturnsNull() {
+ DeclarativeConfigProperties config = fromMap(mapOf("notList", "value"));
+
+ assertThat(config.getScalarList("notList", String.class)).isNull();
+ }
+
+ @Test
+ void getStructured() {
+ Map child = mapOf("nested", "value");
+ DeclarativeConfigProperties config = fromMap(mapOf("child", child, "notMap", "scalar"));
+
+ DeclarativeConfigProperties structured = config.getStructured("child");
+ assertThat(structured).isNotNull();
+ assertThat(structured.getString("nested")).isEqualTo("value");
+ assertThat(config.getStructured("missing")).isNull();
+ assertThat(config.getStructured("notMap")).isNull();
+ }
+
+ @Test
+ void getStructuredList() {
+ List> items =
+ Arrays.asList(mapOf("name", "first"), mapOf("name", "second"));
+ DeclarativeConfigProperties config =
+ fromMap(mapOf("items", items, "badItems", Arrays.asList("notAMap")));
+
+ List result = config.getStructuredList("items");
+ assertThat(result).hasSize(2);
+ assertThat(result.get(0).getString("name")).isEqualTo("first");
+ assertThat(result.get(1).getString("name")).isEqualTo("second");
+ assertThat(config.getStructuredList("missing")).isNull();
+ assertThat(config.getStructuredList("badItems")).isNull();
+ }
+
+ @Test
+ void getStructuredList_nonListReturnsNull() {
+ DeclarativeConfigProperties config = fromMap(mapOf("notList", "value"));
+
+ assertThat(config.getStructuredList("notList")).isNull();
+ }
+
+ @Test
+ void getPropertyKeys() {
+ DeclarativeConfigProperties config = fromMap(mapOf("a", 1, "b", 2));
+
+ assertThat(config.getPropertyKeys()).containsExactlyInAnyOrder("a", "b");
+ }
+
+ @Test
+ void getPropertyKeys_empty() {
+ DeclarativeConfigProperties config = fromMap(Collections.emptyMap());
+
+ assertThat(config.getPropertyKeys()).isEmpty();
+ }
+
+ @Test
+ void getComponentLoader() {
+ DeclarativeConfigProperties config = fromMap(Collections.emptyMap());
+
+ assertThat(config.getComponentLoader()).isSameAs(COMPONENT_LOADER);
+ }
+
+ @Test
+ void get_defaultMethod() {
+ Map child = mapOf("nested", "value");
+ DeclarativeConfigProperties config = fromMap(mapOf("child", child));
+
+ assertThat(config.get("child").getString("nested")).isEqualTo("value");
+ assertThat(config.get("missing").getPropertyKeys()).isEmpty();
+ }
+
+ private static DeclarativeConfigProperties fromMap(Map map) {
+ return DeclarativeConfigProperties.fromMap(map, COMPONENT_LOADER);
+ }
+
+ private static Map mapOf(Object... entries) {
+ Map result = new LinkedHashMap<>();
+ for (int i = 0; i < entries.length; i += 2) {
+ result.put((String) entries[i], entries[i + 1]);
+ }
+ return result;
+ }
+}
diff --git a/api/incubator/src/test/java/io/opentelemetry/sdk/internal/SdkConfigProviderTest.java b/api/incubator/src/test/java/io/opentelemetry/sdk/internal/SdkConfigProviderTest.java
new file mode 100644
index 00000000000..7384eeb5d4d
--- /dev/null
+++ b/api/incubator/src/test/java/io/opentelemetry/sdk/internal/SdkConfigProviderTest.java
@@ -0,0 +1,533 @@
+/*
+ * Copyright The OpenTelemetry Authors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package io.opentelemetry.sdk.internal;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import io.opentelemetry.api.incubator.config.ConfigChangeRegistration;
+import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
+import io.opentelemetry.common.ComponentLoader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
+import org.junit.jupiter.api.Test;
+
+class SdkConfigProviderTest {
+
+ @Test
+ void addConfigChangeListener_notifiesOnWatchedPathChange() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ List notifications = new ArrayList<>();
+ ConfigChangeRegistration registration =
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(path + "=" + newConfig.getString("enabled")));
+
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+
+ assertThat(notifications).containsExactly(".instrumentation/development.general.http=true");
+ registration.close();
+ }
+
+ @Test
+ void addConfigChangeListener_ignoresUnchangedAndNonWatchedUpdates() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf(
+ "general",
+ mapOf("http", mapOf("enabled", "true")),
+ "java",
+ mapOf("servlet", mapOf("enabled", "true"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf(
+ "general",
+ mapOf("http", mapOf("enabled", "true")),
+ "java",
+ mapOf("servlet", mapOf("enabled", "false"))))));
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf(
+ "general",
+ mapOf("http", mapOf("enabled", "true")),
+ "java",
+ mapOf("servlet", mapOf("enabled", "false"))))));
+
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void addConfigChangeListener_returnsEmptyNodeWhenWatchedPathCleared() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+ List> propertyKeysSeen = new ArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> propertyKeysSeen.add(newConfig.getPropertyKeys()));
+
+ provider.updateOpenTelemetryConfigModel(
+ config(mapOf("instrumentation/development", mapOf("general", mapOf()))));
+
+ assertThat(propertyKeysSeen).containsExactly(Collections.emptySet());
+ }
+
+ @Test
+ void addConfigChangeListener_closeAndShutdownStopCallbacks() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ ConfigChangeRegistration registration =
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+
+ registration.close();
+ registration.close();
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+ assertThat(callbackCount).hasValue(0);
+
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+ provider.shutdown();
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void addConfigChangeListener_listenerExceptionIsIsolated() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ AtomicInteger successfulCallbacks = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> {
+ throw new IllegalStateException("boom");
+ });
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> successfulCallbacks.incrementAndGet());
+
+ provider.updateOpenTelemetryConfigModel(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+
+ assertThat(successfulCallbacks).hasValue(1);
+ }
+
+ @Test
+ void updateConfig_replacesSubtreeAndNotifiesListener() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ List notifications = new ArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(path + "=" + newConfig.getString("enabled")));
+
+ provider.updateConfig(
+ ".instrumentation/development.general.http", config(mapOf("enabled", "true")));
+
+ assertThat(notifications).containsExactly(".instrumentation/development.general.http=true");
+ }
+
+ @Test
+ void updateConfig_doesNotNotifyWhenSubtreeUnchanged() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+
+ provider.updateConfig(
+ ".instrumentation/development.general.http", config(mapOf("enabled", "true")));
+
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void updateConfig_createsIntermediateNodesIfMissing() {
+ SdkConfigProvider provider = SdkConfigProvider.create(config(mapOf()));
+ List notifications = new ArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(path + "=" + newConfig.getString("enabled")));
+
+ provider.updateConfig(
+ ".instrumentation/development.general.http", config(mapOf("enabled", "true")));
+
+ assertThat(notifications).containsExactly(".instrumentation/development.general.http=true");
+ }
+
+ @Test
+ void updateConfig_noopWhenDisposed() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+ provider.shutdown();
+
+ provider.updateConfig(
+ ".instrumentation/development.general.http", config(mapOf("enabled", "true")));
+
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void setConfigProperty_setsScalarAndNotifiesListener() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ List notifications = new ArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(path + "=" + newConfig.getString("enabled")));
+
+ provider.setConfigProperty(".instrumentation/development.general.http", "enabled", "true");
+
+ assertThat(notifications).containsExactly(".instrumentation/development.general.http=true");
+ }
+
+ @Test
+ void setConfigProperty_doesNotNotifyWhenValueUnchanged() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+
+ provider.setConfigProperty(".instrumentation/development.general.http", "enabled", "true");
+
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void setConfigProperty_noopWhenDisposed() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ AtomicInteger callbackCount = new AtomicInteger();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> callbackCount.incrementAndGet());
+ provider.shutdown();
+
+ provider.setConfigProperty(".instrumentation/development.general.http", "enabled", "true");
+
+ assertThat(callbackCount).hasValue(0);
+ }
+
+ @Test
+ void concurrentUpdates_allChangesAreApplied() throws Exception {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("count", "0"))))));
+ List notifications = new CopyOnWriteArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(newConfig.getString("count")));
+
+ int threadCount = 10;
+ ExecutorService executor = Executors.newFixedThreadPool(threadCount);
+ CountDownLatch startLatch = new CountDownLatch(1);
+ CountDownLatch doneLatch = new CountDownLatch(threadCount);
+ List> futures = new ArrayList<>();
+ for (int i = 0; i < threadCount; i++) {
+ int index = i + 1;
+ futures.add(
+ executor.submit(
+ () -> {
+ try {
+ startLatch.await();
+ provider.setConfigProperty(
+ ".instrumentation/development.general.http", "count", String.valueOf(index));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ } finally {
+ doneLatch.countDown();
+ }
+ }));
+ }
+ startLatch.countDown();
+ assertThat(doneLatch.await(5, TimeUnit.SECONDS)).isTrue();
+ for (Future> future : futures) {
+ future.get(1, TimeUnit.SECONDS);
+ }
+ executor.shutdown();
+
+ assertThat(notifications).hasSize(threadCount);
+ DeclarativeConfigProperties finalConfig =
+ provider.getInstrumentationConfig().get("general").get("http");
+ assertThat(finalConfig.getString("count")).isNotNull();
+ }
+
+ @Test
+ void pathValidation_rejectsMissingLeadingDot() {
+ SdkConfigProvider provider = SdkConfigProvider.create(config(mapOf()));
+
+ assertThatThrownBy(
+ () -> provider.addConfigChangeListener("instrumentation", (path, newConfig) -> {}))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.updateConfig("instrumentation", config(mapOf())))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.setConfigProperty("instrumentation", "key", "value"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void pathValidation_rejectsWildcards() {
+ SdkConfigProvider provider = SdkConfigProvider.create(config(mapOf()));
+
+ assertThatThrownBy(() -> provider.addConfigChangeListener(".*", (path, newConfig) -> {}))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.updateConfig(".foo.*", config(mapOf())))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.setConfigProperty(".foo.*", "key", "value"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void pathValidation_rejectsBrackets() {
+ SdkConfigProvider provider = SdkConfigProvider.create(config(mapOf()));
+
+ assertThatThrownBy(() -> provider.addConfigChangeListener(".foo[0]", (path, newConfig) -> {}))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.updateConfig(".foo[0]", config(mapOf())))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> provider.setConfigProperty(".foo[0]", "key", "value"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void updateConfig_rootPathReplacesEntireConfig() {
+ SdkConfigProvider provider =
+ SdkConfigProvider.create(
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "false"))))));
+ List notifications = new ArrayList<>();
+ provider.addConfigChangeListener(
+ ".instrumentation/development.general.http",
+ (path, newConfig) -> notifications.add(path + "=" + newConfig.getString("enabled")));
+
+ provider.updateConfig(
+ ".",
+ config(
+ mapOf(
+ "instrumentation/development",
+ mapOf("general", mapOf("http", mapOf("enabled", "true"))))));
+
+ assertThat(notifications).containsExactly(".instrumentation/development.general.http=true");
+ }
+
+ private static DeclarativeConfigProperties config(Map root) {
+ return new MapBackedDeclarativeConfigProperties(root);
+ }
+
+ private static Map mapOf(Object... entries) {
+ Map result = new LinkedHashMap<>();
+ for (int i = 0; i < entries.length; i += 2) {
+ result.put((String) entries[i], entries[i + 1]);
+ }
+ return result;
+ }
+
+ private static final class MapBackedDeclarativeConfigProperties
+ implements DeclarativeConfigProperties {
+ private static final ComponentLoader COMPONENT_LOADER =
+ ComponentLoader.forClassLoader(MapBackedDeclarativeConfigProperties.class.getClassLoader());
+
+ private final Map values;
+
+ private MapBackedDeclarativeConfigProperties(Map values) {
+ this.values = values;
+ }
+
+ @Override
+ public String getString(String name) {
+ Object value = values.get(name);
+ return value instanceof String ? (String) value : null;
+ }
+
+ @Override
+ public Boolean getBoolean(String name) {
+ Object value = values.get(name);
+ return value instanceof Boolean ? (Boolean) value : null;
+ }
+
+ @Override
+ public Integer getInt(String name) {
+ Object value = values.get(name);
+ return value instanceof Integer ? (Integer) value : null;
+ }
+
+ @Override
+ public Long getLong(String name) {
+ Object value = values.get(name);
+ if (value instanceof Long) {
+ return (Long) value;
+ }
+ if (value instanceof Integer) {
+ return ((Integer) value).longValue();
+ }
+ return null;
+ }
+
+ @Override
+ public Double getDouble(String name) {
+ Object value = values.get(name);
+ if (value instanceof Double) {
+ return (Double) value;
+ }
+ if (value instanceof Number) {
+ return ((Number) value).doubleValue();
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ @Override
+ public List getScalarList(String name, Class scalarType) {
+ Object value = values.get(name);
+ if (!(value instanceof List)) {
+ return null;
+ }
+ List raw = (List) value;
+ List casted = new ArrayList<>(raw.size());
+ for (Object element : raw) {
+ if (!scalarType.isInstance(element)) {
+ return null;
+ }
+ casted.add((T) element);
+ }
+ return casted;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public DeclarativeConfigProperties getStructured(String name) {
+ Object value = values.get(name);
+ if (!(value instanceof Map)) {
+ return null;
+ }
+ return new MapBackedDeclarativeConfigProperties((Map) value);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Nullable
+ @Override
+ public List getStructuredList(String name) {
+ Object value = values.get(name);
+ if (!(value instanceof List)) {
+ return null;
+ }
+ List raw = (List) value;
+ List result = new ArrayList<>(raw.size());
+ for (Object element : raw) {
+ if (!(element instanceof Map)) {
+ return null;
+ }
+ result.add(new MapBackedDeclarativeConfigProperties((Map) element));
+ }
+ return result;
+ }
+
+ @Override
+ public Set getPropertyKeys() {
+ return values.keySet();
+ }
+
+ @Override
+ public ComponentLoader getComponentLoader() {
+ return COMPONENT_LOADER;
+ }
+ }
+}
diff --git a/sdk/all/src/main/java/io/opentelemetry/sdk/internal/ExtendedOpenTelemetrySdk.java b/sdk/all/src/main/java/io/opentelemetry/sdk/internal/ExtendedOpenTelemetrySdk.java
index dc89b116108..9a72679103f 100644
--- a/sdk/all/src/main/java/io/opentelemetry/sdk/internal/ExtendedOpenTelemetrySdk.java
+++ b/sdk/all/src/main/java/io/opentelemetry/sdk/internal/ExtendedOpenTelemetrySdk.java
@@ -6,6 +6,8 @@
package io.opentelemetry.sdk.internal;
import io.opentelemetry.api.incubator.ExtendedOpenTelemetry;
+import io.opentelemetry.api.incubator.config.ConfigChangeListener;
+import io.opentelemetry.api.incubator.config.ConfigChangeRegistration;
import io.opentelemetry.api.incubator.config.ConfigProvider;
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
import io.opentelemetry.sdk.OpenTelemetrySdk;
@@ -50,6 +52,12 @@ public SdkConfigProvider getSdkConfigProvider() {
return configProvider.unobfuscate();
}
+ @Override
+ public void close() {
+ configProvider.unobfuscate().shutdown();
+ super.close();
+ }
+
@Override
public String toString() {
return "ExtendedOpenTelemetrySdk{"
@@ -81,6 +89,22 @@ public DeclarativeConfigProperties getInstrumentationConfig() {
return delegate.getInstrumentationConfig();
}
+ @Override
+ public ConfigChangeRegistration addConfigChangeListener(
+ String path, ConfigChangeListener listener) {
+ return delegate.addConfigChangeListener(path, listener);
+ }
+
+ @Override
+ public void updateConfig(String path, DeclarativeConfigProperties newSubtree) {
+ delegate.updateConfig(path, newSubtree);
+ }
+
+ @Override
+ public void setConfigProperty(String path, String key, Object value) {
+ delegate.setConfigProperty(path, key, value);
+ }
+
private SdkConfigProvider unobfuscate() {
return delegate;
}
diff --git a/sdk/all/src/main/java/io/opentelemetry/sdk/internal/SdkConfigProvider.java b/sdk/all/src/main/java/io/opentelemetry/sdk/internal/SdkConfigProvider.java
index a5399ed4a23..6dc1ca4ba6a 100644
--- a/sdk/all/src/main/java/io/opentelemetry/sdk/internal/SdkConfigProvider.java
+++ b/sdk/all/src/main/java/io/opentelemetry/sdk/internal/SdkConfigProvider.java
@@ -5,8 +5,22 @@
package io.opentelemetry.sdk.internal;
+import static java.util.Objects.requireNonNull;
+
+import io.opentelemetry.api.incubator.config.ConfigChangeListener;
+import io.opentelemetry.api.incubator.config.ConfigChangeRegistration;
import io.opentelemetry.api.incubator.config.ConfigProvider;
import io.opentelemetry.api.incubator.config.DeclarativeConfigProperties;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.logging.Level;
+import java.util.logging.Logger;
/**
* SDK implementation of {@link ConfigProvider}.
@@ -16,11 +30,16 @@
* guarantees are made.
*/
public final class SdkConfigProvider implements ConfigProvider {
+ private static final Logger logger = Logger.getLogger(SdkConfigProvider.class.getName());
+ private static final ConfigChangeRegistration NOOP_CHANGE_REGISTRATION = () -> {};
- private final DeclarativeConfigProperties instrumentationConfig;
+ private final AtomicReference openTelemetryConfigModel;
+ private final ConcurrentMap> listenersByPath =
+ new ConcurrentHashMap<>();
+ private final AtomicBoolean disposed = new AtomicBoolean(false);
private SdkConfigProvider(DeclarativeConfigProperties openTelemetryConfigModel) {
- this.instrumentationConfig = openTelemetryConfigModel.get("instrumentation/development");
+ this.openTelemetryConfigModel = new AtomicReference<>(requireNonNull(openTelemetryConfigModel));
}
/**
@@ -36,11 +55,240 @@ public static SdkConfigProvider create(DeclarativeConfigProperties openTelemetry
@Override
public DeclarativeConfigProperties getInstrumentationConfig() {
- return instrumentationConfig;
+ return requireNonNull(openTelemetryConfigModel.get()).get("instrumentation/development");
+ }
+
+ @Override
+ public ConfigChangeRegistration addConfigChangeListener(
+ String path, ConfigChangeListener listener) {
+ requireNonNull(listener, "listener");
+ String watchedPath = normalizeAndValidatePath(path); // fail fast on invalid path
+ if (disposed.get()) {
+ return NOOP_CHANGE_REGISTRATION;
+ }
+
+ ListenerRegistration registration = new ListenerRegistration(watchedPath, listener);
+ listenersByPath
+ .computeIfAbsent(watchedPath, unused -> new CopyOnWriteArrayList<>())
+ .add(registration);
+ if (disposed.get()) {
+ registration.close();
+ return NOOP_CHANGE_REGISTRATION;
+ }
+ return registration;
+ }
+
+ @Override
+ public void updateConfig(String path, DeclarativeConfigProperties newSubtree) {
+ requireNonNull(newSubtree, "newSubtree");
+ String normalizedPath = normalizeAndValidatePath(path);
+ if (disposed.get()) {
+ return;
+ }
+ Map subtreeMap = DeclarativeConfigProperties.toMap(newSubtree);
+ while (true) {
+ DeclarativeConfigProperties current = requireNonNull(openTelemetryConfigModel.get());
+ Map rootMap = DeclarativeConfigProperties.toMap(current);
+ setSubtreeAtPath(rootMap, normalizedPath, subtreeMap);
+ DeclarativeConfigProperties newRoot =
+ DeclarativeConfigProperties.fromMap(rootMap, current.getComponentLoader());
+ if (openTelemetryConfigModel.compareAndSet(current, newRoot)) {
+ notifyListeners(current, newRoot);
+ return;
+ }
+ }
+ }
+
+ @Override
+ public void setConfigProperty(String path, String key, Object value) {
+ requireNonNull(key, "key");
+ requireNonNull(value, "value");
+ String normalizedPath = normalizeAndValidatePath(path);
+ if (disposed.get()) {
+ return;
+ }
+ while (true) {
+ DeclarativeConfigProperties current = requireNonNull(openTelemetryConfigModel.get());
+ Map rootMap = DeclarativeConfigProperties.toMap(current);
+ navigateToPath(rootMap, normalizedPath).put(key, value);
+ DeclarativeConfigProperties newRoot =
+ DeclarativeConfigProperties.fromMap(rootMap, current.getComponentLoader());
+ if (openTelemetryConfigModel.compareAndSet(current, newRoot)) {
+ notifyListeners(current, newRoot);
+ return;
+ }
+ }
+ }
+
+ // Visible for testing.
+ void updateOpenTelemetryConfigModel(DeclarativeConfigProperties updatedOpenTelemetryConfigModel) {
+ requireNonNull(updatedOpenTelemetryConfigModel, "updatedOpenTelemetryConfigModel");
+ DeclarativeConfigProperties previous =
+ openTelemetryConfigModel.getAndSet(updatedOpenTelemetryConfigModel);
+ notifyListeners(previous, updatedOpenTelemetryConfigModel);
+ }
+
+ private void notifyListeners(
+ DeclarativeConfigProperties previous, DeclarativeConfigProperties updated) {
+ if (disposed.get()) {
+ return;
+ }
+
+ for (Map.Entry> entry :
+ listenersByPath.entrySet()) {
+ String watchedPath = entry.getKey();
+ DeclarativeConfigProperties previousConfigAtPath = resolvePath(previous, watchedPath);
+ DeclarativeConfigProperties updatedConfigAtPath = resolvePath(updated, watchedPath);
+ if (hasSameContents(previousConfigAtPath, updatedConfigAtPath)) {
+ continue;
+ }
+
+ for (ListenerRegistration registration : entry.getValue()) {
+ registration.notifyChange(watchedPath, updatedConfigAtPath);
+ }
+ }
+ }
+
+ void shutdown() {
+ if (!disposed.compareAndSet(false, true)) {
+ return;
+ }
+ for (List registrations : listenersByPath.values()) {
+ for (ListenerRegistration registration : registrations) {
+ registration.close();
+ }
+ }
+ listenersByPath.clear();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void setSubtreeAtPath(
+ Map rootMap, String normalizedPath, Map subtreeMap) {
+ String relativePath = normalizedPath.substring(1);
+ if (relativePath.isEmpty()) {
+ rootMap.clear();
+ rootMap.putAll(subtreeMap);
+ return;
+ }
+ String[] segments = relativePath.split("\\.");
+ Map parent = rootMap;
+ for (int i = 0; i < segments.length - 1; i++) {
+ Object child = parent.get(segments[i]);
+ if (child instanceof Map) {
+ parent = (Map) child;
+ } else {
+ Map newChild = new HashMap<>();
+ parent.put(segments[i], newChild);
+ parent = newChild;
+ }
+ }
+ parent.put(segments[segments.length - 1], subtreeMap);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map navigateToPath(
+ Map rootMap, String normalizedPath) {
+ String relativePath = normalizedPath.substring(1);
+ if (relativePath.isEmpty()) {
+ return rootMap;
+ }
+ Map current = rootMap;
+ String[] segments = relativePath.split("\\.");
+ for (String segment : segments) {
+ Object child = current.get(segment);
+ if (child instanceof Map) {
+ current = (Map) child;
+ } else {
+ Map newChild = new HashMap<>();
+ current.put(segment, newChild);
+ current = newChild;
+ }
+ }
+ return current;
+ }
+
+ private static boolean hasSameContents(
+ DeclarativeConfigProperties left, DeclarativeConfigProperties right) {
+ return DeclarativeConfigProperties.toMap(left).equals(DeclarativeConfigProperties.toMap(right));
+ }
+
+ private static DeclarativeConfigProperties resolvePath(
+ DeclarativeConfigProperties root, String watchedPath) {
+ String relativePath = watchedPath.substring(1);
+ if (relativePath.isEmpty()) {
+ return root;
+ }
+
+ DeclarativeConfigProperties current = root;
+ String[] segments = relativePath.split("\\.");
+ for (String segment : segments) {
+ if (segment.isEmpty()) {
+ return DeclarativeConfigProperties.empty();
+ }
+ current = current.get(segment);
+ }
+ return current;
+ }
+
+ private static String normalizeAndValidatePath(String path) {
+ String watchedPath = requireNonNull(path, "path").trim();
+ if (!watchedPath.startsWith(".")) {
+ throw new IllegalArgumentException(
+ "Config change listener path must be absolute and start with '.': " + path);
+ }
+ if (watchedPath.indexOf('*') >= 0) {
+ throw new IllegalArgumentException(
+ "Config change listener path does not support wildcards: " + path);
+ }
+ if (watchedPath.indexOf('[') >= 0 || watchedPath.indexOf(']') >= 0) {
+ throw new IllegalArgumentException(
+ "Config change listener path does not support sequence indexing: " + path);
+ }
+ return watchedPath;
+ }
+
+ private final class ListenerRegistration implements ConfigChangeRegistration {
+ private final String watchedPath;
+ private final ConfigChangeListener listener;
+ private final AtomicBoolean closed = new AtomicBoolean(false);
+
+ private ListenerRegistration(String watchedPath, ConfigChangeListener listener) {
+ this.watchedPath = watchedPath;
+ this.listener = listener;
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ CopyOnWriteArrayList registrations = listenersByPath.get(watchedPath);
+ if (registrations == null) {
+ return;
+ }
+ registrations.remove(this);
+ if (registrations.isEmpty()) {
+ listenersByPath.remove(watchedPath, registrations);
+ }
+ }
+
+ private void notifyChange(String changedPath, DeclarativeConfigProperties updatedConfigAtPath) {
+ if (closed.get()) {
+ return;
+ }
+ try {
+ listener.onChange(changedPath, updatedConfigAtPath);
+ } catch (Throwable throwable) {
+ logger.log(
+ Level.WARNING,
+ "Config change listener threw while handling path " + changedPath,
+ throwable);
+ }
+ }
}
@Override
public String toString() {
- return "SdkConfigProvider{" + "instrumentationConfig=" + instrumentationConfig + '}';
+ return "SdkConfigProvider{" + "instrumentationConfig=" + getInstrumentationConfig() + '}';
}
}