Skip to content

Commit 24ed943

Browse files
committed
feat: configure header extraction for ASGI middleware via constructor params
1 parent 3478831 commit 24ed943

File tree

7 files changed

+136
-72
lines changed

7 files changed

+136
-72
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
### Added
1212
- `opentelemetry-instrumentation-system-metrics` Add support for collecting process metrics
1313
([#1948](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1948))
14+
- Add support for configuring ASGI middleware header extraction via runtime constructor parameters
15+
([#2026](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2026))
1416

1517
### Fixed
1618

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

Lines changed: 46 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,13 @@ def client_response_hook(span: Span, message: dict):
189189
---
190190
"""
191191

192+
from __future__ import annotations
193+
192194
import typing
193195
import urllib
194196
from functools import wraps
195197
from timeit import default_timer
196-
from typing import Tuple
198+
from typing import Any, Awaitable, Callable, Tuple
197199

198200
from asgiref.compatibility import guarantee_single_callable
199201

@@ -332,55 +334,23 @@ def collect_request_attributes(scope):
332334
return result
333335

334336

335-
def collect_custom_request_headers_attributes(scope):
336-
"""returns custom HTTP request headers to be added into SERVER span as span attributes
337-
Refer specification https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
337+
def collect_custom_headers_attributes(scope_or_response_message: dict[str, Any], sanitize: SanitizeValue, header_regexes: list[str], normalize_names: Callable[[str], str]) -> dict[str, str]:
338338
"""
339+
Returns custom HTTP request or response headers to be added into SERVER span as span attributes.
339340
340-
sanitize = SanitizeValue(
341-
get_custom_headers(
342-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
343-
)
344-
)
345-
346-
# Decode headers before processing.
347-
headers = {
348-
_key.decode("utf8"): _value.decode("utf8")
349-
for (_key, _value) in scope.get("headers")
350-
}
351-
352-
return sanitize.sanitize_header_values(
353-
headers,
354-
get_custom_headers(
355-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
356-
),
357-
normalise_request_header_name,
358-
)
359-
360-
361-
def collect_custom_response_headers_attributes(message):
362-
"""returns custom HTTP response headers to be added into SERVER span as span attributes
363-
Refer specification https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
341+
Refer specifications:
342+
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
343+
- https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers
364344
"""
365-
366-
sanitize = SanitizeValue(
367-
get_custom_headers(
368-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
369-
)
370-
)
371-
372345
# Decode headers before processing.
373-
headers = {
346+
headers: dict[str, str] = {
374347
_key.decode("utf8"): _value.decode("utf8")
375-
for (_key, _value) in message.get("headers")
348+
for (_key, _value) in scope_or_response_message.get("headers") or {}
376349
}
377-
378350
return sanitize.sanitize_header_values(
379351
headers,
380-
get_custom_headers(
381-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
382-
),
383-
normalise_response_header_name,
352+
header_regexes,
353+
normalize_names,
384354
)
385355

386356

@@ -493,6 +463,9 @@ def __init__(
493463
tracer_provider=None,
494464
meter_provider=None,
495465
meter=None,
466+
http_capture_headers_server_request : list[str] | None = None,
467+
http_capture_headers_server_response: list[str] | None = None,
468+
http_capture_headers_sanitize_fields: list[str] | None = None
496469
):
497470
self.app = guarantee_single_callable(app)
498471
self.tracer = trace.get_tracer(__name__, __version__, tracer_provider)
@@ -530,7 +503,20 @@ def __init__(
530503
self.client_response_hook = client_response_hook
531504
self.content_length_header = None
532505

533-
async def __call__(self, scope, receive, send):
506+
# Environment variables as constructor parameters
507+
self.http_capture_headers_server_request = http_capture_headers_server_request or (
508+
get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST)
509+
) or None
510+
self.http_capture_headers_server_response = http_capture_headers_server_response or (
511+
get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE)
512+
) or None
513+
self.http_capture_headers_sanitize_fields = SanitizeValue(
514+
http_capture_headers_sanitize_fields or (
515+
get_custom_headers(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS)
516+
) or []
517+
)
518+
519+
async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
534520
"""The ASGI application
535521
536522
Args:
@@ -573,7 +559,14 @@ async def __call__(self, scope, receive, send):
573559

574560
if current_span.kind == trace.SpanKind.SERVER:
575561
custom_attributes = (
576-
collect_custom_request_headers_attributes(scope)
562+
collect_custom_headers_attributes(
563+
scope,
564+
self.http_capture_headers_sanitize_fields,
565+
self.http_capture_headers_server_request,
566+
normalise_request_header_name,
567+
)
568+
if self.http_capture_headers_server_request
569+
else {}
577570
)
578571
if len(custom_attributes) > 0:
579572
current_span.set_attributes(custom_attributes)
@@ -644,7 +637,7 @@ def _get_otel_send(
644637
self, server_span, server_span_name, scope, send, duration_attrs
645638
):
646639
@wraps(send)
647-
async def otel_send(message):
640+
async def otel_send(message: dict[str, Any]) -> None:
648641
with self.tracer.start_as_current_span(
649642
" ".join((server_span_name, scope["type"], "send"))
650643
) as send_span:
@@ -668,7 +661,14 @@ async def otel_send(message):
668661
and "headers" in message
669662
):
670663
custom_response_attributes = (
671-
collect_custom_response_headers_attributes(message)
664+
collect_custom_headers_attributes(
665+
message,
666+
self.http_capture_headers_sanitize_fields,
667+
self.http_capture_headers_server_response,
668+
normalise_response_header_name,
669+
)
670+
if self.http_capture_headers_server_response
671+
else {}
672672
)
673673
if len(custom_response_attributes) > 0:
674674
server_span.set_attributes(

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from unittest import mock
23

34
import opentelemetry.instrumentation.asgi as otel_asgi
@@ -72,21 +73,20 @@ async def websocket_app_with_custom_headers(scope, receive, send):
7273
break
7374

7475

75-
@mock.patch.dict(
76-
"os.environ",
77-
{
78-
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: ".*my-secret.*",
79-
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.*",
80-
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.*",
81-
},
82-
)
8376
class TestCustomHeaders(AsgiTestBase, TestBase):
77+
constructor_params = {}
78+
__test__ = False
79+
80+
def __init_subclass__(cls) -> None:
81+
if cls is not TestCustomHeaders:
82+
cls.__test__ = True
83+
8484
def setUp(self):
8585
super().setUp()
8686
self.tracer_provider, self.exporter = TestBase.create_tracer_provider()
8787
self.tracer = self.tracer_provider.get_tracer(__name__)
8888
self.app = otel_asgi.OpenTelemetryMiddleware(
89-
simple_asgi, tracer_provider=self.tracer_provider
89+
simple_asgi, tracer_provider=self.tracer_provider, **self.constructor_params,
9090
)
9191

9292
def test_http_custom_request_headers_in_span_attributes(self):
@@ -148,7 +148,7 @@ def test_http_custom_request_headers_not_in_span_attributes(self):
148148

149149
def test_http_custom_response_headers_in_span_attributes(self):
150150
self.app = otel_asgi.OpenTelemetryMiddleware(
151-
http_app_with_custom_headers, tracer_provider=self.tracer_provider
151+
http_app_with_custom_headers, tracer_provider=self.tracer_provider, **self.constructor_params,
152152
)
153153
self.seed_app(self.app)
154154
self.send_default_request()
@@ -175,7 +175,7 @@ def test_http_custom_response_headers_in_span_attributes(self):
175175

176176
def test_http_custom_response_headers_not_in_span_attributes(self):
177177
self.app = otel_asgi.OpenTelemetryMiddleware(
178-
http_app_with_custom_headers, tracer_provider=self.tracer_provider
178+
http_app_with_custom_headers, tracer_provider=self.tracer_provider, **self.constructor_params,
179179
)
180180
self.seed_app(self.app)
181181
self.send_default_request()
@@ -277,6 +277,7 @@ def test_websocket_custom_response_headers_in_span_attributes(self):
277277
self.app = otel_asgi.OpenTelemetryMiddleware(
278278
websocket_app_with_custom_headers,
279279
tracer_provider=self.tracer_provider,
280+
**self.constructor_params,
280281
)
281282
self.seed_app(self.app)
282283
self.send_input({"type": "websocket.connect"})
@@ -317,6 +318,7 @@ def test_websocket_custom_response_headers_not_in_span_attributes(self):
317318
self.app = otel_asgi.OpenTelemetryMiddleware(
318319
websocket_app_with_custom_headers,
319320
tracer_provider=self.tracer_provider,
321+
**self.constructor_params,
320322
)
321323
self.seed_app(self.app)
322324
self.send_input({"type": "websocket.connect"})
@@ -333,3 +335,34 @@ def test_websocket_custom_response_headers_not_in_span_attributes(self):
333335
if span.kind == SpanKind.SERVER:
334336
for key, _ in not_expected.items():
335337
self.assertNotIn(key, span.attributes)
338+
339+
340+
341+
SANITIZE_FIELDS_TEST_VALUE = ".*my-secret.*"
342+
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.*"
343+
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.*"
344+
345+
class TestCustomHeadersEnv(TestCustomHeaders):
346+
def setUp(self):
347+
os.environ.update(
348+
{
349+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS: SANITIZE_FIELDS_TEST_VALUE,
350+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: SERVER_REQUEST_TEST_VALUE,
351+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: SERVER_RESPONSE_TEST_VALUE,
352+
}
353+
)
354+
super().setUp()
355+
356+
def tearDown(self):
357+
os.environ.pop(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS, None)
358+
os.environ.pop(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST, None)
359+
os.environ.pop(OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE, None)
360+
super().tearDown()
361+
362+
363+
class TestCustomHeadersConstructor(TestCustomHeaders):
364+
constructor_params = {
365+
"http_capture_headers_sanitize_fields": SANITIZE_FIELDS_TEST_VALUE.split(","),
366+
"http_capture_headers_server_request": SERVER_REQUEST_TEST_VALUE.split(","),
367+
"http_capture_headers_server_response": SERVER_RESPONSE_TEST_VALUE.split(","),
368+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@ def tearDown(self):
802802
pass
803803

804804
@mock.patch(
805-
"opentelemetry.instrumentation.asgi.collect_custom_request_headers_attributes",
805+
"opentelemetry.instrumentation.asgi.collect_custom_headers_attributes",
806806
side_effect=ValueError("whatever"),
807807
)
808808
def test_asgi_issue_1883(

instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware/otel_middleware.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,16 @@
4343
from opentelemetry.semconv.trace import SpanAttributes
4444
from opentelemetry.trace import Span, SpanKind, use_span
4545
from opentelemetry.util.http import (
46+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS,
47+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
48+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
49+
SanitizeValue,
4650
_parse_active_request_count_attrs,
4751
_parse_duration_attrs,
52+
get_custom_headers,
4853
get_excluded_urls,
4954
get_traced_request_attrs,
55+
normalise_request_header_name,
5056
)
5157

5258
try:
@@ -91,10 +97,7 @@ def __call__(self, request):
9197
try:
9298
from opentelemetry.instrumentation.asgi import asgi_getter, asgi_setter
9399
from opentelemetry.instrumentation.asgi import (
94-
collect_custom_request_headers_attributes as asgi_collect_custom_request_attributes,
95-
)
96-
from opentelemetry.instrumentation.asgi import (
97-
collect_custom_response_headers_attributes as asgi_collect_custom_response_attributes,
100+
collect_custom_headers_attributes as asgi_collect_custom_headers_attributes,
98101
)
99102
from opentelemetry.instrumentation.asgi import (
100103
collect_request_attributes as asgi_collect_request_attributes,
@@ -249,7 +252,18 @@ def process_request(self, request):
249252
)
250253
if span.is_recording() and span.kind == SpanKind.SERVER:
251254
attributes.update(
252-
asgi_collect_custom_request_attributes(carrier)
255+
asgi_collect_custom_headers_attributes(
256+
carrier,
257+
SanitizeValue(
258+
get_custom_headers(
259+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
260+
)
261+
),
262+
get_custom_headers(
263+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
264+
),
265+
normalise_request_header_name,
266+
)
253267
)
254268
else:
255269
if span.is_recording() and span.kind == SpanKind.SERVER:
@@ -337,7 +351,18 @@ def process_response(self, request, response):
337351
asgi_setter.set(custom_headers, key, value)
338352

339353
custom_res_attributes = (
340-
asgi_collect_custom_response_attributes(custom_headers)
354+
asgi_collect_custom_headers_attributes(
355+
custom_headers,
356+
SanitizeValue(
357+
get_custom_headers(
358+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SANITIZE_FIELDS
359+
)
360+
),
361+
get_custom_headers(
362+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
363+
),
364+
normalise_request_header_name,
365+
)
341366
)
342367
for key, value in custom_res_attributes.items():
343368
span.set_attribute(key, value)

instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -537,7 +537,8 @@ async def test_http_custom_response_headers_in_span_attributes(self):
537537
),
538538
"http.response.header.my_secret_header": ("[REDACTED]",),
539539
}
540-
await self.async_client.get("/traced_custom_header/")
540+
resp = await self.async_client.get("/traced_custom_header/")
541+
assert resp.status_code == 200
541542
spans = self.exporter.get_finished_spans()
542543
self.assertEqual(len(spans), 1)
543544

util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from __future__ import annotations
16+
1517
from os import environ
1618
from re import IGNORECASE as RE_IGNORECASE
1719
from re import compile as re_compile
1820
from re import search
19-
from typing import Iterable, List, Optional
21+
from typing import Callable, Iterable, List, Optional
2022
from urllib.parse import urlparse, urlunparse
2123

2224
from opentelemetry.semconv.trace import SpanAttributes
@@ -84,9 +86,9 @@ def sanitize_header_value(self, header: str, value: str) -> str:
8486
)
8587

8688
def sanitize_header_values(
87-
self, headers: dict, header_regexes: list, normalize_function: callable
88-
) -> dict:
89-
values = {}
89+
self, headers: dict[str, str], header_regexes: list[str], normalize_function: Callable[[str], str]
90+
) -> dict[str, str]:
91+
values: dict[str, str] = {}
9092

9193
if header_regexes:
9294
header_regexes_compiled = re_compile(
@@ -201,13 +203,14 @@ def sanitize_method(method: Optional[str]) -> Optional[str]:
201203
return "UNKNOWN"
202204

203205
def get_custom_headers(env_var: str) -> List[str]:
204-
custom_headers = environ.get(env_var, [])
206+
custom_headers: str = environ.get(env_var, '')
205207
if custom_headers:
206-
custom_headers = [
208+
return [
207209
custom_headers.strip()
208210
for custom_headers in custom_headers.split(",")
209211
]
210-
return custom_headers
212+
else:
213+
return []
211214

212215

213216
def _parse_active_request_count_attrs(req_attrs):

0 commit comments

Comments
 (0)