From 04ed3535126cc08f13f904b06da5df339cdf034f Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Mon, 2 Jun 2025 19:10:06 -0400 Subject: [PATCH 1/9] [carriers] add env carrier --- .../opentelemetry/propagators/envcarrier.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 opentelemetry-api/src/opentelemetry/propagators/envcarrier.py diff --git a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py new file mode 100644 index 00000000000..87456d0b087 --- /dev/null +++ b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py @@ -0,0 +1,60 @@ +import os +import typing +from opentelemetry.propagators.textmap import Getter, Setter + +class EnvironmentGetter(Getter[dict]): + """This class decorates Getter to enable extracting from context and baggage + from environment variables. + """ + + KEY_MAPPING = { + "TRACEPARENT": "traceparent", + "TRACESTATE": "tracestate", + "BAGGAGE": "baggage" + } + + def __init__(self): + self.env_copy = dict(os.environ) + self.carrier = {} + + for env_key, env_value in self.env_copy.items(): + if env_key in self.KEY_MAPPING: + self.carrier[self.KEY_MAPPING[env_key]] = env_value + else: + self.carrier[env_key] = env_value + + def get(self, carrier: dict, key: str) -> typing.Optional[typing.List[str]]: + """Get a value from the carrier for the given key""" + val = self.carrier.get(key, None) + if val is None: + return None + if isinstance(val, typing.Iterable) and not isinstance(val, str): + return list(val) + return [val] + + def keys(self, carrier: dict) -> typing.List[str]: + """Get all keys from the carrier""" + return list(self.carrier.keys()) + +class EnvironmentSetter(Setter[dict]): + """This class decorates Setter to enable setting context and baggage + to environment variables. + """ + + KEY_MAPPING = { + "TRACEPARENT": "traceparent", + "TRACESTATE": "tracestate", + "BAGGAGE": "baggage" + } + + def set(self, carrier: typing.Optional[dict], key: str, value: str) -> None: + """Set a value in the environment for the given key. + + Args: + carrier: Not used for environment setter, but kept for interface compatibility + key: The key to set + value: The value to set + """ + env_key = self.KEY_MAPPING.get(key, key.upper()) + + os.environ[env_key] = value From 5b7f35fd78b59d7405c135ec7bfd455a5e2b7d26 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Sun, 31 Aug 2025 13:01:09 -0400 Subject: [PATCH 2/9] [chore] updates --- .../opentelemetry/propagators/envcarrier.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py index 87456d0b087..4236e71ee88 100644 --- a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py +++ b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py @@ -7,21 +7,12 @@ class EnvironmentGetter(Getter[dict]): from environment variables. """ - KEY_MAPPING = { - "TRACEPARENT": "traceparent", - "TRACESTATE": "tracestate", - "BAGGAGE": "baggage" - } - def __init__(self): self.env_copy = dict(os.environ) self.carrier = {} for env_key, env_value in self.env_copy.items(): - if env_key in self.KEY_MAPPING: - self.carrier[self.KEY_MAPPING[env_key]] = env_value - else: - self.carrier[env_key] = env_value + self.carrier[env_key.lower()] = env_value def get(self, carrier: dict, key: str) -> typing.Optional[typing.List[str]]: """Get a value from the carrier for the given key""" @@ -40,12 +31,6 @@ class EnvironmentSetter(Setter[dict]): """This class decorates Setter to enable setting context and baggage to environment variables. """ - - KEY_MAPPING = { - "TRACEPARENT": "traceparent", - "TRACESTATE": "tracestate", - "BAGGAGE": "baggage" - } def set(self, carrier: typing.Optional[dict], key: str, value: str) -> None: """Set a value in the environment for the given key. @@ -55,6 +40,6 @@ def set(self, carrier: typing.Optional[dict], key: str, value: str) -> None: key: The key to set value: The value to set """ - env_key = self.KEY_MAPPING.get(key, key.upper()) + env_key = key.upper() os.environ[env_key] = value From 78747140e1d2ffa1a4a46f78accca90a4d72bfc7 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Sun, 31 Aug 2025 21:45:58 -0400 Subject: [PATCH 3/9] [chore] updates to the envcarrier with tests --- .../opentelemetry/propagators/envcarrier.py | 95 +++-- .../tests/propagators/test_envcarrier.py | 380 ++++++++++++++++++ 2 files changed, 451 insertions(+), 24 deletions(-) create mode 100644 opentelemetry-api/tests/propagators/test_envcarrier.py diff --git a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py index 4236e71ee88..90823b9d1a3 100644 --- a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py +++ b/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py @@ -1,45 +1,92 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os import typing + from opentelemetry.propagators.textmap import Getter, Setter + class EnvironmentGetter(Getter[dict]): - """This class decorates Getter to enable extracting from context and baggage - from environment variables. + """Getter implementation for extracting context and baggage from environment variables. + + EnvironmentGetter creates a case-insensitive lookup from the current environment + variables and provides simple data access without validation. + + Example usage: + getter = EnvironmentGetter() + traceparent = getter.get({}, "traceparent") """ - + def __init__(self): - self.env_copy = dict(os.environ) - self.carrier = {} - - for env_key, env_value in self.env_copy.items(): - self.carrier[env_key.lower()] = env_value - - def get(self, carrier: dict, key: str) -> typing.Optional[typing.List[str]]: - """Get a value from the carrier for the given key""" - val = self.carrier.get(key, None) + # Create case-insensitive lookup from current environment + self.carrier = {k.lower(): v for k, v in os.environ.items()} + + def get( + self, carrier: dict, key: str + ) -> typing.Optional[typing.List[str]]: + """Get a value from the environment for the given key. + + Args: + carrier: Not used for environment getter, maintained for interface compatibility + key: The key to look up (case-insensitive) + + Returns: + A list with a single string value if the key exists, None otherwise. + """ + val = self.carrier.get(key.lower()) if val is None: return None if isinstance(val, typing.Iterable) and not isinstance(val, str): return list(val) return [val] - + def keys(self, carrier: dict) -> typing.List[str]: - """Get all keys from the carrier""" + """Get all keys from the environment carrier. + + Args: + carrier: Not used for environment getter, maintained for interface compatibility + + Returns: + List of all environment variable keys (lowercase). + """ return list(self.carrier.keys()) + class EnvironmentSetter(Setter[dict]): - """This class decorates Setter to enable setting context and baggage - to environment variables. + """Setter implementation for building environment variable dictionaries. + + EnvironmentSetter builds a dictionary of environment variables that + can be passed to utilities like subprocess.run() + + Example usage: + setter = EnvironmentSetter() + env_vars = {} + setter.set(env_vars, "traceparent", "00-trace-id-span-id-01") + subprocess.run(myCommand, env=env_vars) """ - def set(self, carrier: typing.Optional[dict], key: str, value: str) -> None: - """Set a value in the environment for the given key. - + def set( + self, carrier: typing.Optional[dict], key: str, value: str + ) -> None: + """Set a value in the carrier dictionary for the given key. + Args: - carrier: Not used for environment setter, but kept for interface compatibility - key: The key to set + carrier: Dictionary to store environment variables, created if None + key: The key to set (will be converted to uppercase) value: The value to set """ - env_key = key.upper() - - os.environ[env_key] = value + if carrier is None: + carrier = {} + carrier[key.upper()] = value diff --git a/opentelemetry-api/tests/propagators/test_envcarrier.py b/opentelemetry-api/tests/propagators/test_envcarrier.py new file mode 100644 index 00000000000..6889f264b0f --- /dev/null +++ b/opentelemetry-api/tests/propagators/test_envcarrier.py @@ -0,0 +1,380 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import unittest +from unittest.mock import patch + +from opentelemetry.propagators.envcarrier import ( + EnvironmentGetter, + EnvironmentSetter, +) + + +class TestEnvironmentGetter(unittest.TestCase): + def test_get_existing_env_var(self): + """Test retrieving an existing environment variable.""" + with patch.dict(os.environ, {"TEST_KEY": "test_value"}): + getter = EnvironmentGetter() + result = getter.get({}, "test_key") + self.assertEqual(result, ["test_value"]) + + def test_get_existing_env_var_case_insensitive(self): + """Test case insensitive lookup for environment variables.""" + with patch.dict(os.environ, {"TEST_KEY": "test_value"}): + getter = EnvironmentGetter() + # Test various case combinations + self.assertEqual(getter.get({}, "test_key"), ["test_value"]) + self.assertEqual(getter.get({}, "TEST_KEY"), ["test_value"]) + self.assertEqual(getter.get({}, "Test_Key"), ["test_value"]) + + def test_get_nonexistent_env_var(self): + """Test retrieving a non-existent environment variable.""" + with patch.dict(os.environ, {}, clear=True): + getter = EnvironmentGetter() + result = getter.get({}, "nonexistent_key") + self.assertIsNone(result) + + def test_get_empty_env_var(self): + """Test retrieving an environment variable with empty value.""" + with patch.dict(os.environ, {"EMPTY_KEY": ""}): + getter = EnvironmentGetter() + result = getter.get({}, "empty_key") + self.assertEqual(result, [""]) + + def test_get_with_special_characters(self): + """Test environment variables with special characters.""" + with patch.dict( + os.environ, {"TEST_KEY": "value with spaces and !@#$%"} + ): + getter = EnvironmentGetter() + result = getter.get({}, "test_key") + self.assertEqual(result, ["value with spaces and !@#$%"]) + + def test_keys(self): + """Test getting all environment variable keys.""" + test_env = {"KEY1": "value1", "KEY2": "value2", "key3": "value3"} + with patch.dict(os.environ, test_env, clear=True): + getter = EnvironmentGetter() + keys = getter.keys({}) + + # Keys should be lowercase + expected_keys = {"key1", "key2", "key3"} + self.assertEqual(set(keys), expected_keys) + + def test_keys_empty_environment(self): + """Test getting keys when environment is empty.""" + with patch.dict(os.environ, {}, clear=True): + getter = EnvironmentGetter() + keys = getter.keys({}) + self.assertEqual(keys, []) + + def test_carrier_parameter_ignored(self): + """Test that the carrier parameter is ignored (maintained for interface compatibility).""" + with patch.dict(os.environ, {"TEST_KEY": "test_value"}): + getter = EnvironmentGetter() + # Carrier parameter should be ignored + result1 = getter.get({}, "test_key") + result2 = getter.get({"test_key": "different_value"}, "test_key") + result3 = getter.get(None, "test_key") + + # All should return the same value from environment + self.assertEqual(result1, ["test_value"]) + self.assertEqual(result2, ["test_value"]) + self.assertEqual(result3, ["test_value"]) + + def test_snapshot_behavior(self): + """Test that getter takes a snapshot of environment at initialization.""" + # Start with empty environment + with patch.dict(os.environ, {}, clear=True): + getter = EnvironmentGetter() + # Should be empty initially + self.assertIsNone(getter.get({}, "test_key")) + + # Add environment variable after initialization + os.environ["TEST_KEY"] = "new_value" + + # Getter should still not see the new value (snapshot behavior) + self.assertIsNone(getter.get({}, "test_key")) + + +class TestEnvironmentSetter(unittest.TestCase): + def test_set_with_new_carrier(self): + """Test setting a value with a new carrier dictionary.""" + setter = EnvironmentSetter() + carrier = {} + setter.set(carrier, "test_key", "test_value") + + self.assertEqual(carrier, {"TEST_KEY": "test_value"}) + + def test_set_with_none_carrier(self): + """Test setting a value when carrier is None.""" + setter = EnvironmentSetter() + carrier = None + setter.set(carrier, "test_key", "test_value") + + # Note: carrier would still be None since Python passes by reference + # but the method should handle None gracefully + # This is a limitation of the current interface design + + def test_set_multiple_values(self): + """Test setting multiple values in the same carrier.""" + setter = EnvironmentSetter() + carrier = {} + + setter.set(carrier, "key1", "value1") + setter.set(carrier, "key2", "value2") + setter.set(carrier, "key3", "value3") + + expected = {"KEY1": "value1", "KEY2": "value2", "KEY3": "value3"} + self.assertEqual(carrier, expected) + + def test_set_overwrites_existing_key(self): + """Test that setting a key overwrites existing value.""" + setter = EnvironmentSetter() + carrier = {"TEST_KEY": "old_value"} + + setter.set(carrier, "test_key", "new_value") + + self.assertEqual(carrier, {"TEST_KEY": "new_value"}) + + def test_set_case_normalization(self): + """Test that keys are normalized to uppercase.""" + setter = EnvironmentSetter() + carrier = {} + + # Test various case inputs + setter.set(carrier, "lowercase_key", "value1") + setter.set(carrier, "UPPERCASE_KEY", "value2") + setter.set(carrier, "MiXeD_cAsE_kEy", "value3") + + expected = { + "LOWERCASE_KEY": "value1", + "UPPERCASE_KEY": "value2", + "MIXED_CASE_KEY": "value3", + } + self.assertEqual(carrier, expected) + + def test_set_with_special_characters(self): + """Test setting values with special characters.""" + setter = EnvironmentSetter() + carrier = {} + + setter.set(carrier, "test_key", "value with spaces and !@#$%^&*()") + + self.assertEqual( + carrier, {"TEST_KEY": "value with spaces and !@#$%^&*()"} + ) + + def test_set_empty_value(self): + """Test setting an empty value.""" + setter = EnvironmentSetter() + carrier = {} + + setter.set(carrier, "empty_key", "") + + self.assertEqual(carrier, {"EMPTY_KEY": ""}) + + def test_does_not_modify_os_environ(self): + """Test that setting values does not modify os.environ.""" + setter = EnvironmentSetter() + carrier = {} + + original_environ = dict(os.environ) + setter.set(carrier, "test_key", "test_value") + + # os.environ should be unchanged + self.assertEqual(dict(os.environ), original_environ) + # But carrier should have the value + self.assertEqual(carrier, {"TEST_KEY": "test_value"}) + + +class TestEnvironmentCarrierIntegration(unittest.TestCase): + """Integration tests for EnvironmentGetter and EnvironmentSetter.""" + + def test_roundtrip_simple(self): + """Test basic roundtrip: set with setter, get with getter.""" + # Set up environment + test_env = { + "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01" + } + + with patch.dict(os.environ, test_env, clear=True): + getter = EnvironmentGetter() + setter = EnvironmentSetter() + + # Get from environment + value = getter.get({}, "traceparent") + self.assertEqual( + value, + ["00-12345678901234567890123456789012-1234567890123456-01"], + ) + + # Set to new carrier + new_carrier = {} + setter.set(new_carrier, "traceparent", value[0]) + self.assertEqual( + new_carrier, + { + "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01" + }, + ) + + def test_w3c_headers_case_handling(self): + """Test proper case handling for W3C standard headers.""" + test_env = { + "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", + "TRACESTATE": "vendor=value", + "BAGGAGE": "key=value", + } + + with patch.dict(os.environ, test_env, clear=True): + getter = EnvironmentGetter() + + # Should be able to retrieve using lowercase (standard HTTP header format) + self.assertEqual( + getter.get({}, "traceparent"), + ["00-12345678901234567890123456789012-1234567890123456-01"], + ) + self.assertEqual(getter.get({}, "tracestate"), ["vendor=value"]) + self.assertEqual(getter.get({}, "baggage"), ["key=value"]) + + def test_empty_environment(self): + """Test behavior with completely empty environment.""" + with patch.dict(os.environ, {}, clear=True): + getter = EnvironmentGetter() + setter = EnvironmentSetter() + + # Getting should return None + self.assertIsNone(getter.get({}, "any_key")) + self.assertEqual(getter.keys({}), []) + + # Setting should work normally + carrier = {} + setter.set(carrier, "new_key", "new_value") + self.assertEqual(carrier, {"NEW_KEY": "new_value"}) + + +class TestEnvironmentCarrierWithPropagators(unittest.TestCase): + """Integration tests demonstrating environment carrier usage with propagators. + + Note: These tests demonstrate usage patterns but don't require actual + propagator imports since validation is handled at the propagator level. + """ + + def test_w3c_traceparent_pattern(self): + """Test environment carrier with W3C TraceContext header format.""" + # Simulate W3C TraceContext format + traceparent = "00-12345678901234567890123456789012-1234567890123456-01" + + with patch.dict(os.environ, {"TRACEPARENT": traceparent}): + getter = EnvironmentGetter() + + # Propagator would use lowercase key for lookup + result = getter.get({}, "traceparent") + self.assertEqual(result, [traceparent]) + + # Setter would prepare environment for process spawning + setter = EnvironmentSetter() + carrier = {} + setter.set(carrier, "traceparent", traceparent) + self.assertEqual(carrier, {"TRACEPARENT": traceparent}) + + def test_w3c_baggage_pattern(self): + """Test environment carrier with W3C Baggage header format.""" + baggage = "key1=value1,key2=value2" + + with patch.dict(os.environ, {"BAGGAGE": baggage}): + getter = EnvironmentGetter() + result = getter.get({}, "baggage") + self.assertEqual(result, [baggage]) + + setter = EnvironmentSetter() + carrier = {} + setter.set(carrier, "baggage", baggage) + self.assertEqual(carrier, {"BAGGAGE": baggage}) + + def test_multiple_headers_integration(self): + """Test environment carrier with multiple W3C headers.""" + test_env = { + "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", + "TRACESTATE": "vendor=value", + "BAGGAGE": "key=value", + } + + with patch.dict(os.environ, test_env, clear=True): + getter = EnvironmentGetter() + setter = EnvironmentSetter() + + # Simulate extraction process + extracted_data = {} + for key in ["traceparent", "tracestate", "baggage"]: + value = getter.get({}, key) + if value is not None: + extracted_data[key] = value[0] + + expected = { + "traceparent": "00-12345678901234567890123456789012-1234567890123456-01", + "tracestate": "vendor=value", + "baggage": "key=value", + } + self.assertEqual(extracted_data, expected) + + # Simulate injection process + carrier = {} + for key, value in extracted_data.items(): + setter.set(carrier, key, value) + + expected_carrier = { + "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", + "TRACESTATE": "vendor=value", + "BAGGAGE": "key=value", + } + self.assertEqual(carrier, expected_carrier) + + def test_carrier_interface_compliance(self): + """Test that environment carriers comply with the TextMap interfaces.""" + getter = EnvironmentGetter() + setter = EnvironmentSetter() + + # Test getter interface compliance + self.assertTrue(hasattr(getter, "get")) + self.assertTrue(hasattr(getter, "keys")) + self.assertTrue(callable(getter.get)) + self.assertTrue(callable(getter.keys)) + + # Test setter interface compliance + self.assertTrue(hasattr(setter, "set")) + self.assertTrue(callable(setter.set)) + + # Test method signatures work as expected + with patch.dict(os.environ, {"TEST": "value"}): + getter = EnvironmentGetter() + + # get() should accept carrier and key parameters + result = getter.get({}, "test") + self.assertEqual(result, ["value"]) + + # keys() should accept carrier parameter + keys = getter.keys({}) + self.assertIn("test", keys) + + # set() should accept carrier, key, and value parameters + carrier = {} + setter.set(carrier, "key", "value") + self.assertEqual(carrier, {"KEY": "value"}) + + +if __name__ == "__main__": + unittest.main() From a35fb8bbafbfa249995cfc3b699958309f5660de Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Sun, 31 Aug 2025 21:50:53 -0400 Subject: [PATCH 4/9] [chore] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c46cf395c8..9c431c59e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4634](https://github.com/open-telemetry/opentelemetry-python/pull/4634)) - semantic-conventions: Bump to 1.37.0 ([#4731](https://github.com/open-telemetry/opentelemetry-python/pull/4731)) +- Add environment variable carriers to API + ([#4609](https://github.com/open-telemetry/opentelemetry-python/pull/4609)) ## Version 1.36.0/0.57b0 (2025-07-29) From d201419af35dd7818d91c251ad15f4c26883b470 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Tue, 13 Jan 2026 09:01:55 -0500 Subject: [PATCH 5/9] [chore] set env carrier to private due to instability --- .../opentelemetry/propagators/{envcarrier.py => _envcarrier.py} | 0 .../propagators/{test_envcarrier.py => test__envcarrier.py} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename opentelemetry-api/src/opentelemetry/propagators/{envcarrier.py => _envcarrier.py} (100%) rename opentelemetry-api/tests/propagators/{test_envcarrier.py => test__envcarrier.py} (99%) diff --git a/opentelemetry-api/src/opentelemetry/propagators/envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py similarity index 100% rename from opentelemetry-api/src/opentelemetry/propagators/envcarrier.py rename to opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py diff --git a/opentelemetry-api/tests/propagators/test_envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py similarity index 99% rename from opentelemetry-api/tests/propagators/test_envcarrier.py rename to opentelemetry-api/tests/propagators/test__envcarrier.py index 6889f264b0f..022c84abf0c 100644 --- a/opentelemetry-api/tests/propagators/test_envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -16,7 +16,7 @@ import unittest from unittest.mock import patch -from opentelemetry.propagators.envcarrier import ( +from opentelemetry.propagators._envcarrier import ( EnvironmentGetter, EnvironmentSetter, ) From 9502153e3c724f57922d2daa1aed857f5fbedbe1 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Tue, 13 Jan 2026 13:36:34 -0500 Subject: [PATCH 6/9] chore: update tests --- .../tests/propagators/test__envcarrier.py | 547 ++++++++++++------ 1 file changed, 360 insertions(+), 187 deletions(-) diff --git a/opentelemetry-api/tests/propagators/test__envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py index 022c84abf0c..de617418d55 100644 --- a/opentelemetry-api/tests/propagators/test__envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -12,17 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. +# type: ignore + import os import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch +from opentelemetry import trace +from opentelemetry.baggage import get_all, set_baggage +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.context import Context, get_current from opentelemetry.propagators._envcarrier import ( EnvironmentGetter, EnvironmentSetter, ) +from opentelemetry.trace.propagation.tracecontext import ( + TraceContextTextMapPropagator, +) class TestEnvironmentGetter(unittest.TestCase): + """Unit tests for EnvironmentGetter.""" + def test_get_existing_env_var(self): """Test retrieving an existing environment variable.""" with patch.dict(os.environ, {"TEST_KEY": "test_value"}): @@ -30,11 +41,10 @@ def test_get_existing_env_var(self): result = getter.get({}, "test_key") self.assertEqual(result, ["test_value"]) - def test_get_existing_env_var_case_insensitive(self): + def test_get_case_insensitive(self): """Test case insensitive lookup for environment variables.""" with patch.dict(os.environ, {"TEST_KEY": "test_value"}): getter = EnvironmentGetter() - # Test various case combinations self.assertEqual(getter.get({}, "test_key"), ["test_value"]) self.assertEqual(getter.get({}, "TEST_KEY"), ["test_value"]) self.assertEqual(getter.get({}, "Test_Key"), ["test_value"]) @@ -46,7 +56,7 @@ def test_get_nonexistent_env_var(self): result = getter.get({}, "nonexistent_key") self.assertIsNone(result) - def test_get_empty_env_var(self): + def test_get_empty_value(self): """Test retrieving an environment variable with empty value.""" with patch.dict(os.environ, {"EMPTY_KEY": ""}): getter = EnvironmentGetter() @@ -68,8 +78,6 @@ def test_keys(self): with patch.dict(os.environ, test_env, clear=True): getter = EnvironmentGetter() keys = getter.keys({}) - - # Keys should be lowercase expected_keys = {"key1", "key2", "key3"} self.assertEqual(set(keys), expected_keys) @@ -80,85 +88,68 @@ def test_keys_empty_environment(self): keys = getter.keys({}) self.assertEqual(keys, []) - def test_carrier_parameter_ignored(self): - """Test that the carrier parameter is ignored (maintained for interface compatibility).""" + def test_uses_snapshot_not_carrier_parameter(self): + """Test that getter uses internal snapshot, not carrier parameter. + + The carrier parameter exists for interface compatibility with + Getter[CarrierT], but EnvironmentGetter reads from os.environ at + initialization, creating an immutable snapshot. + """ with patch.dict(os.environ, {"TEST_KEY": "test_value"}): getter = EnvironmentGetter() - # Carrier parameter should be ignored + # Both return same value from snapshot, carrier is ignored result1 = getter.get({}, "test_key") - result2 = getter.get({"test_key": "different_value"}, "test_key") - result3 = getter.get(None, "test_key") - - # All should return the same value from environment + result2 = getter.get({"test_key": "different"}, "test_key") self.assertEqual(result1, ["test_value"]) self.assertEqual(result2, ["test_value"]) - self.assertEqual(result3, ["test_value"]) - def test_snapshot_behavior(self): - """Test that getter takes a snapshot of environment at initialization.""" - # Start with empty environment + def test_snapshot_immutability(self): + """Test that getter snapshot doesn't see changes after initialization.""" with patch.dict(os.environ, {}, clear=True): getter = EnvironmentGetter() - # Should be empty initially self.assertIsNone(getter.get({}, "test_key")) # Add environment variable after initialization os.environ["TEST_KEY"] = "new_value" - # Getter should still not see the new value (snapshot behavior) + # Getter should still not see the new value self.assertIsNone(getter.get({}, "test_key")) class TestEnvironmentSetter(unittest.TestCase): - def test_set_with_new_carrier(self): - """Test setting a value with a new carrier dictionary.""" + """Unit tests for EnvironmentSetter.""" + + def test_set_basic(self): + """Test setting a value in carrier dictionary.""" setter = EnvironmentSetter() carrier = {} setter.set(carrier, "test_key", "test_value") - self.assertEqual(carrier, {"TEST_KEY": "test_value"}) - def test_set_with_none_carrier(self): - """Test setting a value when carrier is None.""" - setter = EnvironmentSetter() - carrier = None - setter.set(carrier, "test_key", "test_value") - - # Note: carrier would still be None since Python passes by reference - # but the method should handle None gracefully - # This is a limitation of the current interface design - def test_set_multiple_values(self): """Test setting multiple values in the same carrier.""" setter = EnvironmentSetter() carrier = {} - setter.set(carrier, "key1", "value1") setter.set(carrier, "key2", "value2") setter.set(carrier, "key3", "value3") - expected = {"KEY1": "value1", "KEY2": "value2", "KEY3": "value3"} self.assertEqual(carrier, expected) - def test_set_overwrites_existing_key(self): + def test_set_overwrites_existing(self): """Test that setting a key overwrites existing value.""" setter = EnvironmentSetter() carrier = {"TEST_KEY": "old_value"} - setter.set(carrier, "test_key", "new_value") - self.assertEqual(carrier, {"TEST_KEY": "new_value"}) - def test_set_case_normalization(self): + def test_set_uppercases_keys(self): """Test that keys are normalized to uppercase.""" setter = EnvironmentSetter() carrier = {} - - # Test various case inputs setter.set(carrier, "lowercase_key", "value1") setter.set(carrier, "UPPERCASE_KEY", "value2") setter.set(carrier, "MiXeD_cAsE_kEy", "value3") - expected = { "LOWERCASE_KEY": "value1", "UPPERCASE_KEY": "value2", @@ -166,13 +157,11 @@ def test_set_case_normalization(self): } self.assertEqual(carrier, expected) - def test_set_with_special_characters(self): + def test_set_special_characters_in_value(self): """Test setting values with special characters.""" setter = EnvironmentSetter() carrier = {} - setter.set(carrier, "test_key", "value with spaces and !@#$%^&*()") - self.assertEqual( carrier, {"TEST_KEY": "value with spaces and !@#$%^&*()"} ) @@ -181,199 +170,383 @@ def test_set_empty_value(self): """Test setting an empty value.""" setter = EnvironmentSetter() carrier = {} - setter.set(carrier, "empty_key", "") - self.assertEqual(carrier, {"EMPTY_KEY": ""}) def test_does_not_modify_os_environ(self): - """Test that setting values does not modify os.environ.""" + """Test that setter does not modify os.environ.""" setter = EnvironmentSetter() carrier = {} - original_environ = dict(os.environ) setter.set(carrier, "test_key", "test_value") - - # os.environ should be unchanged self.assertEqual(dict(os.environ), original_environ) - # But carrier should have the value self.assertEqual(carrier, {"TEST_KEY": "test_value"}) -class TestEnvironmentCarrierIntegration(unittest.TestCase): - """Integration tests for EnvironmentGetter and EnvironmentSetter.""" +class TestEnvironmentCarrierWithTraceContext(unittest.TestCase): + """Integration tests with W3C TraceContext propagator.""" - def test_roundtrip_simple(self): - """Test basic roundtrip: set with setter, get with getter.""" - # Set up environment - test_env = { - "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01" - } + TRACE_ID = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + SPAN_ID = 0x00F067AA0BA902B7 - with patch.dict(os.environ, test_env, clear=True): + def setUp(self): + self.propagator = TraceContextTextMapPropagator() + + def _extract_with_env(self, env_vars): + """Helper: Extract context from environment variables.""" + with patch.dict(os.environ, env_vars, clear=True): getter = EnvironmentGetter() - setter = EnvironmentSetter() + return self.propagator.extract({}, getter=getter) - # Get from environment - value = getter.get({}, "traceparent") - self.assertEqual( - value, - ["00-12345678901234567890123456789012-1234567890123456-01"], - ) + def _inject_to_env(self, context): + """Helper: Inject context into environment dict.""" + setter = EnvironmentSetter() + env_dict = {} + self.propagator.inject(env_dict, context=context, setter=setter) + return env_dict + + def test_extract_valid_traceparent(self): + """Test extracting valid traceparent from environment.""" + traceparent = f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01" + ctx = self._extract_with_env({"TRACEPARENT": traceparent}) + + span_context = trace.get_current_span(ctx).get_span_context() + self.assertEqual(span_context.trace_id, self.TRACE_ID) + self.assertEqual(span_context.span_id, self.SPAN_ID) + self.assertTrue(span_context.trace_flags.sampled) + self.assertTrue(span_context.is_remote) + + def test_extract_traceparent_not_sampled(self): + """Test extracting traceparent with sampled=false.""" + traceparent = f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-00" + ctx = self._extract_with_env({"TRACEPARENT": traceparent}) + + span_context = trace.get_current_span(ctx).get_span_context() + self.assertFalse(span_context.trace_flags.sampled) + + def test_extract_with_tracestate(self): + """Test extracting traceparent with tracestate.""" + traceparent = f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01" + tracestate = "vendor1=value1,vendor2=value2" + + ctx = self._extract_with_env( + {"TRACEPARENT": traceparent, "TRACESTATE": tracestate} + ) - # Set to new carrier - new_carrier = {} - setter.set(new_carrier, "traceparent", value[0]) - self.assertEqual( - new_carrier, - { - "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01" - }, + span_context = trace.get_current_span(ctx).get_span_context() + self.assertEqual(span_context.trace_state.get("vendor1"), "value1") + self.assertEqual(span_context.trace_state.get("vendor2"), "value2") + + def test_extract_invalid_traceparent(self): + """Test that invalid traceparent formats are handled gracefully. + + Per W3C Trace Context spec, invalid traceparent should be ignored. + """ + invalid_traceparents = [ + "invalid-format", + "00-00000000000000000000000000000000-1234567890123456-00", # zero trace_id + "00-12345678901234567890123456789012-0000000000000000-00", # zero span_id + "00-12345678901234567890123456789012-1234567890123456-00-extra", # extra data + ] + + for invalid_tp in invalid_traceparents: + with self.subTest(traceparent=invalid_tp): + ctx = self._extract_with_env({"TRACEPARENT": invalid_tp}) + span = trace.get_current_span(ctx) + self.assertEqual( + span.get_span_context(), trace.INVALID_SPAN_CONTEXT + ) + + def test_extract_missing_traceparent(self): + """Test extraction with missing TRACEPARENT.""" + ctx = self._extract_with_env({}) + span = trace.get_current_span(ctx) + self.assertIsInstance(span.get_span_context(), trace.SpanContext) + + def test_extract_preserves_context_on_invalid_traceparent(self): + """Test that invalid traceparent preserves original context.""" + orig_ctx = Context({"my_key": "my_value"}) + + with patch.dict(os.environ, {"TRACEPARENT": "invalid"}, clear=True): + getter = EnvironmentGetter() + ctx = self.propagator.extract( + {}, context=orig_ctx, getter=getter ) - def test_w3c_headers_case_handling(self): - """Test proper case handling for W3C standard headers.""" + self.assertDictEqual(ctx, orig_ctx) + + def test_inject_valid_span_context(self): + """Test injecting valid span context to environment dict.""" + span_context = trace.SpanContext( + trace_id=self.TRACE_ID, + span_id=self.SPAN_ID, + is_remote=False, + trace_flags=trace.TraceFlags(0x01), + ) + ctx = trace.set_span_in_context(trace.NonRecordingSpan(span_context)) + + env_dict = self._inject_to_env(ctx) + + expected_traceparent = ( + f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01" + ) + self.assertEqual(env_dict["TRACEPARENT"], expected_traceparent) + + def test_inject_does_not_include_empty_tracestate(self): + """Test that empty tracestate is not injected. + + Per W3C spec: vendors SHOULD avoid sending empty tracestate. + """ + span_context = trace.SpanContext( + trace_id=self.TRACE_ID, + span_id=self.SPAN_ID, + is_remote=False, + ) + ctx = trace.set_span_in_context(trace.NonRecordingSpan(span_context)) + + env_dict = self._inject_to_env(ctx) + + self.assertIn("TRACEPARENT", env_dict) + self.assertNotIn("TRACESTATE", env_dict) + + def test_inject_invalid_context(self): + """Test that invalid context is not propagated.""" + ctx = trace.set_span_in_context(trace.INVALID_SPAN) + env_dict = self._inject_to_env(ctx) + self.assertNotIn("TRACEPARENT", env_dict) + + def test_roundtrip_preserves_traceparent(self): + """Test that traceparent survives extract->inject->extract cycle.""" + original_traceparent = ( + f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01" + ) + + # Extract from environment + ctx1 = self._extract_with_env({"TRACEPARENT": original_traceparent}) + + # Inject to new environment dict + env_dict = self._inject_to_env(ctx1) + self.assertEqual(env_dict["TRACEPARENT"], original_traceparent) + + # Extract again from injected dict + ctx2 = self._extract_with_env(env_dict) + + # Verify both contexts have same span context + span1 = trace.get_current_span(ctx1).get_span_context() + span2 = trace.get_current_span(ctx2).get_span_context() + self.assertEqual(span1.trace_id, span2.trace_id) + self.assertEqual(span1.span_id, span2.span_id) + self.assertEqual(span1.trace_flags, span2.trace_flags) + + def test_roundtrip_preserves_tracestate(self): + """Test that tracestate survives roundtrip.""" + env_vars = { + "TRACEPARENT": f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01", + "TRACESTATE": "vendor1=value1,vendor2=value2", + } + + ctx1 = self._extract_with_env(env_vars) + env_dict = self._inject_to_env(ctx1) + + self.assertIn("TRACESTATE", env_dict) + self.assertIn("vendor1=value1", env_dict["TRACESTATE"]) + self.assertIn("vendor2=value2", env_dict["TRACESTATE"]) + + def test_case_handling(self): + """Test case handling for W3C headers.""" test_env = { - "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", + "TRACEPARENT": f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01", "TRACESTATE": "vendor=value", - "BAGGAGE": "key=value", } with patch.dict(os.environ, test_env, clear=True): getter = EnvironmentGetter() - - # Should be able to retrieve using lowercase (standard HTTP header format) - self.assertEqual( - getter.get({}, "traceparent"), - ["00-12345678901234567890123456789012-1234567890123456-01"], + # Propagator uses lowercase keys + self.assertIsNotNone(getter.get({}, "traceparent")) + self.assertIsNotNone(getter.get({}, "tracestate")) + + @patch("opentelemetry.trace.INVALID_SPAN_CONTEXT") + @patch("opentelemetry.trace.get_current_span") + def test_fields(self, mock_get_current_span, mock_invalid_span_context): + """Test that propagator.fields matches injected keys.""" + from opentelemetry.trace.span import TraceState + + mock_get_current_span.configure_mock( + return_value=Mock( + **{ + "get_span_context.return_value": Mock( + **{ + "trace_id": 1, + "span_id": 2, + "trace_flags": 3, + "trace_state": TraceState([("a", "b")]), + } + ) + } ) - self.assertEqual(getter.get({}, "tracestate"), ["vendor=value"]) - self.assertEqual(getter.get({}, "baggage"), ["key=value"]) + ) - def test_empty_environment(self): - """Test behavior with completely empty environment.""" - with patch.dict(os.environ, {}, clear=True): + mock_setter = Mock() + self.propagator.inject({}, setter=mock_setter) + + inject_fields = set() + for mock_call in mock_setter.mock_calls: + inject_fields.add(mock_call[1][1]) + + self.assertEqual(inject_fields, self.propagator.fields) + + +class TestEnvironmentCarrierWithBaggage(unittest.TestCase): + """Integration tests with W3C Baggage propagator.""" + + def setUp(self): + self.propagator = W3CBaggagePropagator() + + def _extract_with_env(self, env_vars): + """Helper: Extract baggage from environment variables.""" + with patch.dict(os.environ, env_vars, clear=True): getter = EnvironmentGetter() - setter = EnvironmentSetter() + return self.propagator.extract({}, getter=getter) - # Getting should return None - self.assertIsNone(getter.get({}, "any_key")) - self.assertEqual(getter.keys({}), []) + def _inject_to_env(self, context): + """Helper: Inject baggage into environment dict.""" + setter = EnvironmentSetter() + env_dict = {} + self.propagator.inject(env_dict, context=context, setter=setter) + return env_dict + + def test_extract_baggage(self): + """Test extracting baggage from BAGGAGE environment variable.""" + ctx = self._extract_with_env( + {"BAGGAGE": "key1=value1,key2=value2,key3=value3"} + ) - # Setting should work normally - carrier = {} - setter.set(carrier, "new_key", "new_value") - self.assertEqual(carrier, {"NEW_KEY": "new_value"}) + baggage = get_all(ctx) + self.assertEqual( + baggage, {"key1": "value1", "key2": "value2", "key3": "value3"} + ) + def test_extract_empty_baggage(self): + """Test extracting empty baggage.""" + ctx = self._extract_with_env({"BAGGAGE": ""}) + baggage = get_all(ctx) + self.assertEqual(baggage, {}) -class TestEnvironmentCarrierWithPropagators(unittest.TestCase): - """Integration tests demonstrating environment carrier usage with propagators. + def test_extract_missing_baggage(self): + """Test extraction with missing BAGGAGE.""" + ctx = self._extract_with_env({}) + baggage = get_all(ctx) + self.assertEqual(baggage, {}) - Note: These tests demonstrate usage patterns but don't require actual - propagator imports since validation is handled at the propagator level. - """ + def test_inject_baggage(self): + """Test injecting baggage into environment dict.""" + ctx = get_current() + ctx = set_baggage("deployment", "production", context=ctx) + ctx = set_baggage("service", "api-gateway", context=ctx) - def test_w3c_traceparent_pattern(self): - """Test environment carrier with W3C TraceContext header format.""" - # Simulate W3C TraceContext format - traceparent = "00-12345678901234567890123456789012-1234567890123456-01" + env_dict = self._inject_to_env(ctx) - with patch.dict(os.environ, {"TRACEPARENT": traceparent}): - getter = EnvironmentGetter() + self.assertIn("BAGGAGE", env_dict) + baggage_value = env_dict["BAGGAGE"] + self.assertIn("deployment=production", baggage_value) + self.assertIn("service=api-gateway", baggage_value) - # Propagator would use lowercase key for lookup - result = getter.get({}, "traceparent") - self.assertEqual(result, [traceparent]) + def test_inject_no_baggage(self): + """Test that empty baggage is not injected.""" + ctx = get_current() + env_dict = self._inject_to_env(ctx) + self.assertNotIn("BAGGAGE", env_dict) - # Setter would prepare environment for process spawning - setter = EnvironmentSetter() - carrier = {} - setter.set(carrier, "traceparent", traceparent) - self.assertEqual(carrier, {"TRACEPARENT": traceparent}) + def test_roundtrip_baggage(self): + """Test baggage roundtrip.""" + ctx1 = get_current() + ctx1 = set_baggage("user_id", "12345", context=ctx1) + ctx1 = set_baggage("session_id", "abc-def", context=ctx1) - def test_w3c_baggage_pattern(self): - """Test environment carrier with W3C Baggage header format.""" - baggage = "key1=value1,key2=value2" + env_dict = self._inject_to_env(ctx1) + ctx2 = self._extract_with_env(env_dict) - with patch.dict(os.environ, {"BAGGAGE": baggage}): - getter = EnvironmentGetter() - result = getter.get({}, "baggage") - self.assertEqual(result, [baggage]) + baggage1 = get_all(ctx1) + baggage2 = get_all(ctx2) + self.assertEqual(baggage1, baggage2) - setter = EnvironmentSetter() - carrier = {} - setter.set(carrier, "baggage", baggage) - self.assertEqual(carrier, {"BAGGAGE": baggage}) + @patch("opentelemetry.baggage.propagation.get_all") + @patch("opentelemetry.baggage.propagation._format_baggage") + def test_fields(self, mock_format_baggage, mock_get_all): + """Test that propagator.fields matches injected keys.""" + mock_setter = Mock() + self.propagator.inject({}, setter=mock_setter) - def test_multiple_headers_integration(self): - """Test environment carrier with multiple W3C headers.""" - test_env = { - "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", + inject_fields = set() + for mock_call in mock_setter.mock_calls: + inject_fields.add(mock_call[1][1]) + + self.assertEqual(inject_fields, self.propagator.fields) + + +class TestEnvironmentCarrierWithCompositePropagator(unittest.TestCase): + """Integration tests with multiple propagators.""" + + TRACE_ID = 0x4BF92F3577B34DA6A3CE929D0E0E4736 + SPAN_ID = 0x00F067AA0BA902B7 + + def setUp(self): + from opentelemetry.propagators.composite import CompositePropagator + + self.propagator = CompositePropagator( + [TraceContextTextMapPropagator(), W3CBaggagePropagator()] + ) + + def test_extract_all_w3c_headers(self): + """Test extracting both traceparent and baggage.""" + env_vars = { + "TRACEPARENT": f"00-{self.TRACE_ID:032x}-{self.SPAN_ID:016x}-01", "TRACESTATE": "vendor=value", - "BAGGAGE": "key=value", + "BAGGAGE": "user_id=12345,session_id=abc-def", } - with patch.dict(os.environ, test_env, clear=True): + with patch.dict(os.environ, env_vars, clear=True): getter = EnvironmentGetter() - setter = EnvironmentSetter() - - # Simulate extraction process - extracted_data = {} - for key in ["traceparent", "tracestate", "baggage"]: - value = getter.get({}, key) - if value is not None: - extracted_data[key] = value[0] - - expected = { - "traceparent": "00-12345678901234567890123456789012-1234567890123456-01", - "tracestate": "vendor=value", - "baggage": "key=value", - } - self.assertEqual(extracted_data, expected) - - # Simulate injection process - carrier = {} - for key, value in extracted_data.items(): - setter.set(carrier, key, value) - - expected_carrier = { - "TRACEPARENT": "00-12345678901234567890123456789012-1234567890123456-01", - "TRACESTATE": "vendor=value", - "BAGGAGE": "key=value", - } - self.assertEqual(carrier, expected_carrier) - - def test_carrier_interface_compliance(self): - """Test that environment carriers comply with the TextMap interfaces.""" - getter = EnvironmentGetter() - setter = EnvironmentSetter() + ctx = self.propagator.extract({}, getter=getter) + + # Verify traceparent was extracted + span_context = trace.get_current_span(ctx).get_span_context() + self.assertEqual(span_context.trace_id, self.TRACE_ID) + self.assertEqual(span_context.span_id, self.SPAN_ID) + + # Verify baggage was extracted + baggage = get_all(ctx) + self.assertEqual(baggage["user_id"], "12345") + self.assertEqual(baggage["session_id"], "abc-def") + + def test_inject_all_w3c_headers(self): + """Test injecting both traceparent and baggage.""" + span_context = trace.SpanContext( + trace_id=self.TRACE_ID, + span_id=self.SPAN_ID, + is_remote=False, + trace_flags=trace.TraceFlags(0x01), + ) + ctx = trace.set_span_in_context(trace.NonRecordingSpan(span_context)) + ctx = set_baggage("deployment", "production", context=ctx) - # Test getter interface compliance - self.assertTrue(hasattr(getter, "get")) - self.assertTrue(hasattr(getter, "keys")) - self.assertTrue(callable(getter.get)) - self.assertTrue(callable(getter.keys)) + setter = EnvironmentSetter() + env_dict = {} + self.propagator.inject(env_dict, context=ctx, setter=setter) - # Test setter interface compliance - self.assertTrue(hasattr(setter, "set")) - self.assertTrue(callable(setter.set)) + # Verify both were injected with uppercase keys + self.assertIn("TRACEPARENT", env_dict) + self.assertIn("BAGGAGE", env_dict) + self.assertIn("deployment=production", env_dict["BAGGAGE"]) - # Test method signatures work as expected - with patch.dict(os.environ, {"TEST": "value"}): + def test_empty_environment(self): + """Test behavior with completely empty environment.""" + with patch.dict(os.environ, {}, clear=True): getter = EnvironmentGetter() + ctx = self.propagator.extract({}, getter=getter) - # get() should accept carrier and key parameters - result = getter.get({}, "test") - self.assertEqual(result, ["value"]) - - # keys() should accept carrier parameter - keys = getter.keys({}) - self.assertIn("test", keys) - - # set() should accept carrier, key, and value parameters - carrier = {} - setter.set(carrier, "key", "value") - self.assertEqual(carrier, {"KEY": "value"}) + # Should not crash, return valid context + self.assertIsInstance(ctx, Context) if __name__ == "__main__": From c09388e4e0fbc95b69a15eb06147d137f9c18ea1 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Tue, 13 Jan 2026 13:58:18 -0500 Subject: [PATCH 7/9] chore: run ruff and follow ignore pattern for tests with dedicated imports --- .../tests/propagators/test__envcarrier.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/opentelemetry-api/tests/propagators/test__envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py index de617418d55..0bf800f76e9 100644 --- a/opentelemetry-api/tests/propagators/test__envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -269,9 +269,7 @@ def test_extract_preserves_context_on_invalid_traceparent(self): with patch.dict(os.environ, {"TRACEPARENT": "invalid"}, clear=True): getter = EnvironmentGetter() - ctx = self.propagator.extract( - {}, context=orig_ctx, getter=getter - ) + ctx = self.propagator.extract({}, context=orig_ctx, getter=getter) self.assertDictEqual(ctx, orig_ctx) @@ -369,7 +367,8 @@ def test_case_handling(self): @patch("opentelemetry.trace.get_current_span") def test_fields(self, mock_get_current_span, mock_invalid_span_context): """Test that propagator.fields matches injected keys.""" - from opentelemetry.trace.span import TraceState + # pylint: disable=import-outside-toplevel + from opentelemetry.trace.span import TraceState # noqa: PLC0415 mock_get_current_span.configure_mock( return_value=Mock( @@ -491,7 +490,10 @@ class TestEnvironmentCarrierWithCompositePropagator(unittest.TestCase): SPAN_ID = 0x00F067AA0BA902B7 def setUp(self): - from opentelemetry.propagators.composite import CompositePropagator + # pylint: disable=import-outside-toplevel + from opentelemetry.propagators.composite import ( # noqa: PLC0415 + CompositePropagator, + ) self.propagator = CompositePropagator( [TraceContextTextMapPropagator(), W3CBaggagePropagator()] From 00690891577e59ea5b8b6665ac4497ff68967a01 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Wed, 14 Jan 2026 10:41:38 -0500 Subject: [PATCH 8/9] chore: apply suggestions from code review Co-authored-by: Riccardo Magliocchetti --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6f1df52d3..8767f947719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4676](https://github.com/open-telemetry/opentelemetry-python/pull/4676)) - [BREAKING] Rename several classes from Log to LogRecord ([#4647](https://github.com/open-telemetry/opentelemetry-python/pull/4647)) - + **Migration Guide:** `LogData` has been removed. Users should update their code as follows: From ba5462cbcb1b5a26d830953fd0cef78dfb0c8892 Mon Sep 17 00:00:00 2001 From: Adriel Perkins Date: Mon, 19 Jan 2026 11:20:24 -0500 Subject: [PATCH 9/9] [chore] use MutableMapping and take in suggested changes --- .../opentelemetry/propagators/_envcarrier.py | 33 +++++++++++-------- .../tests/propagators/test__envcarrier.py | 4 --- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py index 90823b9d1a3..0ea5f48bcdb 100644 --- a/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py +++ b/opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py @@ -14,15 +14,21 @@ import os import typing +from collections.abc import MutableMapping from opentelemetry.propagators.textmap import Getter, Setter -class EnvironmentGetter(Getter[dict]): +class EnvironmentGetter(Getter[typing.Mapping[str, str]]): """Getter implementation for extracting context and baggage from environment variables. EnvironmentGetter creates a case-insensitive lookup from the current environment - variables and provides simple data access without validation. + variables 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 + change between logical requests (e.g., AWS Lambda's _X_AMZN_TRACE_ID), create a + new EnvironmentGetter instance at the start of each request. Example usage: getter = EnvironmentGetter() @@ -31,15 +37,18 @@ class EnvironmentGetter(Getter[dict]): def __init__(self): # Create case-insensitive lookup from current environment - self.carrier = {k.lower(): v for k, v in os.environ.items()} + # Per spec: "creates an in-memory copy of the current environment variables" + self.carrier: typing.Dict[str, str] = { + k.lower(): v for k, v in os.environ.items() + } def get( - self, carrier: dict, key: str + self, carrier: typing.Mapping[str, str], key: str ) -> typing.Optional[typing.List[str]]: - """Get a value from the environment for the given key. + """Get a value from the environment carrier for the given key. Args: - carrier: Not used for environment getter, maintained for interface compatibility + carrier: Not used; maintained for interface compatibility with Getter[CarrierT] key: The key to look up (case-insensitive) Returns: @@ -52,11 +61,11 @@ def get( return list(val) return [val] - def keys(self, carrier: dict) -> typing.List[str]: + def keys(self, carrier: typing.Mapping[str, str]) -> typing.List[str]: """Get all keys from the environment carrier. Args: - carrier: Not used for environment getter, maintained for interface compatibility + carrier: Not used; maintained for interface compatibility with Getter[CarrierT] Returns: List of all environment variable keys (lowercase). @@ -64,7 +73,7 @@ def keys(self, carrier: dict) -> typing.List[str]: return list(self.carrier.keys()) -class EnvironmentSetter(Setter[dict]): +class EnvironmentSetter(Setter[MutableMapping[str, str]]): """Setter implementation for building environment variable dictionaries. EnvironmentSetter builds a dictionary of environment variables that @@ -78,15 +87,13 @@ class EnvironmentSetter(Setter[dict]): """ def set( - self, carrier: typing.Optional[dict], key: str, value: str + self, carrier: MutableMapping[str, str], key: str, value: str ) -> None: """Set a value in the carrier dictionary for the given key. Args: - carrier: Dictionary to store environment variables, created if None + carrier: Dictionary to store environment variables key: The key to set (will be converted to uppercase) value: The value to set """ - if carrier is None: - carrier = {} carrier[key.upper()] = value diff --git a/opentelemetry-api/tests/propagators/test__envcarrier.py b/opentelemetry-api/tests/propagators/test__envcarrier.py index 0bf800f76e9..eb2b99c39ca 100644 --- a/opentelemetry-api/tests/propagators/test__envcarrier.py +++ b/opentelemetry-api/tests/propagators/test__envcarrier.py @@ -549,7 +549,3 @@ def test_empty_environment(self): # Should not crash, return valid context self.assertIsInstance(ctx, Context) - - -if __name__ == "__main__": - unittest.main()