mirror of
https://github.com/open-telemetry/opentelemetry-python-contrib.git
synced 2026-03-13 08:10:39 +08:00
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:
committed by
GitHub
parent
fd5ddf0b9c
commit
9515f0419e
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user