Skip to content

Commit f7cacbc

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 a946d5c commit f7cacbc

File tree

5 files changed

+144
-11
lines changed

5 files changed

+144
-11
lines changed

Diff for: CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
### Added
1414
- `opentelemetry-instrumentation-urllib3` Add urllib3 instrumentation
1515
([#299](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/299))
16+
- Add trace response header support for Django.
17+
([#395](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/395))
1618

1719
## [0.19b0](https://github.com/open-telemetry/opentelemetry-python-contrib/releases/tag/v0.19b0) - 2021-03-26
1820

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

+16-7
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@
2525
)
2626
from opentelemetry.propagate import extract
2727
from opentelemetry.trace import SpanKind, get_tracer, use_span
28-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
28+
from opentelemetry.util.http import (
29+
get_excluded_urls,
30+
get_trace_response_headers,
31+
get_traced_request_attrs,
32+
)
2933

3034
try:
3135
from django.core.urlresolvers import ( # pylint: disable=no-name-in-module
@@ -156,18 +160,23 @@ def process_response(self, request, response):
156160
if self._excluded_urls.url_disabled(request.build_absolute_uri("?")):
157161
return response
158162

159-
if (
160-
self._environ_activation_key in request.META.keys()
161-
and self._environ_span_key in request.META.keys()
162-
):
163+
span = request.META.pop(self._environ_span_key, None)
164+
if span and self._environ_activation_key in request.META.keys():
165+
# record span attributes from response
163166
add_response_attributes(
164-
request.META[self._environ_span_key],
167+
span,
165168
"{} {}".format(response.status_code, response.reason_phrase),
166169
response,
167170
)
168171

169-
request.META.pop(self._environ_span_key)
172+
# inject trace response headers
173+
for header, value in get_trace_response_headers(span):
174+
old_value = response.get(header, "")
175+
if old_value:
176+
value = "{0}, {1}".format(old_value, value)
177+
response[header] = value
170178

179+
# record any exceptions raised while processing the request
171180
exception = request.META.pop(self._environ_exception_key, None)
172181
if exception:
173182
request.META[self._environ_activation_key].__exit__(

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

@@ -25,7 +26,12 @@
2526
from opentelemetry.test.test_base import TestBase
2627
from opentelemetry.test.wsgitestutil import WsgiTestBase
2728
from opentelemetry.trace import SpanKind, StatusCode
28-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
29+
from opentelemetry.util.http import (
30+
ENV_HTTP_TRACE_RESPONSE_HEADER,
31+
get_excluded_urls,
32+
get_trace_response_headers,
33+
get_traced_request_attrs,
34+
)
2935

3036
# pylint: disable=import-error
3137
from .views import (
@@ -36,6 +42,7 @@
3642
route_span_name,
3743
traced,
3844
traced_template,
45+
with_response_header,
3946
)
4047

4148
DJANGO_2_2 = VERSION >= (2, 2)
@@ -47,6 +54,7 @@
4754
url(r"^excluded_arg/", excluded),
4855
url(r"^excluded_noarg/", excluded_noarg),
4956
url(r"^excluded_noarg2/", excluded_noarg2),
57+
url(r"^response_header/", with_response_header),
5058
url(r"^span_name/([0-9]{4})/$", route_span_name),
5159
]
5260
_django_instrumentor = DjangoInstrumentor()
@@ -268,3 +276,76 @@ def test_traced_request_attrs(self):
268276
self.assertEqual(span.attributes["path_info"], "/span_name/1234/")
269277
self.assertEqual(span.attributes["content_type"], "test/ct")
270278
self.assertNotIn("non_existing_variable", span.attributes)
279+
280+
def test_trace_response_header(self):
281+
original_env_var = environ.pop(ENV_HTTP_TRACE_RESPONSE_HEADER, None)
282+
response = Client().get("/span_name/1234/")
283+
self.assertNotIn("Server-Timing", response._headers)
284+
self.memory_exporter.clear()
285+
286+
for header in ["Server-Timing", "traceresponse"]:
287+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = header
288+
289+
response = Client().get("/span_name/1234/")
290+
span = self.memory_exporter.get_finished_spans()[0]
291+
headers = get_trace_response_headers(span)
292+
self.assertEqual(len(headers), 2)
293+
294+
access_control_header = headers[0]
295+
response_header = headers[1]
296+
297+
self.assertIn(header.lower(), response._headers)
298+
self.assertEqual(
299+
response._headers["access-control-expose-headers"][0],
300+
access_control_header[0],
301+
)
302+
self.assertEqual(
303+
response._headers["access-control-expose-headers"][1],
304+
access_control_header[1],
305+
)
306+
self.assertEqual(
307+
response._headers[header.lower()][0], response_header[0]
308+
)
309+
self.assertEqual(
310+
response._headers[header.lower()][1], response_header[1]
311+
)
312+
313+
self.memory_exporter.clear()
314+
del environ[ENV_HTTP_TRACE_RESPONSE_HEADER]
315+
316+
if original_env_var:
317+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = original_env_var
318+
319+
def test_trace_response_header_pre_existing_header(self):
320+
original_env_var = environ.pop(ENV_HTTP_TRACE_RESPONSE_HEADER, None)
321+
environ[ENV_HTTP_TRACE_RESPONSE_HEADER] = "Server-Timing"
322+
response = Client().get("/response_header/")
323+
324+
span = self.memory_exporter.get_finished_spans()[0]
325+
headers = get_trace_response_headers(span)
326+
self.assertEqual(len(headers), 2)
327+
access_control_header = headers[0]
328+
response_header = headers[1]
329+
330+
self.assertIn("server-timing", response._headers)
331+
self.assertEqual(
332+
response._headers["access-control-expose-headers"][0],
333+
access_control_header[0],
334+
)
335+
self.assertEqual(
336+
response._headers["access-control-expose-headers"][1],
337+
"X-Test-Header, Server-Timing",
338+
)
339+
self.assertEqual(
340+
response._headers["server-timing"][0], response_header[0]
341+
)
342+
self.assertEqual(
343+
response._headers["server-timing"][1],
344+
"abc; val=1, " + response_header[1],
345+
)
346+
347+
self.memory_exporter.clear()
348+
del environ[ENV_HTTP_TRACE_RESPONSE_HEADER]
349+
350+
if original_env_var:
351+
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)