Repository: open-telemetry/opentelemetry-python-contrib

Issue:
Django: Capture request/response headers as span attributes
part of #906 


Discussion:
Please assign this issue to me

Resolving patch:
diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py
--- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py
+++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/__init__.py
@@ -76,6 +76,52 @@ def response_hook(span, request, response):
 Django Request object: https://docs.djangoproject.com/en/3.1/ref/request-response/#httprequest-objects
 Django Response object: https://docs.djangoproject.com/en/3.1/ref/request-response/#httpresponse-objects
 
+Capture HTTP request and response headers
+*****************************************
+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>`_.
+
+Request headers
+***************
+To capture predefined HTTP request headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST``
+to a comma-separated 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 request headers and add them as span attributes.
+
+It is recommended that you should give the correct names of the headers to be captured in the environment variable.
+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``.
+
+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 _ ).
+The value of the attribute will be single item list containing all the header values.
+
+Example of the added span attribute,
+``http.request.header.custom_request_header = ["<value1>,<value2>"]``
+
+Response headers
+****************
+To capture predefined HTTP response headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE``
+to a comma-separated 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 response headers and add them as span attributes.
+
+It is recommended that you should give the correct names of the headers to be captured in the environment variable.
+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``.
+
+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 _ ).
+The value of the attribute will be single item list containing all the header values.
+
+Example of the added span attribute,
+``http.response.header.custom_response_header = ["<value1>,<value2>"]``
+
 API
 ---
 """
diff --git a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py
--- a/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py
+++ b/instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py
@@ -28,13 +28,19 @@
     _start_internal_or_server_span,
     extract_attributes_from_object,
 )
+from opentelemetry.instrumentation.wsgi import (
+    add_custom_request_headers as wsgi_add_custom_request_headers,
+)
+from opentelemetry.instrumentation.wsgi import (
+    add_custom_response_headers as wsgi_add_custom_response_headers,
+)
 from opentelemetry.instrumentation.wsgi import add_response_attributes
 from opentelemetry.instrumentation.wsgi import (
     collect_request_attributes as wsgi_collect_request_attributes,
 )
 from opentelemetry.instrumentation.wsgi import wsgi_getter
 from opentelemetry.semconv.trace import SpanAttributes
-from opentelemetry.trace import Span, use_span
+from opentelemetry.trace import Span, SpanKind, use_span
 from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
 
 try:
@@ -77,7 +83,13 @@ def __call__(self, request):
 
 # try/except block exclusive for optional ASGI imports.
 try:
-    from opentelemetry.instrumentation.asgi import asgi_getter
+    from opentelemetry.instrumentation.asgi import asgi_getter, asgi_setter
+    from opentelemetry.instrumentation.asgi import (
+        collect_custom_request_headers_attributes as asgi_collect_custom_request_attributes,
+    )
+    from opentelemetry.instrumentation.asgi import (
+        collect_custom_response_headers_attributes as asgi_collect_custom_response_attributes,
+    )
     from opentelemetry.instrumentation.asgi import (
         collect_request_attributes as asgi_collect_request_attributes,
     )
@@ -213,6 +225,13 @@ def process_request(self, request):
                     self._traced_request_attrs,
                     attributes,
                 )
+                if span.is_recording() and span.kind == SpanKind.SERVER:
+                    attributes.update(
+                        asgi_collect_custom_request_attributes(carrier)
+                    )
+            else:
+                if span.is_recording() and span.kind == SpanKind.SERVER:
+                    wsgi_add_custom_request_headers(span, carrier)
 
             for key, value in attributes.items():
                 span.set_attribute(key, value)
@@ -257,6 +276,7 @@ def process_exception(self, request, exception):
         if self._environ_activation_key in request.META.keys():
             request.META[self._environ_exception_key] = exception
 
+    # pylint: disable=too-many-branches
     def process_response(self, request, response):
         if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
             return response
@@ -271,12 +291,25 @@ def process_response(self, request, response):
         if activation and span:
             if is_asgi_request:
                 set_status_code(span, response.status_code)
+
+                if span.is_recording() and span.kind == SpanKind.SERVER:
+                    custom_headers = {}
+                    for key, value in response.items():
+                        asgi_setter.set(custom_headers, key, value)
+
+                    custom_res_attributes = (
+                        asgi_collect_custom_response_attributes(custom_headers)
+                    )
+                    for key, value in custom_res_attributes.items():
+                        span.set_attribute(key, value)
             else:
                 add_response_attributes(
                     span,
                     f"{response.status_code} {response.reason_phrase}",
                     response.items(),
                 )
+                if span.is_recording() and span.kind == SpanKind.SERVER:
+                    wsgi_add_custom_response_headers(span, response.items())
 
             propagator = get_global_response_propagator()
             if propagator:


Test patch:
diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py
--- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py
+++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py
@@ -43,7 +43,12 @@
     format_span_id,
     format_trace_id,
 )
-from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
+from opentelemetry.util.http import (
+    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
+    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    get_excluded_urls,
+    get_traced_request_attrs,
+)
 
 # pylint: disable=import-error
 from .views import (
@@ -51,6 +56,7 @@
     excluded,
     excluded_noarg,
     excluded_noarg2,
+    response_with_custom_header,
     route_span_name,
     traced,
     traced_template,
@@ -67,6 +73,7 @@
 
 urlpatterns = [
     re_path(r"^traced/", traced),
+    re_path(r"^traced_custom_header/", response_with_custom_header),
     re_path(r"^route/(?P<year>[0-9]{4})/template/$", traced_template),
     re_path(r"^error/", error),
     re_path(r"^excluded_arg/", excluded),
@@ -451,3 +458,107 @@ def test_django_with_wsgi_instrumented(self):
                 parent_span.get_span_context().span_id,
                 span_list[0].parent.span_id,
             )
+
+
+class TestMiddlewareWsgiWithCustomHeaders(TestBase, WsgiTestBase):
+    @classmethod
+    def setUpClass(cls):
+        conf.settings.configure(ROOT_URLCONF=modules[__name__])
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        setup_test_environment()
+        tracer_provider, exporter = self.create_tracer_provider()
+        self.exporter = exporter
+        _django_instrumentor.instrument(tracer_provider=tracer_provider)
+        self.env_patch = patch.dict(
+            "os.environ",
+            {
+                OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
+                OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
+            },
+        )
+        self.env_patch.start()
+
+    def tearDown(self):
+        super().tearDown()
+        self.env_patch.stop()
+        teardown_test_environment()
+        _django_instrumentor.uninstrument()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+        conf.settings = conf.LazySettings()
+
+    def test_http_custom_request_headers_in_span_attributes(self):
+        expected = {
+            "http.request.header.custom_test_header_1": (
+                "test-header-value-1",
+            ),
+            "http.request.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        Client(
+            HTTP_CUSTOM_TEST_HEADER_1="test-header-value-1",
+            HTTP_CUSTOM_TEST_HEADER_2="test-header-value-2",
+        ).get("/traced/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        self.assertSpanHasAttributes(span, expected)
+        self.memory_exporter.clear()
+
+    def test_http_custom_request_headers_not_in_span_attributes(self):
+        not_expected = {
+            "http.request.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        Client(HTTP_CUSTOM_TEST_HEADER_1="test-header-value-1").get("/traced/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        for key, _ in not_expected.items():
+            self.assertNotIn(key, span.attributes)
+        self.memory_exporter.clear()
+
+    def test_http_custom_response_headers_in_span_attributes(self):
+        expected = {
+            "http.response.header.custom_test_header_1": (
+                "test-header-value-1",
+            ),
+            "http.response.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        Client().get("/traced_custom_header/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        self.assertSpanHasAttributes(span, expected)
+        self.memory_exporter.clear()
+
+    def test_http_custom_response_headers_not_in_span_attributes(self):
+        not_expected = {
+            "http.response.header.custom_test_header_3": (
+                "test-header-value-3",
+            ),
+        }
+        Client().get("/traced_custom_header/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        for key, _ in not_expected.items():
+            self.assertNotIn(key, span.attributes)
+        self.memory_exporter.clear()
diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py
--- a/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py
+++ b/instrumentation/opentelemetry-instrumentation-django/tests/test_middleware_asgi.py
@@ -42,7 +42,12 @@
     format_span_id,
     format_trace_id,
 )
-from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
+from opentelemetry.util.http import (
+    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
+    OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
+    get_excluded_urls,
+    get_traced_request_attrs,
+)
 
 # pylint: disable=import-error
 from .views import (
@@ -53,6 +58,7 @@
     async_route_span_name,
     async_traced,
     async_traced_template,
+    async_with_custom_header,
 )
 
 DJANGO_2_0 = VERSION >= (2, 0)
@@ -65,6 +71,7 @@
 
 urlpatterns = [
     re_path(r"^traced/", async_traced),
+    re_path(r"^traced_custom_header/", async_with_custom_header),
     re_path(r"^route/(?P<year>[0-9]{4})/template/$", async_traced_template),
     re_path(r"^error/", async_error),
     re_path(r"^excluded_arg/", async_excluded),
@@ -415,3 +422,116 @@ async def test_tracer_provider_traced(self):
         self.assertEqual(
             span.resource.attributes["resource-key"], "resource-value"
         )
+
+
+class TestMiddlewareAsgiWithCustomHeaders(SimpleTestCase, TestBase):
+    @classmethod
+    def setUpClass(cls):
+        conf.settings.configure(ROOT_URLCONF=modules[__name__])
+        super().setUpClass()
+
+    def setUp(self):
+        super().setUp()
+        setup_test_environment()
+
+        tracer_provider, exporter = self.create_tracer_provider()
+        self.exporter = exporter
+        _django_instrumentor.instrument(tracer_provider=tracer_provider)
+        self.env_patch = patch.dict(
+            "os.environ",
+            {
+                OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
+                OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
+            },
+        )
+        self.env_patch.start()
+
+    def tearDown(self):
+        super().tearDown()
+        self.env_patch.stop()
+        teardown_test_environment()
+        _django_instrumentor.uninstrument()
+
+    @classmethod
+    def tearDownClass(cls):
+        super().tearDownClass()
+        conf.settings = conf.LazySettings()
+
+    async def test_http_custom_request_headers_in_span_attributes(self):
+        expected = {
+            "http.request.header.custom_test_header_1": (
+                "test-header-value-1",
+            ),
+            "http.request.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        await self.async_client.get(
+            "/traced/",
+            **{
+                "custom-test-header-1": "test-header-value-1",
+                "custom-test-header-2": "test-header-value-2",
+            },
+        )
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        self.assertSpanHasAttributes(span, expected)
+        self.memory_exporter.clear()
+
+    async def test_http_custom_request_headers_not_in_span_attributes(self):
+        not_expected = {
+            "http.request.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        await self.async_client.get(
+            "/traced/",
+            **{
+                "custom-test-header-1": "test-header-value-1",
+            },
+        )
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        for key, _ in not_expected.items():
+            self.assertNotIn(key, span.attributes)
+        self.memory_exporter.clear()
+
+    async def test_http_custom_response_headers_in_span_attributes(self):
+        expected = {
+            "http.response.header.custom_test_header_1": (
+                "test-header-value-1",
+            ),
+            "http.response.header.custom_test_header_2": (
+                "test-header-value-2",
+            ),
+        }
+        await self.async_client.get("/traced_custom_header/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        self.assertSpanHasAttributes(span, expected)
+        self.memory_exporter.clear()
+
+    async def test_http_custom_response_headers_not_in_span_attributes(self):
+        not_expected = {
+            "http.response.header.custom_test_header_3": (
+                "test-header-value-3",
+            ),
+        }
+        await self.async_client.get("/traced_custom_header/")
+        spans = self.exporter.get_finished_spans()
+        self.assertEqual(len(spans), 1)
+
+        span = spans[0]
+        self.assertEqual(span.kind, SpanKind.SERVER)
+        for key, _ in not_expected.items():
+            self.assertNotIn(key, span.attributes)
+        self.memory_exporter.clear()
diff --git a/instrumentation/opentelemetry-instrumentation-django/tests/views.py b/instrumentation/opentelemetry-instrumentation-django/tests/views.py
--- a/instrumentation/opentelemetry-instrumentation-django/tests/views.py
+++ b/instrumentation/opentelemetry-instrumentation-django/tests/views.py
@@ -31,6 +31,13 @@ def route_span_name(
     return HttpResponse()
 
 
+def response_with_custom_header(request):
+    response = HttpResponse()
+    response["custom-test-header-1"] = "test-header-value-1"
+    response["custom-test-header-2"] = "test-header-value-2"
+    return response
+
+
 async def async_traced(request):  # pylint: disable=unused-argument
     return HttpResponse()
 
@@ -61,3 +68,10 @@ async def async_route_span_name(
     request, *args, **kwargs
 ):  # pylint: disable=unused-argument
     return HttpResponse()
+
+
+async def async_with_custom_header(request):
+    response = HttpResponse()
+    response.headers["custom-test-header-1"] = "test-header-value-1"
+    response.headers["custom-test-header-2"] = "test-header-value-2"
+    return response