Skip to content

Commit 560fd04

Browse files
authored
fix(tornado): ensure reading future.result() won't throw an exception in client.py _finish_tracing_callback (#2563)
1 parent 4108d57 commit 560fd04

File tree

3 files changed

+83
-22
lines changed

3 files changed

+83
-22
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3636
([#2385](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2385))
3737
- `opentelemetry-instrumentation-asyncio` Fixes async generator coroutines not being awaited
3838
([#2792](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2792))
39+
- `opentelemetry-instrumentation-tornado` Handle http client exception and record exception info into span
40+
([#2563](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2563))
3941

4042
## Version 1.26.0/0.47b0 (2024-07-23)
4143

instrumentation/opentelemetry-instrumentation-tornado/src/opentelemetry/instrumentation/tornado/client.py

+36-20
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from opentelemetry.instrumentation.utils import http_status_to_status_code
2222
from opentelemetry.propagate import inject
2323
from opentelemetry.semconv.trace import SpanAttributes
24-
from opentelemetry.trace.status import Status
24+
from opentelemetry.trace.status import Status, StatusCode
2525
from opentelemetry.util.http import remove_url_credentials
2626

2727

@@ -105,37 +105,53 @@ def _finish_tracing_callback(
105105
request_size_histogram,
106106
response_size_histogram,
107107
):
108+
response = None
108109
status_code = None
110+
status = None
109111
description = None
110-
exc = future.exception()
111-
112-
response = future.result()
113112

114-
if span.is_recording() and exc:
113+
exc = future.exception()
114+
if exc:
115+
description = f"{type(exc).__qualname__}: {exc}"
115116
if isinstance(exc, HTTPError):
117+
response = exc.response
116118
status_code = exc.code
117-
description = f"{type(exc).__name__}: {exc}"
119+
status = Status(
120+
status_code=http_status_to_status_code(status_code),
121+
description=description,
122+
)
123+
else:
124+
status = Status(
125+
status_code=StatusCode.ERROR,
126+
description=description,
127+
)
128+
span.record_exception(exc)
118129
else:
130+
response = future.result()
119131
status_code = response.code
132+
status = Status(
133+
status_code=http_status_to_status_code(status_code),
134+
description=description,
135+
)
120136

121137
if status_code is not None:
122138
span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code)
123-
span.set_status(
124-
Status(
125-
status_code=http_status_to_status_code(status_code),
126-
description=description,
127-
)
128-
)
139+
span.set_status(status)
129140

130-
metric_attributes = _create_metric_attributes(response)
131-
request_size = int(response.request.headers.get("Content-Length", 0))
132-
response_size = int(response.headers.get("Content-Length", 0))
141+
if response is not None:
142+
metric_attributes = _create_metric_attributes(response)
143+
request_size = int(response.request.headers.get("Content-Length", 0))
144+
response_size = int(response.headers.get("Content-Length", 0))
133145

134-
duration_histogram.record(
135-
response.request_time, attributes=metric_attributes
136-
)
137-
request_size_histogram.record(request_size, attributes=metric_attributes)
138-
response_size_histogram.record(response_size, attributes=metric_attributes)
146+
duration_histogram.record(
147+
response.request_time, attributes=metric_attributes
148+
)
149+
request_size_histogram.record(
150+
request_size, attributes=metric_attributes
151+
)
152+
response_size_histogram.record(
153+
response_size, attributes=metric_attributes
154+
)
139155

140156
if response_hook:
141157
response_hook(span, future)

instrumentation/opentelemetry-instrumentation-tornado/tests/test_instrumentation.py

+45-2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from unittest.mock import Mock, patch
1717

1818
from http_server_mock import HttpServerMock
19+
from tornado.httpclient import HTTPClientError
1920
from tornado.testing import AsyncHTTPTestCase
2021

2122
from opentelemetry import trace
@@ -32,7 +33,7 @@
3233
from opentelemetry.semconv.trace import SpanAttributes
3334
from opentelemetry.test.test_base import TestBase
3435
from opentelemetry.test.wsgitestutil import WsgiTestBase
35-
from opentelemetry.trace import SpanKind
36+
from opentelemetry.trace import SpanKind, StatusCode
3637
from opentelemetry.util.http import (
3738
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
3839
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
@@ -493,7 +494,6 @@ def test_response_headers(self):
493494
self.assertEqual(len(spans), 3)
494495
self.assertTraceResponseHeaderMatchesSpan(response.headers, spans[1])
495496

496-
self.memory_exporter.clear()
497497
set_global_response_propagator(orig)
498498

499499
def test_credential_removal(self):
@@ -602,6 +602,49 @@ def client_response_hook(span, response):
602602
self.memory_exporter.clear()
603603

604604

605+
class TestTornadoHTTPClientInstrumentation(TornadoTest, WsgiTestBase):
606+
def test_http_client_success_response(self):
607+
response = self.fetch("/")
608+
self.assertEqual(response.code, 201)
609+
610+
spans = self.memory_exporter.get_finished_spans()
611+
self.assertEqual(len(spans), 3)
612+
manual, server, client = self.sorted_spans(spans)
613+
self.assertEqual(manual.name, "manual")
614+
self.assertEqual(server.name, "GET /")
615+
self.assertEqual(client.name, "GET")
616+
self.assertEqual(client.status.status_code, StatusCode.UNSET)
617+
self.memory_exporter.clear()
618+
619+
def test_http_client_failed_response(self):
620+
# when an exception isn't thrown
621+
response = self.fetch("/some-404")
622+
self.assertEqual(response.code, 404)
623+
624+
spans = self.memory_exporter.get_finished_spans()
625+
self.assertEqual(len(spans), 2)
626+
server, client = self.sorted_spans(spans)
627+
self.assertEqual(server.name, "GET /some-404")
628+
self.assertEqual(client.name, "GET")
629+
self.assertEqual(client.status.status_code, StatusCode.ERROR)
630+
self.memory_exporter.clear()
631+
632+
# when an exception is thrown
633+
try:
634+
response = self.fetch("/some-404", raise_error=True)
635+
self.assertEqual(response.code, 404)
636+
except HTTPClientError:
637+
pass # expected exception - continue
638+
639+
spans = self.memory_exporter.get_finished_spans()
640+
self.assertEqual(len(spans), 2)
641+
server, client = self.sorted_spans(spans)
642+
self.assertEqual(server.name, "GET /some-404")
643+
self.assertEqual(client.name, "GET")
644+
self.assertEqual(client.status.status_code, StatusCode.ERROR)
645+
self.memory_exporter.clear()
646+
647+
605648
class TestTornadoUninstrument(TornadoTest):
606649
def test_uninstrument(self):
607650
response = self.fetch("/")

0 commit comments

Comments
 (0)