From 94bf122d12289c25f9623af231ed5f3636f180b1 Mon Sep 17 00:00:00 2001 From: Robert Pajak Date: Thu, 11 Jun 2026 11:55:32 +0200 Subject: [PATCH 1/3] opentelemetry-api: update EnvironmentGetter to ignore non-normalized environment variable names --- .../opentelemetry/propagators/_envcarrier.py | 25 +++++-- .../tests/propagators/test__envcarrier.py | 68 ++++++++++++++++++- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py index 6b651488c06..822a130c587 100644 --- a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py +++ b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py @@ -16,11 +16,23 @@ def _normalize_key(key: str) -> str: return result +def _is_normalized_key(key: str) -> bool: + if not key: + return False + if "0" <= key[0] <= "9": + return False + return all( + "A" <= char <= "Z" or "0" <= char <= "9" or char == "_" + for char in key + ) + + class EnvironmentGetter(Getter[typing.Mapping[str, str]]): """Getter implementation for extracting context and baggage from environment variables. - EnvironmentGetter creates a normalized lookup from the current environment - variables at initialization time and provides simple data access without validation. + EnvironmentGetter creates a lookup from the current environment variables + whose names are already normalized at initialization time and provides + simple data access without validation. Per the OpenTelemetry specification, environment variables are treated as immutable within a process. For environments where context-carrying environment variables @@ -33,10 +45,9 @@ class EnvironmentGetter(Getter[typing.Mapping[str, str]]): """ def __init__(self): - # Create a normalized lookup from current environment - # Per spec: "creates an in-memory copy of the current environment variables" + # Per spec, Get reads only normalized environment variable names. self.carrier: dict[str, str] = { - _normalize_key(k): v for k, v in os.environ.items() + k: v for k, v in os.environ.items() if _is_normalized_key(k) } def get( @@ -46,7 +57,7 @@ def get( Args: carrier: Not used; maintained for interface compatibility with Getter[CarrierT] - key: The key to look up (case-insensitive) + key: The key to look up (will be normalized) Returns: A list with a single string value if the key exists, None otherwise. @@ -63,7 +74,7 @@ def keys(self, carrier: typing.Mapping[str, str]) -> list[str]: carrier: Not used; maintained for interface compatibility with Getter[CarrierT] Returns: - List of all environment variable keys (normalized). + List of all already-normalized environment variable keys. """ return list(self.carrier.keys()) diff --git a/opentelemetry-api/tests/propagators/test__envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py index e3e36f46230..81b40c68cd7 100644 --- a/opentelemetry-api/tests/propagators/test__envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -14,6 +14,7 @@ from opentelemetry.propagators._envcarrier import ( EnvironmentGetter, EnvironmentSetter, + _is_normalized_key, _normalize_key, ) from opentelemetry.trace.propagation.tracecontext import ( @@ -43,6 +44,24 @@ def test_empty_string(self): self.assertEqual(_normalize_key(""), "") +class TestIsNormalizedKey(unittest.TestCase): + """Unit tests for _is_normalized_key.""" + + def test_normalized_keys(self): + self.assertTrue(_is_normalized_key("TRACEPARENT")) + self.assertTrue(_is_normalized_key("X_B3_TRACEID")) + self.assertTrue(_is_normalized_key("H_LLO")) + self.assertTrue(_is_normalized_key("_1ABC")) + + def test_non_normalized_keys(self): + self.assertFalse(_is_normalized_key("")) + self.assertFalse(_is_normalized_key("traceparent")) + self.assertFalse(_is_normalized_key("TraceParent")) + self.assertFalse(_is_normalized_key("X-B3-TRACEID")) + self.assertFalse(_is_normalized_key("1ABC")) + self.assertFalse(_is_normalized_key("héllo")) + + class TestEnvironmentGetter(unittest.TestCase): """Unit tests for EnvironmentGetter.""" @@ -84,15 +103,47 @@ def test_get_with_special_characters(self): result = getter.get({}, "test_key") self.assertEqual(result, ["value with spaces and !@#$%"]) + def test_get_ignores_non_normalized_env_var_name(self): + """Test that non-normalized environment variable names are ignored.""" + with patch.dict( + os.environ, {"X-B3-TRACEID": "ignored"}, clear=True + ): + getter = EnvironmentGetter() + self.assertIsNone(getter.get({}, "x-b3-traceid")) + self.assertIsNone(getter.get({}, "X_B3_TRACEID")) + + def test_get_prefers_normalized_env_var_name(self): + """Test deterministic lookup when normalized names collide.""" + with patch.dict( + os.environ, + {"X_B3_TRACEID": "expected", "X-B3-TRACEID": "ignored"}, + clear=True, + ): + getter = EnvironmentGetter() + self.assertEqual(getter.get({}, "x-b3-traceid"), ["expected"]) + def test_keys(self): """Test getting all environment variable keys.""" - test_env = {"KEY1": "value1", "KEY2": "value2", "key3": "value3"} + test_env = {"KEY1": "value1", "KEY2": "value2", "KEY3": "value3"} with patch.dict(os.environ, test_env, clear=True): getter = EnvironmentGetter() keys = getter.keys({}) expected_keys = {"KEY1", "KEY2", "KEY3"} self.assertEqual(set(keys), expected_keys) + def test_keys_ignores_non_normalized_env_var_names(self): + """Test that keys returns only already-normalized names.""" + test_env = { + "KEY1": "value1", + "X-B3-TRACEID": "ignored", + "1START": "ignored", + "_1START": "value2", + } + with patch.dict(os.environ, test_env, clear=True): + getter = EnvironmentGetter() + keys = getter.keys({}) + self.assertEqual(set(keys), {"KEY1", "_1START"}) + def test_keys_empty_environment(self): """Test getting keys when environment is empty.""" with patch.dict(os.environ, {}, clear=True): @@ -249,6 +300,21 @@ def test_extract_with_tracestate(self): self.assertEqual(span_context.trace_state.get("vendor1"), "value1") self.assertEqual(span_context.trace_state.get("vendor2"), "value2") + @unittest.skipIf( + os.name == "nt", + "Windows environment variable names are case-insensitive", + ) + def test_extract_ignores_lowercase_trace_context_names(self): + """Test extraction ignores non-normalized trace context env names.""" + traceparent = f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01" + + ctx = self._extract_with_env( + {"traceparent": traceparent, "tracestate": "vendor=value"} + ) + + span_context = trace.get_current_span(ctx).get_span_context() + self.assertFalse(span_context.is_valid) + def test_extract_invalid_traceparent(self): """Test that invalid traceparent formats are handled gracefully. From 3ecfe4b202214110febce36629a071749c2b962e Mon Sep 17 00:00:00 2001 From: Robert Pajak Date: Thu, 11 Jun 2026 11:56:43 +0200 Subject: [PATCH 2/3] add chlog entry --- .changelog/5289.changed | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changelog/5289.changed diff --git a/.changelog/5289.changed b/.changelog/5289.changed new file mode 100644 index 00000000000..873e4a848a0 --- /dev/null +++ b/.changelog/5289.changed @@ -0,0 +1 @@ +`opentelemetry-api`: update `EnvironmentGetter` to ignore non-normalized environment variable names From eac6823cdc861800c1fe6e998b847813a8030ed9 Mon Sep 17 00:00:00 2001 From: Robert Pajak Date: Thu, 11 Jun 2026 12:04:42 +0200 Subject: [PATCH 3/3] fmt --- .../src/opentelemetry/propagators/_envcarrier.py | 3 +-- opentelemetry-api/tests/propagators/test__envcarrier.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py index 822a130c587..3be7273236b 100644 --- a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py +++ b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py @@ -22,8 +22,7 @@ def _is_normalized_key(key: str) -> bool: if "0" <= key[0] <= "9": return False return all( - "A" <= char <= "Z" or "0" <= char <= "9" or char == "_" - for char in key + "A" <= char <= "Z" or "0" <= char <= "9" or char == "_" for char in key ) diff --git a/opentelemetry-api/tests/propagators/test__envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py index 81b40c68cd7..63aac7e9585 100644 --- a/opentelemetry-api/tests/propagators/test__envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -105,9 +105,7 @@ def test_get_with_special_characters(self): def test_get_ignores_non_normalized_env_var_name(self): """Test that non-normalized environment variable names are ignored.""" - with patch.dict( - os.environ, {"X-B3-TRACEID": "ignored"}, clear=True - ): + with patch.dict(os.environ, {"X-B3-TRACEID": "ignored"}, clear=True): getter = EnvironmentGetter() self.assertIsNone(getter.get({}, "x-b3-traceid")) self.assertIsNone(getter.get({}, "X_B3_TRACEID"))