From d8fe841d783341cc193c7d50f5563ce9f38214a6 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Thu, 12 Feb 2026 11:53:47 +0000 Subject: [PATCH 1/3] Add system property substitution to declarative config Extends env var substitution to support Java system properties via ${sys:property.name} syntax. Maintains backward compatibility with existing ${VAR} and ${env:VAR} patterns. All support :-default values. Addresses #5926 --- .../fileconfig/DeclarativeConfiguration.java | 69 +++++--- .../DeclarativeConfigurationParseTest.java | 151 +++++++++++++++++- 2 files changed, 199 insertions(+), 21 deletions(-) diff --git a/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfiguration.java b/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfiguration.java index 6e67bd0bdb4..6c89702f234 100644 --- a/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfiguration.java +++ b/sdk-extensions/incubator/src/main/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfiguration.java @@ -57,8 +57,9 @@ public final class DeclarativeConfiguration { private static final Logger logger = Logger.getLogger(DeclarativeConfiguration.class.getName()); + // Matches ${VAR_NAME}, ${env:VAR_NAME}, or ${sys:property.name} with optional :-default private static final Pattern ENV_VARIABLE_REFERENCE = - Pattern.compile("\\$\\{([a-zA-Z_][a-zA-Z0-9_]*)(:-([^\n}]*))?}"); + Pattern.compile("\\$\\{(?:(env|sys):)?([a-zA-Z_][a-zA-Z0-9_.]*)(?::-([^\\n}]*))?}"); private static final ComponentLoader DEFAULT_COMPONENT_LOADER = ComponentLoader.forClassLoader(DeclarativeConfigProperties.class.getClassLoader()); @@ -140,7 +141,8 @@ private static ExtendedOpenTelemetrySdk create( /** * Parse the {@code configuration} YAML and return the {@link OpenTelemetryConfigurationModel}. * - *

During parsing, environment variable substitution is performed as defined in the During parsing, environment variable and system property substitution is performed as + * defined in the * OpenTelemetry Configuration Data Model specification. * @@ -148,7 +150,7 @@ private static ExtendedOpenTelemetrySdk create( */ public static OpenTelemetryConfigurationModel parse(InputStream configuration) { try { - return parse(configuration, System.getenv()); + return parse(configuration, System.getenv(), System.getProperties()); } catch (RuntimeException e) { throw new DeclarativeConfigException("Unable to parse configuration input stream", e); } @@ -156,15 +158,20 @@ public static OpenTelemetryConfigurationModel parse(InputStream configuration) { // Visible for testing static OpenTelemetryConfigurationModel parse( - InputStream configuration, Map environmentVariables) { - Object yamlObj = loadYaml(configuration, environmentVariables); + InputStream configuration, + Map environmentVariables, + Map systemProperties) { + Object yamlObj = loadYaml(configuration, environmentVariables, systemProperties); return MAPPER.convertValue(yamlObj, OpenTelemetryConfigurationModel.class); } // Visible for testing - static Object loadYaml(InputStream inputStream, Map environmentVariables) { + static Object loadYaml( + InputStream inputStream, + Map environmentVariables, + Map systemProperties) { LoadSettings settings = LoadSettings.builder().setSchema(new CoreSchema()).build(); - Load yaml = new EnvLoad(settings, environmentVariables); + Load yaml = new EnvLoad(settings, environmentVariables, systemProperties); return yaml.loadFromInputStream(inputStream); } @@ -185,7 +192,7 @@ public static DeclarativeConfigProperties toConfigProperties(Object model) { * @return a generic {@link DeclarativeConfigProperties} representation of the model */ public static DeclarativeConfigProperties toConfigProperties(InputStream configuration) { - Object yamlObj = loadYaml(configuration, System.getenv()); + Object yamlObj = loadYaml(configuration, System.getenv(), System.getProperties()); return toConfigProperties(yamlObj, DEFAULT_COMPONENT_LOADER); } @@ -256,11 +263,16 @@ private static final class EnvLoad extends Load { private final LoadSettings settings; private final Map environmentVariables; + private final Map systemProperties; - private EnvLoad(LoadSettings settings, Map environmentVariables) { + private EnvLoad( + LoadSettings settings, + Map environmentVariables, + Map systemProperties) { super(settings); this.settings = settings; this.environmentVariables = environmentVariables; + this.systemProperties = systemProperties; } @Override @@ -271,12 +283,14 @@ public Object loadFromInputStream(InputStream yamlStream) { settings, new ParserImpl( settings, new StreamReader(settings, new YamlUnicodeReader(yamlStream))), - environmentVariables)); + environmentVariables, + systemProperties)); } } /** - * A YAML Composer that performs environment variable substitution according to the * OpenTelemetry Configuration Data Model specification. * @@ -284,22 +298,24 @@ settings, new StreamReader(settings, new YamlUnicodeReader(yamlStream))), * *

* - *

Environment variable substitution only applies to scalar values. Mapping keys are not - * candidates for substitution. Referenced environment variables that are undefined, null, or - * empty are replaced with empty values unless a default value is provided. + *

Substitution only applies to scalar values. Mapping keys are not candidates for + * substitution. Referenced variables that are undefined, null, or empty are replaced with empty + * values unless a default value is provided. * *

The {@code $} character serves as an escape sequence where {@code $$} in the input is - * translated to a single {@code $} in the output. This prevents environment variable substitution - * for the escaped content. + * translated to a single {@code $} in the output. This prevents substitution for the escaped + * content. */ private static final class EnvComposer extends Composer { private final Load load; private final Map environmentVariables; + private final Map systemProperties; private final ScalarResolver scalarResolver; private static final String ESCAPE_SEQUENCE = "$$"; @@ -307,10 +323,14 @@ private static final class EnvComposer extends Composer { private static final char ESCAPE_SEQUENCE_REPLACEMENT = '$'; private EnvComposer( - LoadSettings settings, ParserImpl parser, Map environmentVariables) { + LoadSettings settings, + ParserImpl parser, + Map environmentVariables, + Map systemProperties) { super(settings, parser); this.load = new Load(settings); this.environmentVariables = environmentVariables; + this.systemProperties = systemProperties; this.scalarResolver = settings.getSchema().getScalarResolver(); } @@ -397,12 +417,23 @@ private StringBuilder envVarSubstitution( int offset = 0; do { MatchResult matchResult = matcher.toMatchResult(); - String envVarKey = matcher.group(1); + String prefix = matcher.group(1); // "env", "sys", or null + String key = matcher.group(2); // variable/property name String defaultValue = matcher.group(3); if (defaultValue == null) { defaultValue = ""; } - String replacement = environmentVariables.getOrDefault(envVarKey, defaultValue); + + String replacement; + if ("sys".equals(prefix)) { + // System property substitution + Object sysProp = systemProperties.get(key); + replacement = sysProp != null ? sysProp.toString() : defaultValue; + } else { + // Environment variable substitution (default or explicit "env:" prefix) + replacement = environmentVariables.getOrDefault(key, defaultValue); + } + newVal.append(val, offset, matchResult.start()).append(replacement); offset = matchResult.end(); } while (matcher.find()); diff --git a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java index ffc1322bcb1..72ed08545a4 100644 --- a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java +++ b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java @@ -999,6 +999,7 @@ void coreSchemaValues(String rawYaml, Object expectedYamlResult) { Object yaml = DeclarativeConfiguration.loadYaml( new ByteArrayInputStream(rawYaml.getBytes(StandardCharsets.UTF_8)), + Collections.emptyMap(), Collections.emptyMap()); assertThat(yaml).isEqualTo(expectedYamlResult); } @@ -1029,7 +1030,8 @@ void envSubstituteAndLoadYaml(String rawYaml, Object expectedYamlResult) { Object yaml = DeclarativeConfiguration.loadYaml( new ByteArrayInputStream(rawYaml.getBytes(StandardCharsets.UTF_8)), - environmentVariables); + environmentVariables, + Collections.emptyMap()); assertThat(yaml).isEqualTo(expectedYamlResult); } @@ -1131,6 +1133,64 @@ private static Map mapOf(Map.Entry... entries) { return result; } + @ParameterizedTest + @MethodSource("sysPropertySubstitutionArgs") + void sysPropertySubstituteAndLoadYaml(String rawYaml, Object expectedYamlResult) { + Map systemProperties = new HashMap<>(); + systemProperties.put("foo.bar", "BAR"); + systemProperties.put("str.1", "value1"); + systemProperties.put("str.2", "value2"); + systemProperties.put("value.with.escape", "value$$"); + systemProperties.put("empty.str", ""); + systemProperties.put("bool.prop", "true"); + systemProperties.put("int.prop", "1"); + systemProperties.put("float.prop", "1.1"); + systemProperties.put("hex.prop", "0xdeadbeef"); + + Object yaml = + DeclarativeConfiguration.loadYaml( + new ByteArrayInputStream(rawYaml.getBytes(StandardCharsets.UTF_8)), + Collections.emptyMap(), + systemProperties); + assertThat(yaml).isEqualTo(expectedYamlResult); + } + + @SuppressWarnings("unchecked") + private static java.util.stream.Stream sysPropertySubstitutionArgs() { + return java.util.stream.Stream.of( + // Simple cases with sys: prefix + Arguments.of("key1: ${sys:str.1}\n", mapOf(entry("key1", "value1"))), + Arguments.of("key1: ${sys:bool.prop}\n", mapOf(entry("key1", true))), + Arguments.of("key1: ${sys:int.prop}\n", mapOf(entry("key1", 1))), + Arguments.of("key1: ${sys:float.prop}\n", mapOf(entry("key1", 1.1))), + Arguments.of("key1: ${sys:hex.prop}\n", mapOf(entry("key1", 3735928559L))), + // Default values + Arguments.of("key1: ${sys:not.set:-value1}\n", mapOf(entry("key1", "value1"))), + Arguments.of("key1: ${sys:not.set:-true}\n", mapOf(entry("key1", true))), + Arguments.of("key1: ${sys:not.set:-1}\n", mapOf(entry("key1", 1))), + // Multiple property references + Arguments.of("key1: ${sys:str.1}${sys:str.2}\n", mapOf(entry("key1", "value1value2"))), + Arguments.of("key1: ${sys:str.1} ${sys:str.2}\n", mapOf(entry("key1", "value1 value2"))), + Arguments.of( + "key1: ${sys:str.1} ${sys:not.set:-default} ${sys:str.2}\n", + mapOf(entry("key1", "value1 default value2"))), + // Undefined / empty system property + Arguments.of("key1: ${sys:empty.str}\n", mapOf(entry("key1", null))), + Arguments.of("key1: ${sys:str.3}\n", mapOf(entry("key1", null))), + Arguments.of("key1: ${sys:str.1} ${sys:str.3}\n", mapOf(entry("key1", "value1 "))), + // Quoted system properties + Arguments.of("key1: \"${sys:hex.prop}\"\n", mapOf(entry("key1", "0xdeadbeef"))), + Arguments.of("key1: \"${sys:str.1}\"\n", mapOf(entry("key1", "value1"))), + Arguments.of("key1: '${sys:str.1}'\n", mapOf(entry("key1", "value1"))), + // Escaped + Arguments.of("key1: ${sys:foo.bar}\n", mapOf(entry("key1", "BAR"))), + Arguments.of("key1: $${sys:foo.bar}\n", mapOf(entry("key1", "${sys:foo.bar}"))), + Arguments.of("key1: $$${sys:foo.bar}\n", mapOf(entry("key1", "$BAR"))), + Arguments.of("key1: $$$${sys:foo.bar}\n", mapOf(entry("key1", "$${sys:foo.bar}"))), + // Mixed env and sys + Arguments.of("key1: ${sys:value.with.escape}\n", mapOf(entry("key1", "value$$")))); + } + @Test void read_WithEnvironmentVariables() { String yaml = @@ -1149,7 +1209,9 @@ void read_WithEnvironmentVariables() { envVars.put("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317"); OpenTelemetryConfigurationModel model = DeclarativeConfiguration.parse( - new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)), envVars); + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)), + envVars, + Collections.emptyMap()); assertThat(model) .isEqualTo( new OpenTelemetryConfigurationModel() @@ -1175,4 +1237,89 @@ void read_WithEnvironmentVariables() { .withOtlpHttp( new OtlpHttpExporterModel()))))))); } + + @Test + void read_WithSystemProperties() { + String yaml = + "file_format: \"1.0-rc.1\"\n" + + "tracer_provider:\n" + + " processors:\n" + + " - batch:\n" + + " exporter:\n" + + " otlp_http:\n" + + " endpoint: ${sys:otel.exporter.otlp.endpoint}\n" + + " - batch:\n" + + " exporter:\n" + + " otlp_http:\n" + + " endpoint: ${sys:unset.sys.prop}\n"; + Map sysProps = new HashMap<>(); + sysProps.put("otel.exporter.otlp.endpoint", "http://collector:4318"); + OpenTelemetryConfigurationModel model = + DeclarativeConfiguration.parse( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)), + Collections.emptyMap(), + sysProps); + assertThat(model) + .isEqualTo( + new OpenTelemetryConfigurationModel() + .withFileFormat("1.0-rc.1") + .withTracerProvider( + new TracerProviderModel() + .withProcessors( + Arrays.asList( + new SpanProcessorModel() + .withBatch( + new BatchSpanProcessorModel() + .withExporter( + new SpanExporterModel() + .withOtlpHttp( + new OtlpHttpExporterModel() + .withEndpoint( + "http://collector:4318")))), + new SpanProcessorModel() + .withBatch( + new BatchSpanProcessorModel() + .withExporter( + new SpanExporterModel() + .withOtlpHttp( + new OtlpHttpExporterModel()))))))); + } + + @Test + void read_WithMixedEnvVarsAndSystemProperties() { + String yaml = + "file_format: \"1.0-rc.1\"\n" + + "resource:\n" + + " attributes:\n" + + " - name: service.name\n" + + " value: ${SERVICE_NAME}\n" + + " - name: service.version\n" + + " value: ${sys:app.version}\n" + + " - name: deployment.environment\n" + + " value: ${env:DEPLOYMENT_ENV:-production}\n"; + Map envVars = new HashMap<>(); + envVars.put("SERVICE_NAME", "my-service"); + Map sysProps = new HashMap<>(); + sysProps.put("app.version", "1.2.3"); + OpenTelemetryConfigurationModel model = + DeclarativeConfiguration.parse( + new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8)), envVars, sysProps); + assertThat(model) + .isEqualTo( + new OpenTelemetryConfigurationModel() + .withFileFormat("1.0-rc.1") + .withResource( + new ResourceModel() + .withAttributes( + Arrays.asList( + new AttributeNameValueModel() + .withName("service.name") + .withValue("my-service"), + new AttributeNameValueModel() + .withName("service.version") + .withValue("1.2.3"), + new AttributeNameValueModel() + .withName("deployment.environment") + .withValue("production"))))); + } } From cc12372df066a3b4f7466e3259cc8515a57bbf2f Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 13 Feb 2026 10:36:53 +0000 Subject: [PATCH 2/3] clean up class ref Co-authored-by: Jack Berg <34418638+jack-berg@users.noreply.github.com> --- .../incubator/fileconfig/DeclarativeConfigurationParseTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java index 72ed08545a4..5b7897416b5 100644 --- a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java +++ b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java @@ -1156,7 +1156,7 @@ void sysPropertySubstituteAndLoadYaml(String rawYaml, Object expectedYamlResult) } @SuppressWarnings("unchecked") - private static java.util.stream.Stream sysPropertySubstitutionArgs() { + private Stream sysPropertySubstitutionArgs() { return java.util.stream.Stream.of( // Simple cases with sys: prefix Arguments.of("key1: ${sys:str.1}\n", mapOf(entry("key1", "value1"))), From b352442b66f6f07cc87424975ebaa53807d1f387 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 13 Feb 2026 11:29:02 +0000 Subject: [PATCH 3/3] fix import after applying pr feedback --- .../fileconfig/DeclarativeConfigurationParseTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java index 5b7897416b5..6889f9b27a4 100644 --- a/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java +++ b/sdk-extensions/incubator/src/test/java/io/opentelemetry/sdk/extension/incubator/fileconfig/DeclarativeConfigurationParseTest.java @@ -111,6 +111,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.stream.Stream; import javax.annotation.Nullable; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -1156,7 +1157,7 @@ void sysPropertySubstituteAndLoadYaml(String rawYaml, Object expectedYamlResult) } @SuppressWarnings("unchecked") - private Stream sysPropertySubstitutionArgs() { + private static Stream sysPropertySubstitutionArgs() { return java.util.stream.Stream.of( // Simple cases with sys: prefix Arguments.of("key1: ${sys:str.1}\n", mapOf(entry("key1", "value1"))),