Skip to content

Commit f6ed62a

Browse files
samuelcolvinlzchenocelotl
authored
Repeated headers list for ASGI frameworks (#2361)
* avoid loosing repeated HTTP headers * fix fof wsgi, test in falcon * add changelog * add more tests * linting * fix falcon and flask * remove unused test * Use a list for repeated HTTP headers * linting * add changelog entry * update docs and improve fastapi tests * revert changes in wsgi based webframeworks * fix linting * Fix import path of typing symbols --------- Co-authored-by: Leighton Chen <lechen@microsoft.com> Co-authored-by: Diego Hurtado <ocelotl@users.noreply.github.com>
1 parent a61739c commit f6ed62a

File tree

7 files changed

+77
-52
lines changed

7 files changed

+77
-52
lines changed

CHANGELOG.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
([#2425](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2425))
4949
- `opentelemetry-instrumentation-flask` Add `http.method` to `span.name`
5050
([#2454](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2454))
51-
- ASGI, FastAPI, Starlette: provide both send and receive hooks with `scope` and `message` for internal spans ([#2546](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2546))
51+
- Record repeated HTTP headers in lists, rather than a comma separate strings for ASGI based web frameworks
52+
([#2361](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2361))
53+
- ASGI, FastAPI, Starlette: provide both send and receive hooks with `scope` and `message` for internal spans
54+
- ([#2546](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2546))
5255

5356
### Added
5457

instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py

+11-15
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
129129
130130
The name of the added span attribute will follow the format ``http.request.header.<header_name>`` where ``<header_name>``
131131
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
132-
single item list containing all the header values.
132+
list containing the header values.
133133
134134
For example:
135-
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
135+
``http.request.header.custom_request_header = ["<value1>", "<value2>"]``
136136
137137
Response headers
138138
****************
@@ -163,10 +163,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
163163
164164
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>``
165165
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
166-
single item list containing all the header values.
166+
list containing the header values.
167167
168168
For example:
169-
``http.response.header.custom_response_header = ["<value1>,<value2>"]``
169+
``http.response.header.custom_response_header = ["<value1>", "<value2>"]``
170170
171171
Sanitizing headers
172172
******************
@@ -193,9 +193,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
193193

194194
import typing
195195
import urllib
196+
from collections import defaultdict
196197
from functools import wraps
197198
from timeit import default_timer
198-
from typing import Any, Awaitable, Callable, Tuple
199+
from typing import Any, Awaitable, Callable, DefaultDict, Tuple
199200

200201
from asgiref.compatibility import guarantee_single_callable
201202

@@ -340,24 +341,19 @@ def collect_custom_headers_attributes(
340341
sanitize: SanitizeValue,
341342
header_regexes: list[str],
342343
normalize_names: Callable[[str], str],
343-
) -> dict[str, str]:
344+
) -> dict[str, list[str]]:
344345
"""
345346
Returns custom HTTP request or response headers to be added into SERVER span as span attributes.
346347
347348
Refer specifications:
348349
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
349350
"""
350-
# Decode headers before processing.
351-
headers: dict[str, str] = {}
351+
headers: DefaultDict[str, list[str]] = defaultdict(list)
352352
raw_headers = scope_or_response_message.get("headers")
353353
if raw_headers:
354-
for _key, _value in raw_headers:
355-
key = _key.decode().lower()
356-
value = _value.decode()
357-
if key in headers:
358-
headers[key] += f",{value}"
359-
else:
360-
headers[key] = value
354+
for key, value in raw_headers:
355+
# Decode headers before processing.
356+
headers[key.decode()].append(value.decode())
361357

362358
return sanitize.sanitize_header_values(
363359
headers,

instrumentation/opentelemetry-instrumentation-asgi/tests/test_asgi_custom_headers.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ def test_http_repeat_request_headers_in_span_attributes(self):
152152
span_list = self.exporter.get_finished_spans()
153153
expected = {
154154
"http.request.header.custom_test_header_1": (
155-
"test-header-value-1,test-header-value-2",
155+
"test-header-value-1",
156+
"test-header-value-2",
156157
),
157158
}
158159
span = next(span for span in span_list if span.kind == SpanKind.SERVER)
@@ -225,7 +226,8 @@ def test_http_repeat_response_headers_in_span_attributes(self):
225226
span_list = self.exporter.get_finished_spans()
226227
expected = {
227228
"http.response.header.custom_test_header_1": (
228-
"test-header-value-1,test-header-value-2",
229+
"test-header-value-1",
230+
"test-header-value-2",
229231
),
230232
}
231233
span = next(span for span in span_list if span.kind == SpanKind.SERVER)

instrumentation/opentelemetry-instrumentation-fastapi/src/opentelemetry/instrumentation/fastapi/__init__.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
115115
single item list containing all the header values.
116116
117117
For example:
118-
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
118+
``http.request.header.custom_request_header = ["<value1>", "<value2>"]``
119119
120120
Response headers
121121
****************
@@ -146,10 +146,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
146146
147147
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>``
148148
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
149-
single item list containing all the header values.
149+
list containing the header values.
150150
151151
For example:
152-
``http.response.header.custom_response_header = ["<value1>,<value2>"]``
152+
``http.response.header.custom_response_header = ["<value1>", "<value2>"]``
153153
154154
Sanitizing headers
155155
******************

instrumentation/opentelemetry-instrumentation-fastapi/tests/test_fastapi_instrumentation.py

+33-10
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
1514
import unittest
15+
from collections.abc import Mapping
1616
from timeit import default_timer
17+
from typing import Tuple
1718
from unittest.mock import patch
1819

1920
import fastapi
@@ -557,6 +558,24 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
557558
)
558559

559560

561+
class MultiMapping(Mapping):
562+
563+
def __init__(self, *items: Tuple[str, str]):
564+
self._items = items
565+
566+
def __len__(self):
567+
return len(self._items)
568+
569+
def __getitem__(self, __key):
570+
raise NotImplementedError("use .items() instead")
571+
572+
def __iter__(self):
573+
raise NotImplementedError("use .items() instead")
574+
575+
def items(self):
576+
return self._items
577+
578+
560579
@patch.dict(
561580
"os.environ",
562581
{
@@ -583,13 +602,15 @@ def _create_app():
583602

584603
@app.get("/foobar")
585604
async def _():
586-
headers = {
587-
"custom-test-header-1": "test-header-value-1",
588-
"custom-test-header-2": "test-header-value-2",
589-
"my-custom-regex-header-1": "my-custom-regex-value-1,my-custom-regex-value-2",
590-
"My-Custom-Regex-Header-2": "my-custom-regex-value-3,my-custom-regex-value-4",
591-
"My-Secret-Header": "My Secret Value",
592-
}
605+
headers = MultiMapping(
606+
("custom-test-header-1", "test-header-value-1"),
607+
("custom-test-header-2", "test-header-value-2"),
608+
("my-custom-regex-header-1", "my-custom-regex-value-1"),
609+
("my-custom-regex-header-1", "my-custom-regex-value-2"),
610+
("My-Custom-Regex-Header-2", "my-custom-regex-value-3"),
611+
("My-Custom-Regex-Header-2", "my-custom-regex-value-4"),
612+
("My-Secret-Header", "My Secret Value"),
613+
)
593614
content = {"message": "hello world"}
594615
return JSONResponse(content=content, headers=headers)
595616

@@ -665,10 +686,12 @@ def test_http_custom_response_headers_in_span_attributes(self):
665686
"test-header-value-2",
666687
),
667688
"http.response.header.my_custom_regex_header_1": (
668-
"my-custom-regex-value-1,my-custom-regex-value-2",
689+
"my-custom-regex-value-1",
690+
"my-custom-regex-value-2",
669691
),
670692
"http.response.header.my_custom_regex_header_2": (
671-
"my-custom-regex-value-3,my-custom-regex-value-4",
693+
"my-custom-regex-value-3",
694+
"my-custom-regex-value-4",
672695
),
673696
"http.response.header.my_secret_header": ("[REDACTED]",),
674697
}

instrumentation/opentelemetry-instrumentation-starlette/src/opentelemetry/instrumentation/starlette/__init__.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
110110
111111
The name of the added span attribute will follow the format ``http.request.header.<header_name>`` where ``<header_name>``
112112
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
113-
single item list containing all the header values.
113+
list containing the header values.
114114
115115
For example:
116-
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
116+
``http.request.header.custom_request_header = ["<value1>", "<value2>"]``
117117
118118
Response headers
119119
****************
@@ -144,10 +144,10 @@ def client_response_hook(span: Span, scope: dict[str, Any], message: dict[str, A
144144
145145
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>``
146146
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
147-
single item list containing all the header values.
147+
list containing the header values.
148148
149149
For example:
150-
``http.response.header.custom_response_header = ["<value1>,<value2>"]``
150+
``http.response.header.custom_response_header = ["<value1>", "<value2>"]``
151151
152152
Sanitizing headers
153153
******************

util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

+18-17
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from __future__ import annotations
1616

17+
from collections.abc import Mapping
1718
from os import environ
1819
from re import IGNORECASE as RE_IGNORECASE
1920
from re import compile as re_compile
@@ -87,32 +88,32 @@ def sanitize_header_value(self, header: str, value: str) -> str:
8788

8889
def sanitize_header_values(
8990
self,
90-
headers: dict[str, str],
91+
headers: Mapping[str, str | list[str]],
9192
header_regexes: list[str],
9293
normalize_function: Callable[[str], str],
93-
) -> dict[str, str]:
94-
values: dict[str, str] = {}
94+
) -> dict[str, list[str]]:
95+
values: dict[str, list[str]] = {}
9596

9697
if header_regexes:
9798
header_regexes_compiled = re_compile(
98-
"|".join("^" + i + "$" for i in header_regexes),
99+
"|".join(header_regexes),
99100
RE_IGNORECASE,
100101
)
101102

102-
for header_name in list(
103-
filter(
104-
header_regexes_compiled.match,
105-
headers.keys(),
106-
)
107-
):
108-
header_values = headers.get(header_name)
109-
if header_values:
103+
for header_name, header_value in headers.items():
104+
if header_regexes_compiled.fullmatch(header_name):
110105
key = normalize_function(header_name.lower())
111-
values[key] = [
112-
self.sanitize_header_value(
113-
header=header_name, value=header_values
114-
)
115-
]
106+
if isinstance(header_value, str):
107+
values[key] = [
108+
self.sanitize_header_value(
109+
header_name, header_value
110+
)
111+
]
112+
else:
113+
values[key] = [
114+
self.sanitize_header_value(header_name, value)
115+
for value in header_value
116+
]
116117

117118
return values
118119

0 commit comments

Comments
 (0)