Skip to content

Commit 57d30b3

Browse files
authored
Accept non URL-encoded headers (#4103)
1 parent 2f928a2 commit 57d30b3

File tree

9 files changed

+109
-28
lines changed

9 files changed

+109
-28
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- Update log export example to not use root logger ([#4090](https://github.com/open-telemetry/opentelemetry-python/pull/4090))
1717
- sdk: Add OS resource detector
1818
([#3992](https://github.com/open-telemetry/opentelemetry-python/pull/3992))
19+
- sdk: Accept non URL-encoded headers in `OTEL_EXPORTER_OTLP_*HEADERS` to match other languages SDKs
20+
([#4103](https://github.com/open-telemetry/opentelemetry-python/pull/4103))
1921
- Update semantic conventions to version 1.27.0
2022
([#4104](https://github.com/open-telemetry/opentelemetry-python/pull/4104))
2123

Diff for: exporter/opentelemetry-exporter-otlp-proto-grpc/src/opentelemetry/exporter/otlp/proto/grpc/exporter.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def __init__(
188188

189189
self._headers = headers or environ.get(OTEL_EXPORTER_OTLP_HEADERS)
190190
if isinstance(self._headers, str):
191-
temp_headers = parse_env_headers(self._headers)
191+
temp_headers = parse_env_headers(self._headers, liberal=True)
192192
self._headers = tuple(temp_headers.items())
193193
elif isinstance(self._headers, dict):
194194
self._headers = tuple(self._headers.items())

Diff for: exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/_log_exporter/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def __init__(
8686
OTEL_EXPORTER_OTLP_LOGS_HEADERS,
8787
environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""),
8888
)
89-
self._headers = headers or parse_env_headers(headers_string)
89+
self._headers = headers or parse_env_headers(
90+
headers_string, liberal=True
91+
)
9092
self._timeout = timeout or int(
9193
environ.get(
9294
OTEL_EXPORTER_OTLP_LOGS_TIMEOUT,

Diff for: exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/metric_exporter/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ def __init__(
117117
OTEL_EXPORTER_OTLP_METRICS_HEADERS,
118118
environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""),
119119
)
120-
self._headers = headers or parse_env_headers(headers_string)
120+
self._headers = headers or parse_env_headers(
121+
headers_string, liberal=True
122+
)
121123
self._timeout = timeout or int(
122124
environ.get(
123125
OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,

Diff for: exporter/opentelemetry-exporter-otlp-proto-http/src/opentelemetry/exporter/otlp/proto/http/trace_exporter/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ def __init__(
8484
OTEL_EXPORTER_OTLP_TRACES_HEADERS,
8585
environ.get(OTEL_EXPORTER_OTLP_HEADERS, ""),
8686
)
87-
self._headers = headers or parse_env_headers(headers_string)
87+
self._headers = headers or parse_env_headers(
88+
headers_string, liberal=True
89+
)
8890
self._timeout = timeout or int(
8991
environ.get(
9092
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,

Diff for: exporter/opentelemetry-exporter-otlp-proto-http/tests/metrics/test_otlp_metrics_exporter.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ def test_headers_parse_from_env(self):
244244
(
245245
"Header format invalid! Header values in environment "
246246
"variables must be URL encoded per the OpenTelemetry "
247-
"Protocol Exporter specification: missingValue"
247+
"Protocol Exporter specification or a comma separated "
248+
"list of name=value occurrences: missingValue"
248249
),
249250
)
250251

Diff for: exporter/opentelemetry-exporter-otlp-proto-http/tests/test_proto_span_exporter.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,8 @@ def test_headers_parse_from_env(self):
197197
(
198198
"Header format invalid! Header values in environment "
199199
"variables must be URL encoded per the OpenTelemetry "
200-
"Protocol Exporter specification: missingValue"
200+
"Protocol Exporter specification or a comma separated "
201+
"list of name=value occurrences: missingValue"
201202
),
202203
)
203204

Diff for: opentelemetry-api/src/opentelemetry/util/re.py

+45-11
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,23 @@
3636
_KEY_VALUE_FORMAT = rf"{_OWS}{_KEY_FORMAT}{_OWS}={_OWS}{_VALUE_FORMAT}{_OWS}"
3737

3838
_HEADER_PATTERN = compile(_KEY_VALUE_FORMAT)
39+
_LIBERAL_HEADER_PATTERN = compile(
40+
rf"{_OWS}{_KEY_FORMAT}{_OWS}={_OWS}[\w ]*{_OWS}"
41+
)
3942
_DELIMITER_PATTERN = compile(r"[ \t]*,[ \t]*")
4043

4144
_BAGGAGE_PROPERTY_FORMAT = rf"{_KEY_VALUE_FORMAT}|{_OWS}{_KEY_FORMAT}{_OWS}"
4245

46+
_INVALID_HEADER_ERROR_MESSAGE_STRICT_TEMPLATE = (
47+
"Header format invalid! Header values in environment variables must be "
48+
"URL encoded per the OpenTelemetry Protocol Exporter specification: %s"
49+
)
50+
51+
_INVALID_HEADER_ERROR_MESSAGE_LIBERAL_TEMPLATE = (
52+
"Header format invalid! Header values in environment variables must be "
53+
"URL encoded per the OpenTelemetry Protocol Exporter specification or "
54+
"a comma separated list of name=value occurrences: %s"
55+
)
4356

4457
# pylint: disable=invalid-name
4558

@@ -49,30 +62,51 @@ def parse_headers(s: str) -> Mapping[str, str]:
4962
return parse_env_headers(s)
5063

5164

52-
def parse_env_headers(s: str) -> Mapping[str, str]:
65+
def parse_env_headers(s: str, liberal: bool = False) -> Mapping[str, str]:
5366
"""
5467
Parse ``s``, which is a ``str`` instance containing HTTP headers encoded
5568
for use in ENV variables per the W3C Baggage HTTP header format at
5669
https://www.w3.org/TR/baggage/#baggage-http-header-format, except that
5770
additional semi-colon delimited metadata is not supported.
71+
If ``liberal`` is True we try to parse ``s`` anyway to be more compatible
72+
with other languages SDKs that accept non URL-encoded headers by default.
5873
"""
5974
headers: Dict[str, str] = {}
6075
headers_list: List[str] = split(_DELIMITER_PATTERN, s)
6176
for header in headers_list:
6277
if not header: # empty string
6378
continue
64-
match = _HEADER_PATTERN.fullmatch(header.strip())
65-
if not match:
79+
header_match = _HEADER_PATTERN.fullmatch(header.strip())
80+
if not header_match and not liberal:
6681
_logger.warning(
67-
"Header format invalid! Header values in environment variables must be "
68-
"URL encoded per the OpenTelemetry Protocol Exporter specification: %s",
69-
header,
82+
_INVALID_HEADER_ERROR_MESSAGE_STRICT_TEMPLATE, header
7083
)
7184
continue
72-
# value may contain any number of `=`
73-
name, value = match.string.split("=", 1)
74-
name = unquote(name).strip().lower()
75-
value = unquote(value).strip()
76-
headers[name] = value
85+
86+
if header_match:
87+
match_string: str = header_match.string
88+
# value may contain any number of `=`
89+
name, value = match_string.split("=", 1)
90+
name = unquote(name).strip().lower()
91+
value = unquote(value).strip()
92+
headers[name] = value
93+
else:
94+
# this is not url-encoded and does not match the spec but we decided to be
95+
# liberal in what we accept to match other languages SDKs behaviour
96+
liberal_header_match = _LIBERAL_HEADER_PATTERN.fullmatch(
97+
header.strip()
98+
)
99+
if not liberal_header_match:
100+
_logger.warning(
101+
_INVALID_HEADER_ERROR_MESSAGE_LIBERAL_TEMPLATE, header
102+
)
103+
continue
104+
105+
liberal_match_string: str = liberal_header_match.string
106+
# value may contain any number of `=`
107+
name, value = liberal_match_string.split("=", 1)
108+
name = name.strip().lower()
109+
value = value.strip()
110+
headers[name] = value
77111

78112
return headers

Diff for: opentelemetry-api/tests/util/test_re.py

+48-11
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020

2121

2222
class TestParseHeaders(unittest.TestCase):
23-
def test_parse_env_headers(self):
24-
inp = [
23+
@staticmethod
24+
def _common_test_cases():
25+
return [
2526
# invalid header name
2627
("=value", [], True),
2728
("}key=value", [], True),
@@ -59,18 +60,54 @@ def test_parse_env_headers(self):
5960
True,
6061
),
6162
]
63+
64+
def test_parse_env_headers(self):
65+
inp = self._common_test_cases() + [
66+
# invalid header value
67+
("key=value othervalue", [], True),
68+
]
6269
for case_ in inp:
6370
headers, expected, warn = case_
64-
if warn:
65-
with self.assertLogs(level="WARNING") as cm:
71+
with self.subTest(headers=headers):
72+
if warn:
73+
with self.assertLogs(level="WARNING") as cm:
74+
self.assertEqual(
75+
parse_env_headers(headers), dict(expected)
76+
)
77+
self.assertTrue(
78+
"Header format invalid! Header values in environment "
79+
"variables must be URL encoded per the OpenTelemetry "
80+
"Protocol Exporter specification:"
81+
in cm.records[0].message,
82+
)
83+
else:
6684
self.assertEqual(
6785
parse_env_headers(headers), dict(expected)
6886
)
69-
self.assertTrue(
70-
"Header format invalid! Header values in environment "
71-
"variables must be URL encoded per the OpenTelemetry "
72-
"Protocol Exporter specification:"
73-
in cm.records[0].message,
87+
88+
def test_parse_env_headers_liberal(self):
89+
inp = self._common_test_cases() + [
90+
# valid header value
91+
("key=value othervalue", [("key", "value othervalue")], False),
92+
]
93+
for case_ in inp:
94+
headers, expected, warn = case_
95+
with self.subTest(headers=headers):
96+
if warn:
97+
with self.assertLogs(level="WARNING") as cm:
98+
self.assertEqual(
99+
parse_env_headers(headers, liberal=True),
100+
dict(expected),
101+
)
102+
self.assertTrue(
103+
"Header format invalid! Header values in environment "
104+
"variables must be URL encoded per the OpenTelemetry "
105+
"Protocol Exporter specification or a comma separated "
106+
"list of name=value occurrences:"
107+
in cm.records[0].message,
108+
)
109+
else:
110+
self.assertEqual(
111+
parse_env_headers(headers, liberal=True),
112+
dict(expected),
74113
)
75-
else:
76-
self.assertEqual(parse_env_headers(headers), dict(expected))

0 commit comments

Comments
 (0)