Skip to content

Commit d241940

Browse files
author
Mergepath
committed
fix: address #4681 - Django Instrumentation: Spans close prematurely for StreamingHttpResponse
Closes #4681
1 parent e0ca1e5 commit d241940

4 files changed

Lines changed: 137 additions & 25 deletions

File tree

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

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ def process_request(self, request):
259259
for key, value in attributes.items():
260260
span.set_attribute(key, value)
261261

262-
activation = use_span(span, end_on_exit=True)
262+
activation = use_span(span, end_on_exit=False)
263263
activation.__enter__() # pylint: disable=unnecessary-dunder-call
264264
request_start_time = default_timer()
265265
request.META[self._environ_timer_key] = request_start_time
@@ -394,29 +394,40 @@ def process_response(self, request, response):
394394
except Exception: # pylint: disable=broad-exception-caught
395395
_logger.exception("Exception raised by response_hook")
396396

397-
if request_start_time is not None:
398-
duration_s = default_timer() - request_start_time
399-
if self._duration_histogram_old:
400-
duration_attrs_old = _parse_duration_attrs(
401-
duration_attrs, _StabilityMode.DEFAULT
402-
)
403-
# http.target to be included in old semantic conventions
404-
target = duration_attrs.get(HTTP_TARGET)
405-
if target:
406-
duration_attrs_old[HTTP_TARGET] = target
407-
self._duration_histogram_old.record(
408-
max(round(duration_s * 1000), 0),
409-
duration_attrs_old,
410-
)
411-
if self._duration_histogram_new:
412-
duration_attrs_new = _parse_duration_attrs(
413-
duration_attrs, _StabilityMode.HTTP
414-
)
415-
self._duration_histogram_new.record(
416-
max(duration_s, 0),
417-
duration_attrs_new,
418-
)
419-
self._active_request_counter.add(-1, active_requests_count_attrs)
397+
finalized = False
398+
399+
def finalize_response():
400+
nonlocal finalized
401+
if finalized:
402+
return
403+
finalized = True
404+
405+
if request_start_time is not None:
406+
duration_s = default_timer() - request_start_time
407+
if self._duration_histogram_old:
408+
duration_attrs_old = _parse_duration_attrs(
409+
duration_attrs, _StabilityMode.DEFAULT
410+
)
411+
# http.target to be included in old semantic conventions
412+
target = duration_attrs.get(HTTP_TARGET)
413+
if target:
414+
duration_attrs_old[HTTP_TARGET] = target
415+
self._duration_histogram_old.record(
416+
max(round(duration_s * 1000), 0),
417+
duration_attrs_old,
418+
)
419+
if self._duration_histogram_new:
420+
duration_attrs_new = _parse_duration_attrs(
421+
duration_attrs, _StabilityMode.HTTP
422+
)
423+
self._duration_histogram_new.record(
424+
max(duration_s, 0),
425+
duration_attrs_new,
426+
)
427+
self._active_request_counter.add(-1, active_requests_count_attrs)
428+
429+
if span:
430+
span.end()
420431

421432
if activation and span:
422433
if exception:
@@ -432,6 +443,16 @@ def process_response(self, request, response):
432443
detach(request.META.get(self._environ_token))
433444
request.META.pop(self._environ_token)
434445

446+
original_close = response.close
447+
448+
def close():
449+
try:
450+
return original_close()
451+
finally:
452+
finalize_response()
453+
454+
response.close = close
455+
435456
return response
436457

437458

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
excluded_noarg2,
5959
response_with_custom_header,
6060
route_span_name,
61+
streaming,
6162
traced,
6263
traced_template,
6364
)
@@ -77,6 +78,7 @@ def path(path_argument, *args, **kwargs):
7778

7879
urlpatterns = [
7980
re_path(r"^traced/", traced),
81+
re_path(r"^streaming/", streaming),
8082
re_path(r"^traced_custom_header/", response_with_custom_header),
8183
re_path(r"^route/(?P<year>[0-9]{4})/template/$", traced_template),
8284
re_path(r"^error/", error),
@@ -242,6 +244,39 @@ def test_traced_get(self):
242244
self.assertEqual(span.attributes["http.scheme"], "http")
243245
self.assertEqual(span.attributes["http.status_code"], 200)
244246

247+
def test_streaming_response_span_closes_after_response_close(self):
248+
response = Client().get("/streaming/")
249+
250+
spans = self.memory_exporter.get_finished_spans()
251+
self.assertEqual(len(spans), 0)
252+
253+
streaming_content = iter(response.streaming_content)
254+
self.assertEqual(next(streaming_content), b"streaming ")
255+
spans = self.memory_exporter.get_finished_spans()
256+
self.assertEqual(len(spans), 0)
257+
258+
response.close()
259+
spans = self.memory_exporter.get_finished_spans()
260+
self.assertEqual(len(spans), 1)
261+
262+
response.close()
263+
spans = self.memory_exporter.get_finished_spans()
264+
self.assertEqual(len(spans), 1)
265+
266+
span = spans[0]
267+
self.assertEqual(span.name, "GET ^streaming/" if DJANGO_2_2 else "GET")
268+
self.assertEqual(span.kind, SpanKind.SERVER)
269+
self.assertEqual(span.status.status_code, StatusCode.UNSET)
270+
self.assertEqual(span.attributes["http.method"], "GET")
271+
self.assertEqual(
272+
span.attributes["http.url"],
273+
"http://testserver/streaming/",
274+
)
275+
if DJANGO_2_2:
276+
self.assertEqual(span.attributes["http.route"], "^streaming/")
277+
self.assertEqual(span.attributes["http.scheme"], "http")
278+
self.assertEqual(span.attributes["http.status_code"], 200)
279+
245280
def test_traced_get_new_semconv(self):
246281
Client().get("/traced/")
247282

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
async_excluded_noarg,
7373
async_excluded_noarg2,
7474
async_route_span_name,
75+
async_streaming,
7576
async_traced,
7677
async_traced_template,
7778
async_with_custom_header,
@@ -87,6 +88,7 @@
8788

8889
urlpatterns = [
8990
re_path(r"^traced/", async_traced),
91+
re_path(r"^streaming/", async_streaming),
9092
re_path(r"^traced_custom_header/", async_with_custom_header),
9193
re_path(r"^route/(?P<year>[0-9]{4})/template/$", async_traced_template),
9294
re_path(r"^error/", async_error),
@@ -248,6 +250,44 @@ async def test_traced_get(self):
248250
self.assertEqual(span.attributes[HTTP_SCHEME], "http")
249251
self.assertEqual(span.attributes[HTTP_STATUS_CODE], 200)
250252

253+
async def test_streaming_response_span_closes_after_response_close(self):
254+
response = await self.async_client.get("/streaming/")
255+
256+
spans = self.memory_exporter.get_finished_spans()
257+
self.assertEqual(len(spans), 0)
258+
259+
if hasattr(response.streaming_content, "__aiter__"):
260+
streaming_content = response.streaming_content.__aiter__()
261+
self.assertEqual(
262+
await streaming_content.__anext__(), b"streaming "
263+
)
264+
else:
265+
streaming_content = iter(response.streaming_content)
266+
self.assertEqual(next(streaming_content), b"streaming ")
267+
spans = self.memory_exporter.get_finished_spans()
268+
self.assertEqual(len(spans), 0)
269+
270+
response.close()
271+
spans = self.memory_exporter.get_finished_spans()
272+
self.assertEqual(len(spans), 1)
273+
274+
response.close()
275+
spans = self.memory_exporter.get_finished_spans()
276+
self.assertEqual(len(spans), 1)
277+
278+
span = spans[0]
279+
self.assertEqual(span.name, "GET ^streaming/")
280+
self.assertEqual(span.kind, SpanKind.SERVER)
281+
self.assertEqual(span.status.status_code, StatusCode.UNSET)
282+
self.assertEqual(span.attributes[HTTP_METHOD], "GET")
283+
self.assertEqual(
284+
span.attributes[HTTP_URL],
285+
"http://testserver/streaming/",
286+
)
287+
self.assertEqual(span.attributes[HTTP_ROUTE], "^streaming/")
288+
self.assertEqual(span.attributes[HTTP_SCHEME], "http")
289+
self.assertEqual(span.attributes[HTTP_STATUS_CODE], 200)
290+
251291
async def test_traced_get_new_semconv(self):
252292
await self.async_client.get("/traced/")
253293

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
# Copyright The OpenTelemetry Authors
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from django.http import HttpResponse
4+
from django import VERSION
5+
from django.http import HttpResponse, StreamingHttpResponse
56

67

78
def traced(request): # pylint: disable=unused-argument
89
return HttpResponse()
910

1011

12+
def streaming(request): # pylint: disable=unused-argument
13+
return StreamingHttpResponse(iter([b"streaming ", b"response"]))
14+
15+
1116
def traced_template(request, year): # pylint: disable=unused-argument
1217
return HttpResponse()
1318

@@ -50,6 +55,17 @@ async def async_traced(request): # pylint: disable=unused-argument
5055
return HttpResponse()
5156

5257

58+
async def async_streaming(request): # pylint: disable=unused-argument
59+
if VERSION < (4, 2):
60+
return StreamingHttpResponse(iter([b"streaming ", b"response"]))
61+
62+
async def streaming_content():
63+
yield b"streaming "
64+
yield b"response"
65+
66+
return StreamingHttpResponse(streaming_content())
67+
68+
5369
async def async_traced_template(request, year): # pylint: disable=unused-argument
5470
return HttpResponse()
5571

0 commit comments

Comments
 (0)