aiohttp-server: add support for headers capture for request and response (#3916)

* Collect request attributes once

* Doen't add request attributes to span if it is not recording

While at it also avoid copying empty dictionaries

* Add support for collecting custom request and response headers

* update type annotations

* Add changelog

* Add PR number in changelog

* Add documentation

* Apply suggestions from code review

Co-authored-by: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com>

---------

Co-authored-by: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com>
This commit is contained in:
Riccardo Magliocchetti
2025-11-04 09:39:25 +01:00
committed by GitHub
parent fd5ddf0b9c
commit 9515f0419e
4 changed files with 297 additions and 17 deletions

View File

@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#3885](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3885))
- `opentelemetry-instrumentation-django`: improve readthedocs for sqlcommenter configuration.
([#3884](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3884))
- `opentelemetry-instrumentation-aiohttp-server`: add support for custom header captures via `OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST` and `OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE`
([#3916](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3916))
### Fixed

View File

@@ -54,11 +54,104 @@ For example,
will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``.
Capture HTTP request and response headers
*****************************************
You can configure the agent to capture specified HTTP headers as span attributes, according to the
`semantic conventions <https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/http-spans.md#http-server-span>`_.
Request headers
***************
To capture HTTP request headers as span attributes, set the environment variable
``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST`` to a comma delimited list of HTTP header names.
For example,
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="content-type,custom_request_header"
will extract ``content-type`` and ``custom_request_header`` from the request headers and add them as span attributes.
Request header names in aiohttp are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment
variable will capture the header named ``custom-header``.
Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example:
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="Accept.*,X-.*"
Would match all request headers that start with ``Accept`` and ``X-``.
To capture all request headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST`` to ``".*"``.
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=".*"
The name of the added span attribute will follow the format ``http.request.header.<header_name>`` where ``<header_name>``
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
list containing the header values.
For example:
``http.request.header.custom_request_header = ["<value1>, <value2>"]``
Response headers
****************
To capture HTTP response headers as span attributes, set the environment variable
``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE`` to a comma delimited list of HTTP header names.
For example,
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="content-type,custom_response_header"
will extract ``content-type`` and ``custom_response_header`` from the response headers and add them as span attributes.
Response header names in aiohttp are case-insensitive. So, giving the header name as ``CUStom-Header`` in the environment
variable will capture the header named ``custom-header``.
Regular expressions may also be used to match multiple headers that correspond to the given pattern. For example:
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="Content.*,X-.*"
Would match all response headers that start with ``Content`` and ``X-``.
To capture all response headers, set ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE`` to ``".*"``.
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE=".*"
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>``
is the normalized HTTP header name (lowercase, with ``-`` replaced by ``_``). The value of the attribute will be a
list containing the header values.
For example:
``http.response.header.custom_response_header = ["<value1>, <value2>"]``
Sanitizing headers
******************
In order to prevent storing sensitive data such as personally identifiable information (PII), session keys, passwords,
etc, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS``
to a comma delimited list of HTTP header names to be sanitized. Regexes may be used, and all header names will be
matched in a case-insensitive manner.
For example,
::
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS=".*session.*,set-cookie"
will replace the value of headers such as ``session-id`` and ``set-cookie`` with ``[REDACTED]`` in the span.
Note:
The environment variable names used to capture HTTP headers are still experimental, and thus are subject to change.
API
---
"""
from __future__ import annotations
import urllib
from timeit import default_timer
from typing import Dict, List, Tuple, Union
from aiohttp import web
from multidict import CIMultiDictProxy
@@ -91,7 +184,17 @@ from opentelemetry.semconv._incubating.attributes.net_attributes import (
)
from opentelemetry.semconv.metrics import MetricInstruments
from opentelemetry.trace.status import Status, StatusCode
from opentelemetry.util.http import get_excluded_urls, redact_url
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
SanitizeValue,
get_custom_headers,
get_excluded_urls,
normalise_request_header_name,
normalise_response_header_name,
redact_url,
)
_duration_attrs = [
HTTP_METHOD,
@@ -134,15 +237,15 @@ def _parse_active_request_count_attrs(req_attrs):
return active_requests_count_attrs
def get_default_span_details(request: web.Request) -> Tuple[str, dict]:
def get_default_span_name(request: web.Request) -> str:
"""Default implementation for get_default_span_details
Args:
request: the request object itself.
Returns:
a tuple of the span name, and any attributes to attach to the span.
The span name.
"""
span_name = request.path.strip() or f"HTTP {request.method}"
return span_name, {}
return span_name
def _get_view_func(request: web.Request) -> str:
@@ -158,7 +261,7 @@ def _get_view_func(request: web.Request) -> str:
return "unknown"
def collect_request_attributes(request: web.Request) -> Dict:
def collect_request_attributes(request: web.Request) -> dict:
"""Collects HTTP request attributes from the ASGI scope and returns a
dictionary to be used as span creation attributes."""
@@ -203,6 +306,42 @@ def collect_request_attributes(request: web.Request) -> Dict:
return result
def collect_request_headers_attributes(
request: web.Request,
) -> dict[str, list[str]]:
sanitize = SanitizeValue(
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
)
)
return sanitize.sanitize_header_values(
request.headers,
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
),
normalise_request_header_name,
)
def collect_response_headers_attributes(
response: web.Response,
) -> dict[str, list[str]]:
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
),
normalise_response_header_name,
)
def set_status_code(span, status_code: int) -> None:
"""Adds HTTP response attributes to span using the status_code argument."""
@@ -225,7 +364,7 @@ def set_status_code(span, status_code: int) -> None:
class AiohttpGetter(Getter):
"""Extract current trace from headers"""
def get(self, carrier, key: str) -> Union[List, None]:
def get(self, carrier, key: str) -> list | None:
"""Getter implementation to retrieve an HTTP header value from the ASGI
scope.
@@ -241,7 +380,7 @@ class AiohttpGetter(Getter):
return None
return headers.getall(key, None)
def keys(self, carrier: Dict) -> List:
def keys(self, carrier: dict) -> list:
return list(carrier.keys())
@@ -256,11 +395,13 @@ async def middleware(request, handler):
):
return await handler(request)
span_name, additional_attributes = get_default_span_details(request)
span_name = get_default_span_name(request)
req_attrs = collect_request_attributes(request)
duration_attrs = _parse_duration_attrs(req_attrs)
active_requests_count_attrs = _parse_active_request_count_attrs(req_attrs)
request_attrs = collect_request_attributes(request)
duration_attrs = _parse_duration_attrs(request_attrs)
active_requests_count_attrs = _parse_active_request_count_attrs(
request_attrs
)
duration_histogram = meter.create_histogram(
name=MetricInstruments.HTTP_SERVER_DURATION,
@@ -279,14 +420,22 @@ async def middleware(request, handler):
context=extract(request, getter=getter),
kind=trace.SpanKind.SERVER,
) as span:
attributes = collect_request_attributes(request)
attributes.update(additional_attributes)
span.set_attributes(attributes)
if span.is_recording():
request_headers_attributes = collect_request_headers_attributes(
request
)
request_attrs.update(request_headers_attributes)
span.set_attributes(request_attrs)
start = default_timer()
active_requests_counter.add(1, active_requests_count_attrs)
try:
resp = await handler(request)
set_status_code(span, resp.status)
if span.is_recording():
response_headers_attributes = (
collect_response_headers_attributes(resp)
)
span.set_attributes(response_headers_attributes)
except web.HTTPException as ex:
set_status_code(span, ex.status_code)
raise

View File

@@ -18,6 +18,7 @@ from http import HTTPStatus
import aiohttp
import pytest
import pytest_asyncio
from multidict import CIMultiDict
from opentelemetry import metrics as metrics_api
from opentelemetry import trace as trace_api
@@ -36,6 +37,11 @@ from opentelemetry.test.globals_test import (
)
from opentelemetry.test.test_base import TestBase
from opentelemetry.util._importlib_metadata import entry_points
from opentelemetry.util.http import (
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
)
class HTTPMethod(Enum):
@@ -55,7 +61,7 @@ class HTTPMethod(Enum):
TRACE = "TRACE"
@pytest.fixture(name="tracer", scope="session")
@pytest.fixture(name="tracer", scope="function")
def fixture_tracer():
test_base = TestBase()
@@ -67,6 +73,7 @@ def fixture_tracer():
yield tracer_provider, memory_exporter
reset_trace_globals()
memory_exporter.clear()
@pytest.fixture(name="meter", scope="function")
@@ -272,3 +279,124 @@ async def test_excluded_urls(
assert len(metrics) == 0
AioHttpServerInstrumentor().uninstrument()
@pytest.mark.asyncio
async def test_custom_request_headers(tracer, aiohttp_server, monkeypatch):
# pylint: disable=too-many-locals
_, memory_exporter = tracer
monkeypatch.setenv(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
".*my-secret.*",
)
monkeypatch.setenv(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
"Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*",
)
AioHttpServerInstrumentor().instrument()
app = aiohttp.web.Application()
async def handler(request):
return aiohttp.web.Response(text="hello")
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:
headers = {
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"Regex-Test-Header-1": "Regex Test Value 1",
"regex-test-header-2": "RegexTestValue2,RegexTestValue3",
"My-Secret-Header": "My Secret Value",
}
async with session.get(url, headers=headers) as response:
assert response.status == 200
assert await response.text() == "hello"
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
expected = {
"http.request.header.custom_test_header_1": ("test-header-value-1",),
"http.request.header.custom_test_header_2": ("test-header-value-2",),
"http.request.header.regex_test_header_1": ("Regex Test Value 1",),
"http.request.header.regex_test_header_2": (
"RegexTestValue2,RegexTestValue3",
),
"http.request.header.my_secret_header": ("[REDACTED]",),
}
for attribute, value in expected.items():
assert span.attributes.get(attribute) == value
assert "http.request.header.custom_test_header_3" not in span.attributes
AioHttpServerInstrumentor().uninstrument()
@pytest.mark.asyncio
async def test_custom_response_headers(tracer, aiohttp_server, monkeypatch):
_, memory_exporter = tracer
monkeypatch.setenv(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
".*my-secret.*",
)
monkeypatch.setenv(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
"Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*",
)
AioHttpServerInstrumentor().instrument()
app = aiohttp.web.Application()
async def handler(request):
headers = CIMultiDict(
**{
"custom-test-header-1": "test-header-value-1",
"custom-test-header-2": "test-header-value-2",
"my-custom-regex-header-1": "my-custom-regex-value-1,my-custom-regex-value-2",
"My-Custom-Regex-Header-2": "my-custom-regex-value-3,my-custom-regex-value-4",
"My-Secret-Header": "My Secret Value",
}
)
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 = memory_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
expected = {
"http.response.header.custom_test_header_1": ("test-header-value-1",),
"http.response.header.custom_test_header_2": ("test-header-value-2",),
"http.response.header.my_custom_regex_header_1": (
"my-custom-regex-value-1,my-custom-regex-value-2",
),
"http.response.header.my_custom_regex_header_2": (
"my-custom-regex-value-3,my-custom-regex-value-4",
),
"http.response.header.my_secret_header": ("[REDACTED]",),
}
for attribute, value in expected.items():
assert span.attributes.get(attribute) == value
assert "http.response.header.custom_test_header_3" not in span.attributes
AioHttpServerInstrumentor().uninstrument()

View File

@@ -150,7 +150,8 @@ class TestUploadCompletionHook(TestCase):
self.hook.shutdown()
@skipIf(
python_implementation().lower() == "pypy", "fails randomly on pypy: https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3812"
python_implementation().lower() == "pypy",
"fails randomly on pypy: https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3812",
)
def test_upload_then_shutdown(self):
self.hook.on_completion(