Skip to content

Commit b1e94d6

Browse files
Django: Capture custom request/response headers (open-telemetry#1024)
1 parent 36ba621 commit b1e94d6

File tree

6 files changed

+332
-8
lines changed

6 files changed

+332
-8
lines changed

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.10.0-0.29b0...HEAD)
99

1010
### Added
11-
11+
- `opentelemetry-instrumentation-django` Capture custom request/response headers in span attributes
12+
([#1024])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1024)
13+
- `opentelemetry-instrumentation-asgi` Capture custom request/response headers in span attributes
14+
([#1004])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1004)
1215
- `opentelemetry-instrumentation-psycopg2` extended the sql commenter support of dbapi into psycopg2
1316
([#940](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/940))
1417
- `opentelemetry-instrumentation-flask` Fix non-recording span bug
@@ -24,9 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2427

2528
## [1.10.0-0.29b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.10.0-0.29b0) - 2022-03-10
2629

27-
- `opentelemetry-instrumentation-wsgi` Capture custom request/response headers in span attributes
28-
([#1004])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1004)
29-
3030
- `opentelemetry-instrumentation-wsgi` Capture custom request/response headers in span attributes
3131
([#925])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/925)
3232
- `opentelemetry-instrumentation-flask` Flask: Capture custom request/response headers in span attributes

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,52 @@ def response_hook(span, request, response):
7676
Django Request object: https://docs.djangoproject.com/en/3.1/ref/request-response/#httprequest-objects
7777
Django Response object: https://docs.djangoproject.com/en/3.1/ref/request-response/#httpresponse-objects
7878
79+
Capture HTTP request and response headers
80+
*****************************************
81+
You can configure the agent to capture predefined HTTP headers as span attributes, according to the `semantic convention <https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers>`_.
82+
83+
Request headers
84+
***************
85+
To capture predefined HTTP request headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST``
86+
to a comma-separated list of HTTP header names.
87+
88+
For example,
89+
::
90+
91+
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="content_type,custom_request_header"
92+
93+
will extract content_type and custom_request_header from request headers and add them as span attributes.
94+
95+
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
96+
Request header names in django are case insensitive. So, giving header name as ``CUStom_Header`` in environment variable will be able capture header with name ``custom-header``.
97+
98+
The name of the added span attribute will follow the format ``http.request.header.<header_name>`` where ``<header_name>`` being the normalized HTTP header name (lowercase, with - characters replaced by _ ).
99+
The value of the attribute will be single item list containing all the header values.
100+
101+
Example of the added span attribute,
102+
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
103+
104+
Response headers
105+
****************
106+
To capture predefined HTTP response headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE``
107+
to a comma-separated list of HTTP header names.
108+
109+
For example,
110+
::
111+
112+
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="content_type,custom_response_header"
113+
114+
will extract content_type and custom_response_header from response headers and add them as span attributes.
115+
116+
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
117+
Response header names captured in django are case insensitive. So, giving header name as ``CUStomHeader`` in environment variable will be able capture header with name ``customheader``.
118+
119+
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>`` being the normalized HTTP header name (lowercase, with - characters replaced by _ ).
120+
The value of the attribute will be single item list containing all the header values.
121+
122+
Example of the added span attribute,
123+
``http.response.header.custom_response_header = ["<value1>,<value2>"]``
124+
79125
API
80126
---
81127
"""

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

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,19 @@
2828
_start_internal_or_server_span,
2929
extract_attributes_from_object,
3030
)
31+
from opentelemetry.instrumentation.wsgi import (
32+
add_custom_request_headers as wsgi_add_custom_request_headers,
33+
)
34+
from opentelemetry.instrumentation.wsgi import (
35+
add_custom_response_headers as wsgi_add_custom_response_headers,
36+
)
3137
from opentelemetry.instrumentation.wsgi import add_response_attributes
3238
from opentelemetry.instrumentation.wsgi import (
3339
collect_request_attributes as wsgi_collect_request_attributes,
3440
)
3541
from opentelemetry.instrumentation.wsgi import wsgi_getter
3642
from opentelemetry.semconv.trace import SpanAttributes
37-
from opentelemetry.trace import Span, use_span
43+
from opentelemetry.trace import Span, SpanKind, use_span
3844
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
3945

4046
try:
@@ -77,7 +83,13 @@ def __call__(self, request):
7783

7884
# try/except block exclusive for optional ASGI imports.
7985
try:
80-
from opentelemetry.instrumentation.asgi import asgi_getter
86+
from opentelemetry.instrumentation.asgi import asgi_getter, asgi_setter
87+
from opentelemetry.instrumentation.asgi import (
88+
collect_custom_request_headers_attributes as asgi_collect_custom_request_attributes,
89+
)
90+
from opentelemetry.instrumentation.asgi import (
91+
collect_custom_response_headers_attributes as asgi_collect_custom_response_attributes,
92+
)
8193
from opentelemetry.instrumentation.asgi import (
8294
collect_request_attributes as asgi_collect_request_attributes,
8395
)
@@ -213,6 +225,13 @@ def process_request(self, request):
213225
self._traced_request_attrs,
214226
attributes,
215227
)
228+
if span.is_recording() and span.kind == SpanKind.SERVER:
229+
attributes.update(
230+
asgi_collect_custom_request_attributes(carrier)
231+
)
232+
else:
233+
if span.is_recording() and span.kind == SpanKind.SERVER:
234+
wsgi_add_custom_request_headers(span, carrier)
216235

217236
for key, value in attributes.items():
218237
span.set_attribute(key, value)
@@ -257,6 +276,7 @@ def process_exception(self, request, exception):
257276
if self._environ_activation_key in request.META.keys():
258277
request.META[self._environ_exception_key] = exception
259278

279+
# pylint: disable=too-many-branches
260280
def process_response(self, request, response):
261281
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
262282
return response
@@ -271,12 +291,25 @@ def process_response(self, request, response):
271291
if activation and span:
272292
if is_asgi_request:
273293
set_status_code(span, response.status_code)
294+
295+
if span.is_recording() and span.kind == SpanKind.SERVER:
296+
custom_headers = {}
297+
for key, value in response.items():
298+
asgi_setter.set(custom_headers, key, value)
299+
300+
custom_res_attributes = (
301+
asgi_collect_custom_response_attributes(custom_headers)
302+
)
303+
for key, value in custom_res_attributes.items():
304+
span.set_attribute(key, value)
274305
else:
275306
add_response_attributes(
276307
span,
277308
f"{response.status_code} {response.reason_phrase}",
278309
response.items(),
279310
)
311+
if span.is_recording() and span.kind == SpanKind.SERVER:
312+
wsgi_add_custom_response_headers(span, response.items())
280313

281314
propagator = get_global_response_propagator()
282315
if propagator:

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

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,20 @@
4343
format_span_id,
4444
format_trace_id,
4545
)
46-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
46+
from opentelemetry.util.http import (
47+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
48+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
49+
get_excluded_urls,
50+
get_traced_request_attrs,
51+
)
4752

4853
# pylint: disable=import-error
4954
from .views import (
5055
error,
5156
excluded,
5257
excluded_noarg,
5358
excluded_noarg2,
59+
response_with_custom_header,
5460
route_span_name,
5561
traced,
5662
traced_template,
@@ -67,6 +73,7 @@
6773

6874
urlpatterns = [
6975
re_path(r"^traced/", traced),
76+
re_path(r"^traced_custom_header/", response_with_custom_header),
7077
re_path(r"^route/(?P<year>[0-9]{4})/template/$", traced_template),
7178
re_path(r"^error/", error),
7279
re_path(r"^excluded_arg/", excluded),
@@ -451,3 +458,107 @@ def test_django_with_wsgi_instrumented(self):
451458
parent_span.get_span_context().span_id,
452459
span_list[0].parent.span_id,
453460
)
461+
462+
463+
class TestMiddlewareWsgiWithCustomHeaders(TestBase, WsgiTestBase):
464+
@classmethod
465+
def setUpClass(cls):
466+
conf.settings.configure(ROOT_URLCONF=modules[__name__])
467+
super().setUpClass()
468+
469+
def setUp(self):
470+
super().setUp()
471+
setup_test_environment()
472+
tracer_provider, exporter = self.create_tracer_provider()
473+
self.exporter = exporter
474+
_django_instrumentor.instrument(tracer_provider=tracer_provider)
475+
self.env_patch = patch.dict(
476+
"os.environ",
477+
{
478+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
479+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
480+
},
481+
)
482+
self.env_patch.start()
483+
484+
def tearDown(self):
485+
super().tearDown()
486+
self.env_patch.stop()
487+
teardown_test_environment()
488+
_django_instrumentor.uninstrument()
489+
490+
@classmethod
491+
def tearDownClass(cls):
492+
super().tearDownClass()
493+
conf.settings = conf.LazySettings()
494+
495+
def test_http_custom_request_headers_in_span_attributes(self):
496+
expected = {
497+
"http.request.header.custom_test_header_1": (
498+
"test-header-value-1",
499+
),
500+
"http.request.header.custom_test_header_2": (
501+
"test-header-value-2",
502+
),
503+
}
504+
Client(
505+
HTTP_CUSTOM_TEST_HEADER_1="test-header-value-1",
506+
HTTP_CUSTOM_TEST_HEADER_2="test-header-value-2",
507+
).get("/traced/")
508+
spans = self.exporter.get_finished_spans()
509+
self.assertEqual(len(spans), 1)
510+
511+
span = spans[0]
512+
self.assertEqual(span.kind, SpanKind.SERVER)
513+
self.assertSpanHasAttributes(span, expected)
514+
self.memory_exporter.clear()
515+
516+
def test_http_custom_request_headers_not_in_span_attributes(self):
517+
not_expected = {
518+
"http.request.header.custom_test_header_2": (
519+
"test-header-value-2",
520+
),
521+
}
522+
Client(HTTP_CUSTOM_TEST_HEADER_1="test-header-value-1").get("/traced/")
523+
spans = self.exporter.get_finished_spans()
524+
self.assertEqual(len(spans), 1)
525+
526+
span = spans[0]
527+
self.assertEqual(span.kind, SpanKind.SERVER)
528+
for key, _ in not_expected.items():
529+
self.assertNotIn(key, span.attributes)
530+
self.memory_exporter.clear()
531+
532+
def test_http_custom_response_headers_in_span_attributes(self):
533+
expected = {
534+
"http.response.header.custom_test_header_1": (
535+
"test-header-value-1",
536+
),
537+
"http.response.header.custom_test_header_2": (
538+
"test-header-value-2",
539+
),
540+
}
541+
Client().get("/traced_custom_header/")
542+
spans = self.exporter.get_finished_spans()
543+
self.assertEqual(len(spans), 1)
544+
545+
span = spans[0]
546+
self.assertEqual(span.kind, SpanKind.SERVER)
547+
self.assertSpanHasAttributes(span, expected)
548+
self.memory_exporter.clear()
549+
550+
def test_http_custom_response_headers_not_in_span_attributes(self):
551+
not_expected = {
552+
"http.response.header.custom_test_header_3": (
553+
"test-header-value-3",
554+
),
555+
}
556+
Client().get("/traced_custom_header/")
557+
spans = self.exporter.get_finished_spans()
558+
self.assertEqual(len(spans), 1)
559+
560+
span = spans[0]
561+
self.assertEqual(span.kind, SpanKind.SERVER)
562+
for key, _ in not_expected.items():
563+
self.assertNotIn(key, span.attributes)
564+
self.memory_exporter.clear()

0 commit comments

Comments
 (0)