Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5289.changed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`opentelemetry-api`: update `EnvironmentGetter` to ignore non-normalized environment variable names
24 changes: 17 additions & 7 deletions opentelemetry-api/src/opentelemetry/propagators/_envcarrier.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,22 @@ 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
Expand All @@ -33,10 +44,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(
Expand All @@ -46,7 +56,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.
Expand All @@ -63,7 +73,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())

Expand Down
66 changes: 65 additions & 1 deletion opentelemetry-api/tests/propagators/test__envcarrier.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from opentelemetry.propagators._envcarrier import (
EnvironmentGetter,
EnvironmentSetter,
_is_normalized_key,
_normalize_key,
)
from opentelemetry.trace.propagation.tracecontext import (
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -84,15 +103,45 @@ 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):
Expand Down Expand Up @@ -249,6 +298,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.

Expand Down
Loading