Skip to content

Commit 5207a78

Browse files
samuelcolvinlzchenocelotl
authored
avoid losing repeated HTTP headers (#2266)
* avoid loosing repeated HTTP headers * fix fof wsgi, test in falcon * add changelog * add more tests * linting * fix falcon and flask * remove unused test --------- Co-authored-by: Leighton Chen <[email protected]> Co-authored-by: Diego Hurtado <[email protected]>
1 parent b84d779 commit 5207a78

File tree

7 files changed

+149
-8
lines changed

7 files changed

+149
-8
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
([#2297](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/2297))
2020
- Ensure all http.server.duration metrics have the same description
2121
([#2151](https://github.com/open-telemetry/opentelemetry-python-contrib/issues/2298))
22+
- Avoid losing repeated HTTP headers
23+
([#2266](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2266))
2224

2325
## Version 1.23.0/0.44b0 (2024-02-23)
2426

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

+12-6
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def client_response_hook(span: Span, message: dict):
195195
import urllib
196196
from functools import wraps
197197
from timeit import default_timer
198-
from typing import Any, Awaitable, Callable, Tuple, cast
198+
from typing import Any, Awaitable, Callable, Tuple
199199

200200
from asgiref.compatibility import guarantee_single_callable
201201

@@ -347,11 +347,17 @@ def collect_custom_headers_attributes(
347347
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
348348
"""
349349
# Decode headers before processing.
350-
headers: dict[str, str] = {
351-
_key.decode("utf8"): _value.decode("utf8")
352-
for (_key, _value) in scope_or_response_message.get("headers")
353-
or cast("list[tuple[bytes, bytes]]", [])
354-
}
350+
headers: dict[str, str] = {}
351+
raw_headers = scope_or_response_message.get("headers")
352+
if raw_headers:
353+
for _key, _value in raw_headers:
354+
key = _key.decode().lower()
355+
value = _value.decode()
356+
if key in headers:
357+
headers[key] += f",{value}"
358+
else:
359+
headers[key] = value
360+
355361
return sanitize.sanitize_header_values(
356362
headers,
357363
header_regexes,

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

+55
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ async def http_app_with_custom_headers(scope, receive, send):
4040
await send({"type": "http.response.body", "body": b"*"})
4141

4242

43+
async def http_app_with_repeat_headers(scope, receive, send):
44+
message = await receive()
45+
assert scope["type"] == "http"
46+
if message.get("type") == "http.request":
47+
await send(
48+
{
49+
"type": "http.response.start",
50+
"status": 200,
51+
"headers": [
52+
(b"Content-Type", b"text/plain"),
53+
(b"custom-test-header-1", b"test-header-value-1"),
54+
(b"custom-test-header-1", b"test-header-value-2"),
55+
],
56+
}
57+
)
58+
await send({"type": "http.response.body", "body": b"*"})
59+
60+
4361
async def websocket_app_with_custom_headers(scope, receive, send):
4462
assert scope["type"] == "websocket"
4563
while True:
@@ -121,6 +139,25 @@ def test_http_custom_request_headers_in_span_attributes(self):
121139
if span.kind == SpanKind.SERVER:
122140
self.assertSpanHasAttributes(span, expected)
123141

142+
def test_http_repeat_request_headers_in_span_attributes(self):
143+
self.scope["headers"].extend(
144+
[
145+
(b"custom-test-header-1", b"test-header-value-1"),
146+
(b"custom-test-header-1", b"test-header-value-2"),
147+
]
148+
)
149+
self.seed_app(self.app)
150+
self.send_default_request()
151+
self.get_all_output()
152+
span_list = self.exporter.get_finished_spans()
153+
expected = {
154+
"http.request.header.custom_test_header_1": (
155+
"test-header-value-1,test-header-value-2",
156+
),
157+
}
158+
span = next(span for span in span_list if span.kind == SpanKind.SERVER)
159+
self.assertSpanHasAttributes(span, expected)
160+
124161
def test_http_custom_request_headers_not_in_span_attributes(self):
125162
self.scope["headers"].extend(
126163
[
@@ -176,6 +213,24 @@ def test_http_custom_response_headers_in_span_attributes(self):
176213
if span.kind == SpanKind.SERVER:
177214
self.assertSpanHasAttributes(span, expected)
178215

216+
def test_http_repeat_response_headers_in_span_attributes(self):
217+
self.app = otel_asgi.OpenTelemetryMiddleware(
218+
http_app_with_repeat_headers,
219+
tracer_provider=self.tracer_provider,
220+
**self.constructor_params,
221+
)
222+
self.seed_app(self.app)
223+
self.send_default_request()
224+
self.get_all_output()
225+
span_list = self.exporter.get_finished_spans()
226+
expected = {
227+
"http.response.header.custom_test_header_1": (
228+
"test-header-value-1,test-header-value-2",
229+
),
230+
}
231+
span = next(span for span in span_list if span.kind == SpanKind.SERVER)
232+
self.assertSpanHasAttributes(span, expected)
233+
179234
def test_http_custom_response_headers_not_in_span_attributes(self):
180235
self.app = otel_asgi.OpenTelemetryMiddleware(
181236
http_app_with_custom_headers,

instrumentation/opentelemetry-instrumentation-flask/tests/base_test.py

+11
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ def _custom_response_headers():
8888
resp.headers["my-secret-header"] = "my-secret-value"
8989
return resp
9090

91+
@staticmethod
92+
def _repeat_custom_response_headers():
93+
headers = {
94+
"content-type": "text/plain; charset=utf-8",
95+
"my-custom-header": ["my-custom-value-1", "my-custom-header-2"],
96+
}
97+
return flask.Response("test response", headers=headers)
98+
9199
def _common_initialization(self):
92100
def excluded_endpoint():
93101
return "excluded"
@@ -106,6 +114,9 @@ def excluded2_endpoint():
106114
self.app.route("/test_custom_response_headers")(
107115
self._custom_response_headers
108116
)
117+
self.app.route("/test_repeat_custom_response_headers")(
118+
self._repeat_custom_response_headers
119+
)
109120

110121
# pylint: disable=attribute-defined-outside-init
111122
self.client = Client(self.app, Response)

instrumentation/opentelemetry-instrumentation-flask/tests/test_programmatic.py

+31
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,22 @@ def test_custom_request_header_added_in_server_span(self):
671671
self.assertEqual(span.kind, trace.SpanKind.SERVER)
672672
self.assertSpanHasAttributes(span, expected)
673673

674+
def test_repeat_custom_request_header_added_in_server_span(self):
675+
headers = [
676+
("Custom-Test-Header-1", "Test Value 1"),
677+
("Custom-Test-Header-1", "Test Value 2"),
678+
]
679+
resp = self.client.get("/hello/123", headers=headers)
680+
self.assertEqual(200, resp.status_code)
681+
span = self.memory_exporter.get_finished_spans()[0]
682+
expected = {
683+
"http.request.header.custom_test_header_1": (
684+
"Test Value 1, Test Value 2",
685+
),
686+
}
687+
self.assertEqual(span.kind, trace.SpanKind.SERVER)
688+
self.assertSpanHasAttributes(span, expected)
689+
674690
def test_custom_request_header_not_added_in_internal_span(self):
675691
tracer = trace.get_tracer(__name__)
676692
with tracer.start_as_current_span("test", kind=trace.SpanKind.SERVER):
@@ -724,6 +740,21 @@ def test_custom_response_header_added_in_server_span(self):
724740
self.assertEqual(span.kind, trace.SpanKind.SERVER)
725741
self.assertSpanHasAttributes(span, expected)
726742

743+
def test_repeat_custom_response_header_added_in_server_span(self):
744+
resp = self.client.get("/test_repeat_custom_response_headers")
745+
self.assertEqual(resp.status_code, 200)
746+
span = self.memory_exporter.get_finished_spans()[0]
747+
expected = {
748+
"http.response.header.content_type": (
749+
"text/plain; charset=utf-8",
750+
),
751+
"http.response.header.my_custom_header": (
752+
"my-custom-value-1,my-custom-header-2",
753+
),
754+
}
755+
self.assertEqual(span.kind, trace.SpanKind.SERVER)
756+
self.assertSpanHasAttributes(span, expected)
757+
727758
def test_custom_response_header_not_added_in_internal_span(self):
728759
tracer = trace.get_tracer(__name__)
729760
with tracer.start_as_current_span("test", kind=trace.SpanKind.SERVER):

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

+6-2
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,6 @@ def collect_custom_request_headers_attributes(environ):
358358
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
359359
)
360360
)
361-
362361
headers = {
363362
key[_CARRIER_KEY_PREFIX_LEN:].replace("_", "-"): val
364363
for key, val in environ.items()
@@ -387,7 +386,12 @@ def collect_custom_response_headers_attributes(response_headers):
387386
)
388387
response_headers_dict = {}
389388
if response_headers:
390-
response_headers_dict = dict(response_headers)
389+
for key, val in response_headers:
390+
key = key.lower()
391+
if key in response_headers_dict:
392+
response_headers_dict[key] += "," + val
393+
else:
394+
response_headers_dict[key] = val
391395

392396
return sanitize.sanitize_header_values(
393397
response_headers_dict,

instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py

+32
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,18 @@ def wsgi_with_custom_response_headers(environ, start_response):
115115
return [b"*"]
116116

117117

118+
def wsgi_with_repeat_custom_response_headers(environ, start_response):
119+
assert isinstance(environ, dict)
120+
start_response(
121+
"200 OK",
122+
[
123+
("my-custom-header", "my-custom-value-1"),
124+
("my-custom-header", "my-custom-value-2"),
125+
],
126+
)
127+
return [b"*"]
128+
129+
118130
_expected_metric_names = [
119131
"http.server.active_requests",
120132
"http.server.duration",
@@ -711,6 +723,26 @@ def test_custom_response_headers_not_added_in_internal_span(self):
711723
for key, _ in not_expected.items():
712724
self.assertNotIn(key, span.attributes)
713725

726+
@mock.patch.dict(
727+
"os.environ",
728+
{
729+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "my-custom-header",
730+
},
731+
)
732+
def test_repeat_custom_response_headers_added_in_server_span(self):
733+
app = otel_wsgi.OpenTelemetryMiddleware(
734+
wsgi_with_repeat_custom_response_headers
735+
)
736+
response = app(self.environ, self.start_response)
737+
self.iterate_response(response)
738+
span = self.memory_exporter.get_finished_spans()[0]
739+
expected = {
740+
"http.response.header.my_custom_header": (
741+
"my-custom-value-1,my-custom-value-2",
742+
),
743+
}
744+
self.assertSpanHasAttributes(span, expected)
745+
714746

715747
if __name__ == "__main__":
716748
unittest.main()

0 commit comments

Comments
 (0)