Skip to content

Commit 0e05254

Browse files
committed
[Django] Added support for traceresponse headers
Added opt-in support to return traceresponse headers from Django. This allows users to configure their Django apps to inject trace context as headers in HTTP responses. This is useful when client side apps need to connect their spans with the server side spans e.g, in RUM products. Today the most practical way to do this is to use the `Server-Timing` header but in near future we might use the `traceresponse` header as described here: https://w3c.github.io/trace-context/#trace-context-http-response-headers-format As a result the implementation does not use a hard-coded header and instead let's the users pick one. This can be done by setting the `OTEL_PYTHON_TRACE_RESPONSE_HEADER` to the header name that users want to inject in HTTP responses. The option does not have a default value and the feature is disbaled when a env var is not set.
1 parent 370952f commit 0e05254

File tree

6 files changed

+148
-5
lines changed

6 files changed

+148
-5
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
([#299](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/299))
2222
- `opentelemetry-instrumenation-django` now supports request and response hooks.
2323
([#407](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/407))
24+
- Add trace response header support for Django.
25+
([#395](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/395))
2426

2527
### Removed
2628
- Remove `http.status_text` from span attributes

Diff for: instrumentation/opentelemetry-instrumentation-aiohttp-client/tests/test_aiohttp_client_integration.py

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import typing
1818
import unittest
1919
import urllib.parse
20+
import pkg_resources
2021
from http import HTTPStatus
2122
from unittest import mock
2223

Diff for: instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py

+19-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from django.http import HttpRequest, HttpResponse
2020

2121
from opentelemetry.context import attach, detach
22+
from opentelemetry.instrumentation.propagators import get_global_back_propagator
2223
from opentelemetry.instrumentation.django.version import __version__
2324
from opentelemetry.instrumentation.utils import extract_attributes_from_object
2425
from opentelemetry.instrumentation.wsgi import (
@@ -28,7 +29,11 @@
2829
)
2930
from opentelemetry.propagate import extract
3031
from opentelemetry.trace import Span, SpanKind, get_tracer, use_span
31-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
32+
from opentelemetry.util.http import (
33+
get_excluded_urls,
34+
get_trace_response_headers,
35+
get_traced_request_attrs,
36+
)
3237

3338
try:
3439
from django.core.urlresolvers import ( # pylint: disable=no-name-in-module
@@ -179,6 +184,19 @@ def process_response(self, request, response):
179184
response,
180185
)
181186

187+
propagator = get_global_back_propagator()
188+
if propagator:
189+
propagator.inject(response)
190+
191+
# inject trace response headers
192+
#for header, value in get_trace_response_headers(span):
193+
# old_value = response.get(header, "")
194+
# if old_value:
195+
# value = "{0}, {1}".format(old_value, value)
196+
# response[header] = value
197+
198+
199+
# record any exceptions raised while processing the request
182200
exception = request.META.pop(self._environ_exception_key, None)
183201
if _DjangoMiddleware._otel_response_hook:
184202
_DjangoMiddleware._otel_response_hook( # pylint: disable=not-callable

Diff for: instrumentation/opentelemetry-instrumentation-django/tests/test_middleware.py

+82-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from os import environ
1516
from sys import modules
1617
from unittest.mock import Mock, patch
1718

@@ -30,7 +31,12 @@
3031
from opentelemetry.test.test_base import TestBase
3132
from opentelemetry.test.wsgitestutil import WsgiTestBase
3233
from opentelemetry.trace import SpanKind, StatusCode
33-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
34+
from opentelemetry.util.http import (
35+
ENV_HTTP_TRACE_RESPONSE_HEADER,
36+
get_excluded_urls,
37+
get_trace_response_headers,
38+
get_traced_request_attrs,
39+
)
3440

3541
# pylint: disable=import-error
3642
from .views import (
@@ -41,6 +47,7 @@
4147
route_span_name,
4248
traced,
4349
traced_template,
50+
with_response_header,
4451
)
4552

4653
DJANGO_2_2 = VERSION >= (2, 2)
@@ -52,6 +59,7 @@
5259
url(r"^excluded_arg/", excluded),
5360
url(r"^excluded_noarg/", excluded_noarg),
5461
url(r"^excluded_noarg2/", excluded_noarg2),
62+
url(r"^response_header/", with_response_header),
5563
url(r"^span_name/([0-9]{4})/$", route_span_name),
5664
]
5765
_django_instrumentor = DjangoInstrumentor()
@@ -309,3 +317,76 @@ def response_hook(span, request, response):
309317
self.assertIsInstance(response_hook_args[1], HttpRequest)
310318
self.assertIsInstance(response_hook_args[2], HttpResponse)
311319
self.assertEqual(response_hook_args[2], response)
320+
321+
def test_trace_response_header(self):
322+
original_env_var = environ.pop(ENV_HTTP_TRACE_RESPONSE_HEADER, None)
323+
response = Client().get("/span_name/1234/")
324+
self.assertNotIn("Server-Timing", response._headers)
325+
self.memory_exporter.clear()
326+
327+
for header in ["Server-Timing", "traceresponse"]:
328+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = header
329+
330+
response = Client().get("/span_name/1234/")
331+
span = self.memory_exporter.get_finished_spans()[0]
332+
headers = get_trace_response_headers(span)
333+
self.assertEqual(len(headers), 2)
334+
335+
access_control_header = headers[0]
336+
response_header = headers[1]
337+
338+
self.assertIn(header.lower(), response._headers)
339+
self.assertEqual(
340+
response._headers["access-control-expose-headers"][0],
341+
access_control_header[0],
342+
)
343+
self.assertEqual(
344+
response._headers["access-control-expose-headers"][1],
345+
access_control_header[1],
346+
)
347+
self.assertEqual(
348+
response._headers[header.lower()][0], response_header[0]
349+
)
350+
self.assertEqual(
351+
response._headers[header.lower()][1], response_header[1]
352+
)
353+
354+
self.memory_exporter.clear()
355+
del environ[ENV_HTTP_TRACE_RESPONSE_HEADER]
356+
357+
if original_env_var:
358+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = original_env_var
359+
360+
def test_trace_response_header_pre_existing_header(self):
361+
original_env_var = environ.pop(ENV_HTTP_TRACE_RESPONSE_HEADER, None)
362+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = "Server-Timing"
363+
response = Client().get("/response_header/")
364+
365+
span = self.memory_exporter.get_finished_spans()[0]
366+
headers = get_trace_response_headers(span)
367+
self.assertEqual(len(headers), 2)
368+
access_control_header = headers[0]
369+
response_header = headers[1]
370+
371+
self.assertIn("server-timing", response._headers)
372+
self.assertEqual(
373+
response._headers["access-control-expose-headers"][0],
374+
access_control_header[0],
375+
)
376+
self.assertEqual(
377+
response._headers["access-control-expose-headers"][1],
378+
"X-Test-Header, Server-Timing",
379+
)
380+
self.assertEqual(
381+
response._headers["server-timing"][0], response_header[0]
382+
)
383+
self.assertEqual(
384+
response._headers["server-timing"][1],
385+
"abc; val=1, " + response_header[1],
386+
)
387+
388+
self.memory_exporter.clear()
389+
del environ[ENV_HTTP_TRACE_RESPONSE_HEADER]
390+
391+
if original_env_var:
392+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = original_env_var

Diff for: instrumentation/opentelemetry-instrumentation-django/tests/views.py

+9
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.http import HttpResponse
22

3+
from opentelemetry.util.http import HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS
4+
35

46
def traced(request): # pylint: disable=unused-argument
57
return HttpResponse()
@@ -29,3 +31,10 @@ def route_span_name(
2931
request, *args, **kwargs
3032
): # pylint: disable=unused-argument
3133
return HttpResponse()
34+
35+
36+
def with_response_header(request): # pylint: disable=unused-argument
37+
response = HttpResponse()
38+
response["Server-Timing"] = "abc; val=1"
39+
response[HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS] = "X-Test-Header"
40+
return response

Diff for: util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

+35-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@
1515
from os import environ
1616
from re import compile as re_compile
1717
from re import search
18+
from typing import Callable, Collection, Optional, Tuple
19+
20+
from opentelemetry import trace
21+
22+
_root = r"OTEL_PYTHON_{}"
23+
24+
HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers"
25+
ENV_HTTP_TRACE_RESPONSE_HEADER = "OTEL_PYTHON_HTTP_TRACE_RESPONSE_HEADER"
1826

1927

2028
class ExcludeList:
@@ -29,9 +37,6 @@ def url_disabled(self, url: str) -> bool:
2937
return bool(self._excluded_urls and search(self._regex, url))
3038

3139

32-
_root = r"OTEL_PYTHON_{}"
33-
34-
3540
def get_traced_request_attrs(instrumentation):
3641
traced_request_attrs = environ.get(
3742
_root.format("{}_TRACED_REQUEST_ATTRS".format(instrumentation)), []
@@ -57,3 +62,30 @@ def get_excluded_urls(instrumentation):
5762
]
5863

5964
return ExcludeList(excluded_urls)
65+
66+
67+
def get_trace_response_headers(
68+
span: trace.Span,
69+
) -> Collection[Tuple[str, str]]:
70+
header_name = environ.get(ENV_HTTP_TRACE_RESPONSE_HEADER, "").strip()
71+
if not header_name:
72+
return tuple()
73+
74+
if span is trace.INVALID_SPAN:
75+
return tuple()
76+
77+
ctx = span.get_span_context()
78+
if ctx is trace.INVALID_SPAN_CONTEXT:
79+
return tuple()
80+
81+
return (
82+
(HTTP_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS, header_name),
83+
(
84+
header_name,
85+
'traceparent;desc="00-{trace_id}-{span_id}-{sampled}"'.format(
86+
trace_id=trace.format_trace_id(ctx.trace_id),
87+
span_id=trace.format_span_id(ctx.span_id),
88+
sampled="01" if ctx.trace_flags.sampled else "00",
89+
),
90+
),
91+
)

0 commit comments

Comments
 (0)