diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetter.java new file mode 100644 index 00000000000..deead027313 --- /dev/null +++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetter.java @@ -0,0 +1,91 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.propagation; + +import io.opentelemetry.context.propagation.TextMapGetter; +import java.util.Collections; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * A {@link TextMapGetter} that extracts context from a map carrier, intended for use with + * environment variables in child processes. + * + *

This is useful when a child process needs to extract propagated context from its environment. + * For example: + * + *

{@code
+ * Map env = System.getenv();
+ * Context context = contextPropagators.getTextMapPropagator()
+ *     .extract(Context.current(), env, EnvironmentGetter.getInstance());
+ * }
+ * + *

This getter automatically sanitizes keys to match environment variable naming conventions: + * + *

+ * + *

Values are validated to contain only characters valid in HTTP header fields per RFC 9110 (visible ASCII + * characters, space, and horizontal tab). Values containing invalid characters are treated as + * absent and {@code null} is returned. + * + * @see Environment + * Variable Format Restrictions + */ +public final class EnvironmentGetter implements TextMapGetter> { + + private static final Logger logger = Logger.getLogger(EnvironmentGetter.class.getName()); + private static final EnvironmentGetter INSTANCE = new EnvironmentGetter(); + + private EnvironmentGetter() {} + + /** Returns the singleton instance of {@link EnvironmentGetter}. */ + public static EnvironmentGetter getInstance() { + return INSTANCE; + } + + @Override + public Iterable keys(Map carrier) { + if (carrier == null) { + return Collections.emptyList(); + } + return carrier.keySet(); + } + + @Nullable + @Override + public String get(@Nullable Map carrier, String key) { + if (carrier == null || key == null) { + return null; + } + // Spec recommends using uppercase and underscores for environment variable + // names for maximum + // cross-platform compatibility. + String sanitizedKey = key.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); + String value = carrier.get(sanitizedKey); + if (value != null && !EnvironmentSetter.isValidHttpHeaderValue(value)) { + logger.log( + Level.FINE, + "Ignoring environment variable '{0}': " + + "value contains characters not valid in HTTP header fields per RFC 9110.", + sanitizedKey); + return null; + } + return value; + } + + @Override + public String toString() { + return "EnvironmentGetter"; + } +} diff --git a/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetter.java b/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetter.java new file mode 100644 index 00000000000..a4dd5974751 --- /dev/null +++ b/api/incubator/src/main/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetter.java @@ -0,0 +1,102 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.propagation; + +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +/** + * A {@link TextMapSetter} that injects context into a map carrier, intended for use with + * environment variables when spawning child processes. + * + *

This is useful when an application needs to propagate context to sub-processes via their + * environment. For example, when using {@link ProcessBuilder}: + * + *

{@code
+ * Map env = new HashMap<>();
+ * contextPropagators.getTextMapPropagator().inject(context, env, EnvironmentSetter.getInstance());
+ * ProcessBuilder processBuilder = new ProcessBuilder();
+ * processBuilder.environment().putAll(env);
+ * }
+ * + *

This setter automatically sanitizes keys to be compatible with environment variable naming + * conventions: + * + *

+ * + *

Values are validated to contain only characters valid in HTTP header fields per RFC 9110 (visible ASCII + * characters, space, and horizontal tab). Values containing invalid characters are silently + * skipped. + * + *

Size limitations: Environment variable sizes are platform-dependent (e.g., + * Windows limits name=value pairs to 32,767 characters). Callers are responsible for being aware of + * platform-specific limits when injecting context. + * + * @see Environment + * Variable Format Restrictions + */ +public final class EnvironmentSetter implements TextMapSetter> { + + private static final Logger logger = Logger.getLogger(EnvironmentSetter.class.getName()); + private static final EnvironmentSetter INSTANCE = new EnvironmentSetter(); + + private EnvironmentSetter() {} + + /** Returns the singleton instance of {@link EnvironmentSetter}. */ + public static EnvironmentSetter getInstance() { + return INSTANCE; + } + + @Override + public void set(@Nullable Map carrier, String key, String value) { + if (carrier == null || key == null || value == null) { + return; + } + if (!isValidHttpHeaderValue(value)) { + logger.log( + Level.FINE, + "Skipping environment variable injection for key ''{0}'': " + + "value contains characters not valid in HTTP header fields per RFC 9110.", + key); + return; + } + // Spec recommends using uppercase and underscores for environment variable + // names for maximum + // cross-platform compatibility. + String sanitizedKey = key.replace('.', '_').replace('-', '_').toUpperCase(Locale.ROOT); + carrier.put(sanitizedKey, value); + } + + /** + * Checks whether a string contains only characters valid in HTTP header field values per RFC 9110 Section 5.5. + * Valid characters are: visible ASCII (0x21-0x7E), space (0x20), and horizontal tab (0x09). + */ + static boolean isValidHttpHeaderValue(String value) { + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + // VCHAR (0x21-0x7E), SP (0x20), HTAB (0x09) + if (ch != '\t' && (ch < ' ' || ch > '~')) { + return false; + } + } + return true; + } + + @Override + public String toString() { + return "EnvironmentSetter"; + } +} diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetterTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetterTest.java new file mode 100644 index 00000000000..3f1895cdd10 --- /dev/null +++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentGetterTest.java @@ -0,0 +1,85 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.propagation; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class EnvironmentGetterTest { + + @Test + void get() { + Map carrier = new HashMap<>(); + carrier.put("TRACEPARENT", "val1"); + carrier.put("TRACESTATE", "val2"); + carrier.put("BAGGAGE", "val3"); + carrier.put("OTHER", "val4"); + + assertThat(EnvironmentGetter.getInstance().get(carrier, "traceparent")).isEqualTo("val1"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "TRACESTATE")).isEqualTo("val2"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "Baggage")).isEqualTo("val3"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "other")).isEqualTo("val4"); + } + + @Test + void get_sanitization() { + Map carrier = new HashMap<>(); + carrier.put("OTEL_TRACE_ID", "val1"); + carrier.put("OTEL_BAGGAGE_KEY", "val2"); + + assertThat(EnvironmentGetter.getInstance().get(carrier, "otel.trace.id")).isEqualTo("val1"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "otel-baggage-key")).isEqualTo("val2"); + } + + @Test + void get_null() { + assertThat(EnvironmentGetter.getInstance().get(null, "key")).isNull(); + assertThat(EnvironmentGetter.getInstance().get(Collections.emptyMap(), null)).isNull(); + } + + @Test + void keys() { + Map carrier = new HashMap<>(); + carrier.put("K1", "V1"); + carrier.put("K2", "V2"); + + assertThat(EnvironmentGetter.getInstance().keys(carrier)).containsExactlyInAnyOrder("K1", "K2"); + assertThat(EnvironmentGetter.getInstance().keys(null)).isEmpty(); + } + + @Test + void get_validHeaderValues() { + Map carrier = new HashMap<>(); + carrier.put("KEY1", "simple-value"); + carrier.put("KEY2", "value with spaces"); + carrier.put("KEY3", "value\twith\ttabs"); + + assertThat(EnvironmentGetter.getInstance().get(carrier, "key1")).isEqualTo("simple-value"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "key2")).isEqualTo("value with spaces"); + assertThat(EnvironmentGetter.getInstance().get(carrier, "key3")).isEqualTo("value\twith\ttabs"); + } + + @Test + void get_invalidHeaderValues() { + Map carrier = new HashMap<>(); + carrier.put("KEY1", "value\u0000with\u0001control"); + carrier.put("KEY2", "value\nwith\nnewlines"); + carrier.put("KEY3", "value\u0080non-ascii"); + + assertThat(EnvironmentGetter.getInstance().get(carrier, "key1")).isNull(); + assertThat(EnvironmentGetter.getInstance().get(carrier, "key2")).isNull(); + assertThat(EnvironmentGetter.getInstance().get(carrier, "key3")).isNull(); + } + + @Test + void testToString() { + assertThat(EnvironmentGetter.getInstance().toString()).isEqualTo("EnvironmentGetter"); + } +} diff --git a/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetterTest.java b/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetterTest.java new file mode 100644 index 00000000000..de166401af6 --- /dev/null +++ b/api/incubator/src/test/java/io/opentelemetry/api/incubator/propagation/EnvironmentSetterTest.java @@ -0,0 +1,76 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.api.incubator.propagation; + +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; + +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class EnvironmentSetterTest { + + @Test + void set() { + Map carrier = new HashMap<>(); + EnvironmentSetter.getInstance().set(carrier, "traceparent", "val1"); + EnvironmentSetter.getInstance().set(carrier, "TRACESTATE", "val2"); + EnvironmentSetter.getInstance().set(carrier, "Baggage", "val3"); + + assertThat(carrier).containsEntry("TRACEPARENT", "val1"); + assertThat(carrier).containsEntry("TRACESTATE", "val2"); + assertThat(carrier).containsEntry("BAGGAGE", "val3"); + } + + @Test + void set_sanitization() { + Map carrier = new HashMap<>(); + EnvironmentSetter.getInstance().set(carrier, "otel.trace.id", "val1"); + EnvironmentSetter.getInstance().set(carrier, "otel-baggage-key", "val2"); + + assertThat(carrier).containsEntry("OTEL_TRACE_ID", "val1"); + assertThat(carrier).containsEntry("OTEL_BAGGAGE_KEY", "val2"); + } + + @Test + void set_null() { + Map carrier = new HashMap<>(); + EnvironmentSetter.getInstance().set(null, "key", "val"); + EnvironmentSetter.getInstance().set(carrier, null, "val"); + EnvironmentSetter.getInstance().set(carrier, "key", null); + assertThat(carrier).isEmpty(); + } + + @Test + void set_validHeaderValues() { + Map carrier = new HashMap<>(); + // Printable ASCII and tab are valid per RFC 9110 + EnvironmentSetter.getInstance().set(carrier, "key1", "simple-value"); + EnvironmentSetter.getInstance().set(carrier, "key2", "value with spaces"); + EnvironmentSetter.getInstance().set(carrier, "key3", "value\twith\ttabs"); + + assertThat(carrier).containsEntry("KEY1", "simple-value"); + assertThat(carrier).containsEntry("KEY2", "value with spaces"); + assertThat(carrier).containsEntry("KEY3", "value\twith\ttabs"); + } + + @Test + void set_invalidHeaderValues() { + Map carrier = new HashMap<>(); + // Control characters and non-ASCII are invalid per RFC 9110 + EnvironmentSetter.getInstance().set(carrier, "key1", "value\u0000with\u0001control"); + EnvironmentSetter.getInstance().set(carrier, "key2", "value\nwith\nnewlines"); + EnvironmentSetter.getInstance().set(carrier, "key3", "value\rwith\rcarriage"); + EnvironmentSetter.getInstance().set(carrier, "key4", "value\u0080non-ascii"); + + assertThat(carrier).isEmpty(); + } + + @Test + void testToString() { + assertThat(EnvironmentSetter.getInstance().toString()).isEqualTo("EnvironmentSetter"); + } +}