diff --git a/.changelog/5322.fixed b/.changelog/5322.fixed new file mode 100644 index 0000000000..26056675fc --- /dev/null +++ b/.changelog/5322.fixed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-aiohttp-server`: capture all values of repeated headers diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py index 99df218557..c7422d7bc0 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/src/opentelemetry/instrumentation/aiohttp_server/__init__.py @@ -150,7 +150,7 @@ async def hello(request): from timeit import default_timer from aiohttp import web -from multidict import CIMultiDictProxy +from multidict import CIMultiDictProxy, MultiMapping from opentelemetry import metrics, trace from opentelemetry.instrumentation._semconv import ( @@ -334,6 +334,13 @@ def collect_request_attributes( return result +def _get_header_values(headers: MultiMapping[str]) -> dict[str, list[str]]: + values: dict[str, list[str]] = {} + for key, value in headers.items(): + values.setdefault(key.lower(), []).append(value) + return values + + def collect_request_headers_attributes( request: web.Request, ) -> dict[str, list[str]]: @@ -344,7 +351,7 @@ def collect_request_headers_attributes( sanitize = SanitizeValue(get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS)) return sanitize.sanitize_header_values( - request.headers, + _get_header_values(request.headers), captured_headers, normalise_request_header_name, ) @@ -353,11 +360,15 @@ def collect_request_headers_attributes( def collect_response_headers_attributes( response: web.Response, ) -> dict[str, list[str]]: + captured_headers = get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE) + if not captured_headers: + return {} + sanitize = SanitizeValue(get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS)) return sanitize.sanitize_header_values( - response.headers, - get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE), + _get_header_values(response.headers), + captured_headers, normalise_response_header_name, ) diff --git a/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py b/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py index a49c72e4cc..3313b85cd3 100644 --- a/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py +++ b/instrumentation/opentelemetry-instrumentation-aiohttp-server/tests/test_aiohttp_server_integration.py @@ -561,6 +561,38 @@ async def test_custom_request_headers_control_sampling( assert len(exporter.get_finished_spans()) == expected_span_count +@pytest.mark.asyncio +async def test_custom_request_headers_repeated(test_base: TestBase, aiohttp_server, monkeypatch): + expected = { + "http.request.header.custom_test_header_1": ["test-header-value-1", "test-header-value-2"], + "http.request.header.my_secret_header": ["[REDACTED]", "[REDACTED]"], + } + sampler = CapturingSampler() + server, exporter = await _create_server_with_sampler(aiohttp_server, monkeypatch, sampler) + + try: + url = f"http://{server.host}:{server.port}/status/200" + async with aiohttp.ClientSession() as session: + headers = CIMultiDict( + [ + ("custom-test-header-1", "test-header-value-1"), + ("custom-test-header-1", "test-header-value-2"), + ("My-Secret-Header", "My Secret Value 1"), + ("My-Secret-Header", "My Secret Value 2"), + ] + ) + async with session.get(url, headers=headers) as response: + assert response.status == 200 + finally: + AioHttpServerInstrumentor().uninstrument() + + assert _request_header_attributes(sampler.attributes) == expected + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert _request_header_attributes(spans[0].attributes) == {key: tuple(value) for key, value in expected.items()} + + @pytest.mark.asyncio async def test_custom_response_headers(test_base: TestBase, aiohttp_server, monkeypatch): monkeypatch.setenv( @@ -617,6 +649,46 @@ async def handler(request): AioHttpServerInstrumentor().uninstrument() +@pytest.mark.asyncio +async def test_custom_response_headers_repeated(test_base: TestBase, aiohttp_server, monkeypatch): + monkeypatch.setenv( + OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, + "Custom-Test-Header-1", + ) + AioHttpServerInstrumentor().instrument() + + app = aiohttp.web.Application() + + async def handler(request): + headers = CIMultiDict( + [ + ("custom-test-header-1", "test-header-value-1"), + ("Custom-Test-Header-1", "test-header-value-2"), + ] + ) + return aiohttp.web.Response(text="hello", headers=headers) + + app.router.add_get("/status/200", handler) + + server = await aiohttp_server(app) + + url = f"http://{server.host}:{server.port}/status/200" + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + assert response.status == 200 + assert await response.text() == "hello" + + spans = test_base.get_finished_spans() + assert len(spans) == 1 + + assert spans[0].attributes.get("http.response.header.custom_test_header_1") == ( + "test-header-value-1", + "test-header-value-2", + ) + + AioHttpServerInstrumentor().uninstrument() + + # pylint: disable=too-many-locals @pytest.mark.asyncio async def test_semantic_conventions_metrics_old_default(test_base: TestBase, aiohttp_server, monkeypatch):