Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4559fdf
Add InstrumentationDefaults helper to declarative-config-bridge
zeitlinger Apr 10, 2026
0572994
fix: add @CanIgnoreReturnValue to InstrumentationDefaults.setDefault
zeitlinger Apr 13, 2026
deef7d8
fix: add @CanIgnoreReturnValue to applyToModel
zeitlinger Apr 13, 2026
97ab9a4
docs: add InstrumentationDefaults usage to declarative-config-bridge …
zeitlinger Apr 13, 2026
1e43af3
docs(declarative-config-bridge): restructure InstrumentationDefaults …
zeitlinger Apr 17, 2026
425d9c2
refactor(declarative-config-bridge): nest InstrumentationDefaults API
zeitlinger Apr 18, 2026
ea1616d
docs(declarative-config-bridge): use get instead of getStructured in …
zeitlinger Apr 18, 2026
a370c6a
Merge branch 'main' into instrumentation-defaults
trask May 4, 2026
a7d2e54
fix: mirror development key translation in InstrumentationDefaults
zeitlinger May 4, 2026
0f27445
test: use parameterized InstrumentationDefaults translation cases
zeitlinger May 4, 2026
5b0f8fc
test: add bridge roundtrip coverage for InstrumentationDefaults
zeitlinger May 4, 2026
fdadd88
Use HashMap for InstrumentationDefaults storage; iteration order is n…
zeitlinger May 8, 2026
ef6c451
fix: isolate declarative model support in defaults helper
zeitlinger May 12, 2026
93b37ee
fix: export incubator model dependency from config bridge
zeitlinger May 12, 2026
b4a4100
Merge remote-tracking branch 'origin/main' into lane-pr17816-fix
zeitlinger May 12, 2026
256f42b
fix: align config bridge defaults helper with declarative config API
zeitlinger May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions declarative-config-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,106 @@ public class InferredSpansComponentProvider implements ComponentProvider {
}
}
```

Comment thread
zeitlinger marked this conversation as resolved.
## 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;
}
}
Comment thread
zeitlinger marked this conversation as resolved.
```

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`).
2 changes: 2 additions & 0 deletions declarative-config-bridge/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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)}.
*
* <p>Usage:
*
* <pre>{@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);
* }</pre>
*
* <p>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<String, String> defaults;
private final List<String> path;
private final Map<String, String> propertyMappings;

public DefaultInstrumentationConfig() {
this(new HashMap<>(), emptyList(), new HashMap<>());
}

private DefaultInstrumentationConfig(
Map<String, String> defaults, List<String> path, Map<String, String> 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<String> 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.
*
* <p>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.
*
* <p>Defaults use {@code otel.instrumentation.*} keys unless a custom mapping overrides the
* property prefix for a declarative path subtree.
*/
public Map<String, String> toConfigProperties() {
HashMap<String, String> map = new HashMap<>();
defaults.forEach((declarativePath, value) -> map.put(toConfigProperty(declarativePath), value));
return map;
}

Map<String, String> 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<String, String> 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('_', '-');
}
}
Original file line number Diff line number Diff line change
@@ -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<String, ExperimentalLanguageSpecificInstrumentationPropertyModel> props =
java.getAdditionalProperties();

for (Map.Entry<String, String> entry : defaults.getDefaults().entrySet()) {
applyDefault(props, entry.getKey(), entry.getValue());
}

return model;
}

private static void applyDefault(
Map<String, ExperimentalLanguageSpecificInstrumentationPropertyModel> props,
String declarativePath,
String value) {
String[] segments = declarativePath.split("\\.");
ExperimentalLanguageSpecificInstrumentationPropertyModel propertyModel =
props.computeIfAbsent(
segments[0], key -> new ExperimentalLanguageSpecificInstrumentationPropertyModel());
Map<String, Object> target = propertyModel.getAdditionalProperties();
for (int i = 1; i < segments.length - 1; i++) {
Object child = target.get(segments[i]);
if (child == null) {
Map<String, Object> 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<String, Object> nested = (Map<String, Object>) child;
target = nested;
}
target.putIfAbsent(segments[segments.length - 1], value);
}
}
Loading
Loading