feat: configure header extraction for ASGI middleware via constructor params (#2026)

* feat: configure header extraction for ASGI middleware via constructor params

* fix django middleware

* lint

* remove import

* Fix lint

* Update instrumentation/opentelemetry-instrumentation-asgi/src/opentelemetry/instrumentation/asgi/__init__.py
This commit is contained in:
Adrian Garcia Badaracco 2024-01-31 00:11:00 -03:00 committed by GitHub
parent a93bd74dc3
commit 4b1a9c75db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 209 additions and 99 deletions

View File

@ -48,6 +48,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
([#1948](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1948)) ([#1948](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1948))
- Added schema_url (`"https://opentelemetry.io/schemas/1.11.0"`) to all metrics and traces - Added schema_url (`"https://opentelemetry.io/schemas/1.11.0"`) to all metrics and traces
([#1977](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1977)) ([#1977](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1977))
- Add support for configuring ASGI middleware header extraction via runtime constructor parameters
([#2026](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2026))
### Fixed ### Fixed

View File

@ -189,11 +189,13 @@ API
--- ---
""" """
from __future__ import annotations
import typing import typing
import urllib import urllib
from functools import wraps from functools import wraps
from timeit import default_timer from timeit import default_timer
from typing import Tuple from typing import Any, Awaitable, Callable, Tuple, cast
from asgiref.compatibility import guarantee_single_callable from asgiref.compatibility import guarantee_single_callable
@ -332,55 +334,28 @@ def collect_request_attributes(scope):
return result return result
def collect_custom_request_headers_attributes(scope): def collect_custom_headers_attributes(
"""returns custom HTTP request headers to be added into SERVER span as span attributes scope_or_response_message: dict[str, Any],
Refer specification https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers sanitize: SanitizeValue,
header_regexes: list[str],
normalize_names: Callable[[str], str],
) -> dict[str, str]:
""" """
Returns custom HTTP request or response headers to be added into SERVER span as span attributes.
sanitize = SanitizeValue( Refer specifications:
get_custom_headers( - https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS """
)
)
# Decode headers before processing. # Decode headers before processing.
headers = { headers: dict[str, str] = {
_key.decode("utf8"): _value.decode("utf8") _key.decode("utf8"): _value.decode("utf8")
for (_key, _value) in scope.get("headers") for (_key, _value) in scope_or_response_message.get("headers")
or cast("list[tuple[bytes, bytes]]", [])
} }
return sanitize.sanitize_header_values( return sanitize.sanitize_header_values(
headers, headers,
get_custom_headers( header_regexes,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST normalize_names,
),
normalise_request_header_name,
)
def collect_custom_response_headers_attributes(message):
"""returns custom HTTP response headers to be added into SERVER span as span attributes
Refer specification https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
"""
sanitize = SanitizeValue(
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
)
)
# Decode headers before processing.
headers = {
_key.decode("utf8"): _value.decode("utf8")
for (_key, _value) in message.get("headers")
}
return sanitize.sanitize_header_values(
headers,
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
),
normalise_response_header_name,
) )
@ -493,6 +468,9 @@ class OpenTelemetryMiddleware:
tracer_provider=None, tracer_provider=None,
meter_provider=None, meter_provider=None,
meter=None, meter=None,
http_capture_headers_server_request: list[str] | None = None,
http_capture_headers_server_response: list[str] | None = None,
http_capture_headers_sanitize_fields: list[str] | None = None,
): ):
self.app = guarantee_single_callable(app) self.app = guarantee_single_callable(app)
self.tracer = trace.get_tracer( self.tracer = trace.get_tracer(
@ -540,7 +518,41 @@ class OpenTelemetryMiddleware:
self.client_response_hook = client_response_hook self.client_response_hook = client_response_hook
self.content_length_header = None self.content_length_header = None
async def __call__(self, scope, receive, send): # Environment variables as constructor parameters
self.http_capture_headers_server_request = (
http_capture_headers_server_request
or (
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
)
)
or None
)
self.http_capture_headers_server_response = (
http_capture_headers_server_response
or (
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
)
)
or None
)
self.http_capture_headers_sanitize_fields = SanitizeValue(
http_capture_headers_sanitize_fields
or (
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
)
)
or []
)
async def __call__(
self,
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
"""The ASGI application """The ASGI application
Args: Args:
@ -583,7 +595,14 @@ class OpenTelemetryMiddleware:
if current_span.kind == trace.SpanKind.SERVER: if current_span.kind == trace.SpanKind.SERVER:
custom_attributes = ( custom_attributes = (
collect_custom_request_headers_attributes(scope) collect_custom_headers_attributes(
scope,
self.http_capture_headers_sanitize_fields,
self.http_capture_headers_server_request,
normalise_request_header_name,
)
if self.http_capture_headers_server_request
else {}
) )
if len(custom_attributes) > 0: if len(custom_attributes) > 0:
current_span.set_attributes(custom_attributes) current_span.set_attributes(custom_attributes)
@ -658,7 +677,7 @@ class OpenTelemetryMiddleware:
expecting_trailers = False expecting_trailers = False
@wraps(send) @wraps(send)
async def otel_send(message): async def otel_send(message: dict[str, Any]):
nonlocal expecting_trailers nonlocal expecting_trailers
with self.tracer.start_as_current_span( with self.tracer.start_as_current_span(
" ".join((server_span_name, scope["type"], "send")) " ".join((server_span_name, scope["type"], "send"))
@ -685,7 +704,14 @@ class OpenTelemetryMiddleware:
and "headers" in message and "headers" in message
): ):
custom_response_attributes = ( custom_response_attributes = (
collect_custom_response_headers_attributes(message) collect_custom_headers_attributes(
message,
self.http_capture_headers_sanitize_fields,
self.http_capture_headers_server_response,
normalise_response_header_name,
)
if self.http_capture_headers_server_response
else {}
) )
if len(custom_response_attributes) > 0: if len(custom_response_attributes) > 0:
server_span.set_attributes( server_span.set_attributes(

View File

@ -1,4 +1,4 @@
from unittest import mock import os
import opentelemetry.instrumentation.asgi as otel_asgi import opentelemetry.instrumentation.asgi as otel_asgi
from opentelemetry.test.asgitestutil import AsgiTestBase from opentelemetry.test.asgitestutil import AsgiTestBase
@ -72,21 +72,22 @@ async def websocket_app_with_custom_headers(scope, receive, send):
break break
@mock.patch.dict(
"os.environ",
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
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.*",
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.*",
},
)
class TestCustomHeaders(AsgiTestBase, TestBase): class TestCustomHeaders(AsgiTestBase, TestBase):
constructor_params = {}
__test__ = False
def __init_subclass__(cls) -> None:
if cls is not TestCustomHeaders:
cls.__test__ = True
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.tracer_provider, self.exporter = TestBase.create_tracer_provider() self.tracer_provider, self.exporter = TestBase.create_tracer_provider()
self.tracer = self.tracer_provider.get_tracer(__name__) self.tracer = self.tracer_provider.get_tracer(__name__)
self.app = otel_asgi.OpenTelemetryMiddleware( self.app = otel_asgi.OpenTelemetryMiddleware(
simple_asgi, tracer_provider=self.tracer_provider simple_asgi,
tracer_provider=self.tracer_provider,
**self.constructor_params,
) )
def test_http_custom_request_headers_in_span_attributes(self): def test_http_custom_request_headers_in_span_attributes(self):
@ -148,7 +149,9 @@ class TestCustomHeaders(AsgiTestBase, TestBase):
def test_http_custom_response_headers_in_span_attributes(self): def test_http_custom_response_headers_in_span_attributes(self):
self.app = otel_asgi.OpenTelemetryMiddleware( self.app = otel_asgi.OpenTelemetryMiddleware(
http_app_with_custom_headers, tracer_provider=self.tracer_provider http_app_with_custom_headers,
tracer_provider=self.tracer_provider,
**self.constructor_params,
) )
self.seed_app(self.app) self.seed_app(self.app)
self.send_default_request() self.send_default_request()
@ -175,7 +178,9 @@ class TestCustomHeaders(AsgiTestBase, TestBase):
def test_http_custom_response_headers_not_in_span_attributes(self): def test_http_custom_response_headers_not_in_span_attributes(self):
self.app = otel_asgi.OpenTelemetryMiddleware( self.app = otel_asgi.OpenTelemetryMiddleware(
http_app_with_custom_headers, tracer_provider=self.tracer_provider http_app_with_custom_headers,
tracer_provider=self.tracer_provider,
**self.constructor_params,
) )
self.seed_app(self.app) self.seed_app(self.app)
self.send_default_request() self.send_default_request()
@ -277,6 +282,7 @@ class TestCustomHeaders(AsgiTestBase, TestBase):
self.app = otel_asgi.OpenTelemetryMiddleware( self.app = otel_asgi.OpenTelemetryMiddleware(
websocket_app_with_custom_headers, websocket_app_with_custom_headers,
tracer_provider=self.tracer_provider, tracer_provider=self.tracer_provider,
**self.constructor_params,
) )
self.seed_app(self.app) self.seed_app(self.app)
self.send_input({"type": "websocket.connect"}) self.send_input({"type": "websocket.connect"})
@ -317,6 +323,7 @@ class TestCustomHeaders(AsgiTestBase, TestBase):
self.app = otel_asgi.OpenTelemetryMiddleware( self.app = otel_asgi.OpenTelemetryMiddleware(
websocket_app_with_custom_headers, websocket_app_with_custom_headers,
tracer_provider=self.tracer_provider, tracer_provider=self.tracer_provider,
**self.constructor_params,
) )
self.seed_app(self.app) self.seed_app(self.app)
self.send_input({"type": "websocket.connect"}) self.send_input({"type": "websocket.connect"})
@ -333,3 +340,46 @@ class TestCustomHeaders(AsgiTestBase, TestBase):
if span.kind == SpanKind.SERVER: if span.kind == SpanKind.SERVER:
for key, _ in not_expected.items(): for key, _ in not_expected.items():
self.assertNotIn(key, span.attributes) self.assertNotIn(key, span.attributes)
SANITIZE_FIELDS_TEST_VALUE = ".*my-secret.*"
SERVER_REQUEST_TEST_VALUE = "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,Regex-Test-Header-.*,Regex-Invalid-Test-Header-.*,.*my-secret.*"
SERVER_RESPONSE_TEST_VALUE = "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3,my-custom-regex-header-.*,invalid-regex-header-.*,.*my-secret.*"
class TestCustomHeadersEnv(TestCustomHeaders):
def setUp(self):
os.environ.update(
{
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: SANITIZE_FIELDS_TEST_VALUE,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: SERVER_REQUEST_TEST_VALUE,
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: SERVER_RESPONSE_TEST_VALUE,
}
)
super().setUp()
def tearDown(self):
os.environ.pop(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, None
)
os.environ.pop(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST, None
)
os.environ.pop(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, None
)
super().tearDown()
class TestCustomHeadersConstructor(TestCustomHeaders):
constructor_params = {
"http_capture_headers_sanitize_fields": SANITIZE_FIELDS_TEST_VALUE.split(
","
),
"http_capture_headers_server_request": SERVER_REQUEST_TEST_VALUE.split(
","
),
"http_capture_headers_server_response": SERVER_RESPONSE_TEST_VALUE.split(
","
),
}

View File

@ -983,18 +983,16 @@ class TestAsgiApplicationRaisingError(AsgiTestBase):
def tearDown(self): def tearDown(self):
pass pass
@mock.patch( def test_asgi_issue_1883(self):
"opentelemetry.instrumentation.asgi.collect_custom_request_headers_attributes",
side_effect=ValueError("whatever"),
)
def test_asgi_issue_1883(
self, mock_collect_custom_request_headers_attributes
):
""" """
Test that exception UnboundLocalError local variable 'start' referenced before assignment is not raised Test that exception UnboundLocalError local variable 'start' referenced before assignment is not raised
See https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1883 See https://github.com/open-telemetry/opentelemetry-python-contrib/issues/1883
""" """
app = otel_asgi.OpenTelemetryMiddleware(simple_asgi)
async def bad_app(_scope, _receive, _send):
raise ValueError("whatever")
app = otel_asgi.OpenTelemetryMiddleware(bad_app)
self.seed_app(app) self.seed_app(app)
self.send_default_request() self.send_default_request()
try: try:

View File

@ -43,10 +43,17 @@ from opentelemetry.instrumentation.wsgi import wsgi_getter
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
from opentelemetry.trace import Span, SpanKind, use_span from opentelemetry.trace import Span, SpanKind, use_span
from opentelemetry.util.http import ( 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,
_parse_active_request_count_attrs, _parse_active_request_count_attrs,
_parse_duration_attrs, _parse_duration_attrs,
get_custom_headers,
get_excluded_urls, get_excluded_urls,
get_traced_request_attrs, get_traced_request_attrs,
normalise_request_header_name,
normalise_response_header_name,
) )
try: try:
@ -91,10 +98,7 @@ else:
try: try:
from opentelemetry.instrumentation.asgi import asgi_getter, asgi_setter from opentelemetry.instrumentation.asgi import asgi_getter, asgi_setter
from opentelemetry.instrumentation.asgi import ( from opentelemetry.instrumentation.asgi import (
collect_custom_request_headers_attributes as asgi_collect_custom_request_attributes, collect_custom_headers_attributes as asgi_collect_custom_headers_attributes,
)
from opentelemetry.instrumentation.asgi import (
collect_custom_response_headers_attributes as asgi_collect_custom_response_attributes,
) )
from opentelemetry.instrumentation.asgi import ( from opentelemetry.instrumentation.asgi import (
collect_request_attributes as asgi_collect_request_attributes, collect_request_attributes as asgi_collect_request_attributes,
@ -108,7 +112,6 @@ except ImportError:
set_status_code = None set_status_code = None
_is_asgi_supported = False _is_asgi_supported = False
_logger = getLogger(__name__) _logger = getLogger(__name__)
_attributes_by_preference = [ _attributes_by_preference = [
[ [
@ -249,7 +252,18 @@ class _DjangoMiddleware(MiddlewareMixin):
) )
if span.is_recording() and span.kind == SpanKind.SERVER: if span.is_recording() and span.kind == SpanKind.SERVER:
attributes.update( attributes.update(
asgi_collect_custom_request_attributes(carrier) asgi_collect_custom_headers_attributes(
carrier,
SanitizeValue(
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
)
),
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
),
normalise_request_header_name,
)
) )
else: else:
if span.is_recording() and span.kind == SpanKind.SERVER: if span.is_recording() and span.kind == SpanKind.SERVER:
@ -336,8 +350,17 @@ class _DjangoMiddleware(MiddlewareMixin):
for key, value in response.items(): for key, value in response.items():
asgi_setter.set(custom_headers, key, value) asgi_setter.set(custom_headers, key, value)
custom_res_attributes = ( custom_res_attributes = asgi_collect_custom_headers_attributes(
asgi_collect_custom_response_attributes(custom_headers) custom_headers,
SanitizeValue(
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
)
),
get_custom_headers(
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
),
normalise_response_header_name,
) )
for key, value in custom_res_attributes.items(): for key, value in custom_res_attributes.items():
span.set_attribute(key, value) span.set_attribute(key, value)

View File

@ -467,6 +467,7 @@ class TestBaseWithCustomHeaders(TestBase):
return app return app
class TestHTTPAppWithCustomHeaders(TestBaseWithCustomHeaders):
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{ {
@ -475,7 +476,9 @@ class TestBaseWithCustomHeaders(TestBase):
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.*", 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.*",
}, },
) )
class TestHTTPAppWithCustomHeaders(TestBaseWithCustomHeaders): def setUp(self) -> None:
super().setUp()
def test_custom_request_headers_in_span_attributes(self): def test_custom_request_headers_in_span_attributes(self):
expected = { expected = {
"http.request.header.custom_test_header_1": ( "http.request.header.custom_test_header_1": (
@ -590,6 +593,7 @@ class TestHTTPAppWithCustomHeaders(TestBaseWithCustomHeaders):
self.assertNotIn(key, server_span.attributes) self.assertNotIn(key, server_span.attributes)
class TestWebSocketAppWithCustomHeaders(TestBaseWithCustomHeaders):
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{ {
@ -598,7 +602,9 @@ class TestHTTPAppWithCustomHeaders(TestBaseWithCustomHeaders):
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.*", 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.*",
}, },
) )
class TestWebSocketAppWithCustomHeaders(TestBaseWithCustomHeaders): def setUp(self) -> None:
super().setUp()
def test_custom_request_headers_in_span_attributes(self): def test_custom_request_headers_in_span_attributes(self):
expected = { expected = {
"http.request.header.custom_test_header_1": ( "http.request.header.custom_test_header_1": (

View File

@ -12,11 +12,13 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from __future__ import annotations
from os import environ from os import environ
from re import IGNORECASE as RE_IGNORECASE from re import IGNORECASE as RE_IGNORECASE
from re import compile as re_compile from re import compile as re_compile
from re import search from re import search
from typing import Iterable, List, Optional from typing import Callable, Iterable, Optional
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.semconv.trace import SpanAttributes
@ -84,9 +86,12 @@ class SanitizeValue:
) )
def sanitize_header_values( def sanitize_header_values(
self, headers: dict, header_regexes: list, normalize_function: callable self,
) -> dict: headers: dict[str, str],
values = {} header_regexes: list[str],
normalize_function: Callable[[str], str],
) -> dict[str, str]:
values: dict[str, str] = {}
if header_regexes: if header_regexes:
header_regexes_compiled = re_compile( header_regexes_compiled = re_compile(
@ -216,14 +221,14 @@ def sanitize_method(method: Optional[str]) -> Optional[str]:
return "UNKNOWN" return "UNKNOWN"
def get_custom_headers(env_var: str) -> List[str]: def get_custom_headers(env_var: str) -> list[str]:
custom_headers = environ.get(env_var, []) custom_headers = environ.get(env_var, None)
if custom_headers: if custom_headers:
custom_headers = [ return [
custom_headers.strip() custom_headers.strip()
for custom_headers in custom_headers.split(",") for custom_headers in custom_headers.split(",")
] ]
return custom_headers return []
def _parse_active_request_count_attrs(req_attrs): def _parse_active_request_count_attrs(req_attrs):