diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e12ff8586..b5031bc905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#2616](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2616)) - `opentelemetry-instrumentation-confluent-kafka` Add support for produce purge ([#2638](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2638)) +- `opentelemetry-instrumentation-httpx` Implement new semantic convention opt-in migration with stable http semantic conventions + ([#2631](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2631)) - `opentelemetry-instrumentation-system-metrics` Permit to use psutil 6.0+. ([#2630](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2630)) diff --git a/instrumentation/README.md b/instrumentation/README.md index 26c7d24daf..eb21717843 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -22,7 +22,7 @@ | [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.58 | Yes | experimental | [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0 | Yes | migration | [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio ~= 1.27 | No | experimental -| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No | experimental +| [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0 | No | migration | [opentelemetry-instrumentation-jinja2](./opentelemetry-instrumentation-jinja2) | jinja2 >= 2.7, < 4.0 | No | experimental | [opentelemetry-instrumentation-kafka-python](./opentelemetry-instrumentation-kafka-python) | kafka-python >= 2.0 | No | experimental | [opentelemetry-instrumentation-logging](./opentelemetry-instrumentation-logging) | logging | No | experimental diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py index 5404b2f025..d2ff0be292 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/__init__.py @@ -196,6 +196,19 @@ async def async_response_hook(span, request, response): import httpx +from opentelemetry.instrumentation._semconv import ( + _get_schema_url, + _HTTPStabilityMode, + _OpenTelemetrySemanticConventionStability, + _OpenTelemetryStabilitySignalType, + _report_new, + _set_http_host, + _set_http_method, + _set_http_network_protocol_version, + _set_http_peer_port_client, + _set_http_status_code, + _set_http_url, +) from opentelemetry.instrumentation.httpx.package import _instruments from opentelemetry.instrumentation.httpx.version import __version__ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -204,11 +217,15 @@ async def async_response_hook(span, request, response): is_http_instrumentation_enabled, ) from opentelemetry.propagate import inject -from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, +) from opentelemetry.trace import SpanKind, TracerProvider, get_tracer from opentelemetry.trace.span import Span -from opentelemetry.trace.status import Status -from opentelemetry.util.http import remove_url_credentials +from opentelemetry.trace.status import StatusCode +from opentelemetry.util.http import remove_url_credentials, sanitize_method _logger = logging.getLogger(__name__) @@ -242,25 +259,11 @@ class ResponseInfo(typing.NamedTuple): def _get_default_span_name(method: str) -> str: - return method.strip() - - -def _apply_status_code(span: Span, status_code: int) -> None: - if not span.is_recording(): - return + method = sanitize_method(method.upper().strip()) + if method == "_OTHER": + method = "HTTP" - span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) - span.set_status(Status(http_status_to_status_code(status_code))) - - -def _prepare_attributes(method: bytes, url: URL) -> typing.Dict[str, str]: - _method = method.decode().upper() - _url = str(httpx.URL(url)) - span_attributes = { - SpanAttributes.HTTP_METHOD: _method, - SpanAttributes.HTTP_URL: _url, - } - return span_attributes + return method def _prepare_headers(headers: typing.Optional[Headers]) -> httpx.Headers: @@ -299,6 +302,84 @@ def _inject_propagation_headers(headers, args, kwargs): kwargs["headers"] = _headers.raw +def _extract_response( + response: typing.Union[ + httpx.Response, typing.Tuple[int, Headers, httpx.SyncByteStream, dict] + ] +) -> typing.Tuple[int, Headers, httpx.SyncByteStream, dict, str]: + if isinstance(response, httpx.Response): + status_code = response.status_code + headers = response.headers + stream = response.stream + extensions = response.extensions + http_version = response.http_version + else: + status_code, headers, stream, extensions = response + http_version = extensions.get("http_version", b"HTTP/1.1").decode( + "ascii", errors="ignore" + ) + + return (status_code, headers, stream, extensions, http_version) + + +def _apply_request_client_attributes_to_span( + span_attributes: dict, + url: typing.Union[str, URL, httpx.URL], + method_original: str, + span_name: str, + semconv: _HTTPStabilityMode, +): + url = httpx.URL(url) + # http semconv transition: http.method -> http.request.method + _set_http_method(span_attributes, method_original, span_name, semconv) + # http semconv transition: http.url -> url.full + _set_http_url(span_attributes, str(url), semconv) + + if _report_new(semconv): + if url.host: + # http semconv transition: http.host -> server.address + _set_http_host(span_attributes, url.host, semconv) + # http semconv transition: net.sock.peer.addr -> network.peer.address + span_attributes[NETWORK_PEER_ADDRESS] = url.host + if url.port: + # http semconv transition: net.sock.peer.port -> network.peer.port + _set_http_peer_port_client(span_attributes, url.port, semconv) + span_attributes[NETWORK_PEER_PORT] = url.port + + +def _apply_response_client_attributes_to_span( + span: Span, + status_code: int, + http_version: str, + semconv: _HTTPStabilityMode, +): + # http semconv transition: http.status_code -> http.response.status_code + # TODO: use _set_status when it's stable for http clients + span_attributes = {} + _set_http_status_code( + span_attributes, + status_code, + semconv, + ) + http_status_code = http_status_to_status_code(status_code) + span.set_status(http_status_code) + + if http_status_code == StatusCode.ERROR and _report_new(semconv): + # http semconv transition: new error.type + span_attributes[ERROR_TYPE] = str(status_code) + + if http_version and _report_new(semconv): + # http semconv transition: http.flavor -> network.protocol.version + _set_http_network_protocol_version( + span_attributes, + http_version.replace("HTTP/", ""), + semconv, + ) + + for key, val in span_attributes.items(): + span.set_attribute(key, val) + + class SyncOpenTelemetryTransport(httpx.BaseTransport): """Sync transport class that will trace all requests made with a client. @@ -318,12 +399,17 @@ def __init__( request_hook: typing.Optional[RequestHook] = None, response_hook: typing.Optional[ResponseHook] = None, ): + _OpenTelemetrySemanticConventionStability._initialize() + self._sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + self._transport = transport self._tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url(self._sem_conv_opt_in_mode), ) self._request_hook = request_hook self._response_hook = response_hook @@ -340,6 +426,7 @@ def __exit__( ) -> None: self._transport.__exit__(exc_type, exc_value, traceback) + # pylint: disable=R0914 def handle_request( self, *args, @@ -355,39 +442,64 @@ def handle_request( method, url, headers, stream, extensions = _extract_parameters( args, kwargs ) - span_attributes = _prepare_attributes(method, url) + method_original = method.decode() + span_name = _get_default_span_name(method_original) + span_attributes = {} + # apply http client response attributes according to semconv + _apply_request_client_attributes_to_span( + span_attributes, + url, + method_original, + span_name, + self._sem_conv_opt_in_mode, + ) request_info = RequestInfo(method, url, headers, stream, extensions) - span_name = _get_default_span_name( - span_attributes[SpanAttributes.HTTP_METHOD] - ) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: - if self._request_hook is not None: + exception = None + if callable(self._request_hook): self._request_hook(span, request_info) _inject_propagation_headers(headers, args, kwargs) - response = self._transport.handle_request(*args, **kwargs) - if isinstance(response, httpx.Response): - response: httpx.Response = response - status_code = response.status_code - headers = response.headers - stream = response.stream - extensions = response.extensions - else: - status_code, headers, stream, extensions = response - - _apply_status_code(span, status_code) - - if self._response_hook is not None: - self._response_hook( - span, - request_info, - ResponseInfo(status_code, headers, stream, extensions), + + try: + response = self._transport.handle_request(*args, **kwargs) + except Exception as exc: # pylint: disable=W0703 + exception = exc + response = getattr(exc, "response", None) + + if isinstance(response, (httpx.Response, tuple)): + status_code, headers, stream, extensions, http_version = ( + _extract_response(response) ) + if span.is_recording(): + # apply http client response attributes according to semconv + _apply_response_client_attributes_to_span( + span, + status_code, + http_version, + self._sem_conv_opt_in_mode, + ) + if callable(self._response_hook): + self._response_hook( + span, + request_info, + ResponseInfo(status_code, headers, stream, extensions), + ) + + if exception: + if span.is_recording() and _report_new( + self._sem_conv_opt_in_mode + ): + span.set_attribute( + ERROR_TYPE, type(exception).__qualname__ + ) + raise exception.with_traceback(exception.__traceback__) + return response def close(self) -> None: @@ -413,12 +525,17 @@ def __init__( request_hook: typing.Optional[AsyncRequestHook] = None, response_hook: typing.Optional[AsyncResponseHook] = None, ): + _OpenTelemetrySemanticConventionStability._initialize() + self._sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, + ) + self._transport = transport self._tracer = get_tracer( __name__, instrumenting_library_version=__version__, tracer_provider=tracer_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url(self._sem_conv_opt_in_mode), ) self._request_hook = request_hook self._response_hook = response_hook @@ -435,6 +552,7 @@ async def __aexit__( ) -> None: await self._transport.__aexit__(exc_type, exc_value, traceback) + # pylint: disable=R0914 async def handle_async_request(self, *args, **kwargs) -> typing.Union[ typing.Tuple[int, "Headers", httpx.AsyncByteStream, dict], httpx.Response, @@ -446,41 +564,66 @@ async def handle_async_request(self, *args, **kwargs) -> typing.Union[ method, url, headers, stream, extensions = _extract_parameters( args, kwargs ) - span_attributes = _prepare_attributes(method, url) - - span_name = _get_default_span_name( - span_attributes[SpanAttributes.HTTP_METHOD] + method_original = method.decode() + span_name = _get_default_span_name(method_original) + span_attributes = {} + # apply http client response attributes according to semconv + _apply_request_client_attributes_to_span( + span_attributes, + url, + method_original, + span_name, + self._sem_conv_opt_in_mode, ) + request_info = RequestInfo(method, url, headers, stream, extensions) with self._tracer.start_as_current_span( span_name, kind=SpanKind.CLIENT, attributes=span_attributes ) as span: - if self._request_hook is not None: + exception = None + if callable(self._request_hook): await self._request_hook(span, request_info) _inject_propagation_headers(headers, args, kwargs) - response = await self._transport.handle_async_request( - *args, **kwargs - ) - if isinstance(response, httpx.Response): - response: httpx.Response = response - status_code = response.status_code - headers = response.headers - stream = response.stream - extensions = response.extensions - else: - status_code, headers, stream, extensions = response - - _apply_status_code(span, status_code) - - if self._response_hook is not None: - await self._response_hook( - span, - request_info, - ResponseInfo(status_code, headers, stream, extensions), + try: + response = await self._transport.handle_async_request( + *args, **kwargs ) + except Exception as exc: # pylint: disable=W0703 + exception = exc + response = getattr(exc, "response", None) + + if isinstance(response, (httpx.Response, tuple)): + status_code, headers, stream, extensions, http_version = ( + _extract_response(response) + ) + + if span.is_recording(): + # apply http client response attributes according to semconv + _apply_response_client_attributes_to_span( + span, + status_code, + http_version, + self._sem_conv_opt_in_mode, + ) + + if callable(self._response_hook): + await self._response_hook( + span, + request_info, + ResponseInfo(status_code, headers, stream, extensions), + ) + + if exception: + if span.is_recording() and _report_new( + self._sem_conv_opt_in_mode + ): + span.set_attribute( + ERROR_TYPE, type(exception).__qualname__ + ) + raise exception.with_traceback(exception.__traceback__) return response diff --git a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py index 4e548655b6..633e01c8b1 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/src/opentelemetry/instrumentation/httpx/package.py @@ -14,3 +14,7 @@ _instruments = ("httpx >= 0.18.0",) + +_supports_metrics = False + +_semconv_status = "migration" diff --git a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py index 06ad963ab0..84bab598e6 100644 --- a/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py +++ b/instrumentation/opentelemetry-instrumentation-httpx/tests/test_httpx_integration.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +# pylint: disable=too-many-lines + import abc import asyncio import typing @@ -22,6 +24,10 @@ import opentelemetry.instrumentation.httpx from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) from opentelemetry.instrumentation.httpx import ( AsyncOpenTelemetryTransport, HTTPXClientInstrumentor, @@ -30,6 +36,21 @@ from opentelemetry.instrumentation.utils import suppress_http_instrumentation from opentelemetry.propagate import get_global_textmap, set_global_textmap from opentelemetry.sdk import resources +from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE +from opentelemetry.semconv.attributes.http_attributes import ( + HTTP_REQUEST_METHOD, + HTTP_RESPONSE_STATUS_CODE, +) +from opentelemetry.semconv.attributes.network_attributes import ( + NETWORK_PEER_ADDRESS, + NETWORK_PEER_PORT, + NETWORK_PROTOCOL_VERSION, +) +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) +from opentelemetry.semconv.attributes.url_attributes import URL_FULL from opentelemetry.semconv.trace import SpanAttributes from opentelemetry.test.mock_textmap import MockTextMapPropagator from opentelemetry.test.test_base import TestBase @@ -100,6 +121,9 @@ async def _async_no_update_request_hook(span: "Span", request: "RequestInfo"): return 123 +# pylint: disable=too-many-public-methods + + # Using this wrapper class to have a base class for the tests while also not # angering pylint or mypy when calling methods not in the class when only # subclassing abc.ABC. @@ -112,15 +136,39 @@ class BaseTest(TestBase, metaclass=abc.ABCMeta): request_hook = staticmethod(_request_hook) no_update_request_hook = staticmethod(_no_update_request_hook) + # TODO: make this more explicit to tests # pylint: disable=invalid-name def setUp(self): super().setUp() + test_name = "" + if hasattr(self, "_testMethodName"): + test_name = self._testMethodName + sem_conv_mode = "default" + if "new_semconv" in test_name: + sem_conv_mode = "http" + elif "both_semconv" in test_name: + sem_conv_mode = "http/dup" + self.env_patch = mock.patch.dict( + "os.environ", + { + OTEL_SEMCONV_STABILITY_OPT_IN: sem_conv_mode, + }, + ) + self.env_patch.start() + _OpenTelemetrySemanticConventionStability._initialized = False respx.start() - respx.get(self.URL).mock(httpx.Response(200, text="Hello!")) + respx.get(self.URL).mock( + httpx.Response( + 200, + text="Hello!", + extensions={"http_version": b"HTTP/1.1"}, + ) + ) # pylint: disable=invalid-name def tearDown(self): super().tearDown() + self.env_patch.stop() respx.stop() def assert_span( @@ -169,6 +217,87 @@ def test_basic(self): span, opentelemetry.instrumentation.httpx ) + def test_basic_new_semconv(self): + url = "http://mock:8080/status/200" + respx.get(url).mock( + httpx.Response( + 200, + text="Hello!", + extensions={"http_version": b"HTTP/1.1"}, + ) + ) + result = self.perform_request(url) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + SpanAttributes.SCHEMA_URL, + ) + self.assertEqual( + span.attributes, + { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url, + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + HTTP_RESPONSE_STATUS_CODE: 200, + NETWORK_PROTOCOL_VERSION: "1.1", + SERVER_PORT: 8080, + NETWORK_PEER_PORT: 8080, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationInfo( + span, opentelemetry.instrumentation.httpx + ) + + def test_basic_both_semconv(self): + url = "http://mock:8080/status/200" # 8080 because httpx returns None for common ports (http, https, wss) + respx.get(url).mock(httpx.Response(200, text="Hello!")) + result = self.perform_request(url) + self.assertEqual(result.text, "Hello!") + span = self.assert_span() + + self.assertIs(span.kind, trace.SpanKind.CLIENT) + self.assertEqual(span.name, "GET") + + self.assertEqual( + span.instrumentation_scope.schema_url, + SpanAttributes.SCHEMA_URL, + ) + + self.assertEqual( + span.attributes, + { + SpanAttributes.HTTP_METHOD: "GET", + HTTP_REQUEST_METHOD: "GET", + SpanAttributes.HTTP_URL: url, + URL_FULL: url, + SpanAttributes.HTTP_HOST: "mock", + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + SpanAttributes.NET_PEER_PORT: 8080, + SpanAttributes.HTTP_STATUS_CODE: 200, + HTTP_RESPONSE_STATUS_CODE: 200, + SpanAttributes.HTTP_FLAVOR: "1.1", + NETWORK_PROTOCOL_VERSION: "1.1", + SERVER_PORT: 8080, + NETWORK_PEER_PORT: 8080, + }, + ) + + self.assertIs(span.status.status_code, trace.StatusCode.UNSET) + + self.assertEqualSpanInstrumentationInfo( + span, opentelemetry.instrumentation.httpx + ) + def test_basic_multiple(self): self.perform_request(self.URL) self.perform_request(self.URL) @@ -191,6 +320,48 @@ def test_not_foundbasic(self): trace.StatusCode.ERROR, ) + def test_not_foundbasic_new_semconv(self): + url_404 = "http://mock/status/404" + + with respx.mock: + respx.get(url_404).mock(httpx.Response(404)) + result = self.perform_request(url_404) + + self.assertEqual(result.status_code, 404) + span = self.assert_span() + self.assertEqual( + span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404 + ) + # new in semconv + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + + def test_not_foundbasic_both_semconv(self): + url_404 = "http://mock/status/404" + + with respx.mock: + respx.get(url_404).mock(httpx.Response(404)) + result = self.perform_request(url_404) + + self.assertEqual(result.status_code, 404) + span = self.assert_span() + self.assertEqual( + span.attributes.get(SpanAttributes.HTTP_STATUS_CODE), 404 + ) + self.assertEqual( + span.attributes.get(HTTP_RESPONSE_STATUS_CODE), 404 + ) + self.assertEqual(span.attributes.get(ERROR_TYPE), "404") + + self.assertIs( + span.status.status_code, + trace.StatusCode.ERROR, + ) + def test_suppress_instrumentation(self): with suppress_http_instrumentation(): result = self.perform_request(self.URL) @@ -245,6 +416,83 @@ def test_requests_basic_exception(self): span = self.assert_span() self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertIn("Exception", span.status.description) + self.assertEqual( + span.events[0].attributes["exception.type"], "Exception" + ) + self.assertIsNone(span.attributes.get(ERROR_TYPE)) + + def test_requests_basic_exception_new_semconv(self): + with respx.mock, self.assertRaises(Exception): + respx.get(self.URL).mock(side_effect=Exception) + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertIn("Exception", span.status.description) + self.assertEqual( + span.events[0].attributes["exception.type"], "Exception" + ) + self.assertEqual(span.attributes.get(ERROR_TYPE), "Exception") + + def test_requests_basic_exception_both_semconv(self): + with respx.mock, self.assertRaises(Exception): + respx.get(self.URL).mock(side_effect=Exception) + self.perform_request(self.URL) + + span = self.assert_span() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertIn("Exception", span.status.description) + self.assertEqual( + span.events[0].attributes["exception.type"], "Exception" + ) + self.assertEqual(span.attributes.get(ERROR_TYPE), "Exception") + + def test_requests_timeout_exception_new_semconv(self): + url = "http://mock:8080/exception" + with respx.mock, self.assertRaises(httpx.TimeoutException): + respx.get(url).mock(side_effect=httpx.TimeoutException) + self.perform_request(url) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + HTTP_REQUEST_METHOD: "GET", + URL_FULL: url, + SERVER_ADDRESS: "mock", + SERVER_PORT: 8080, + NETWORK_PEER_PORT: 8080, + NETWORK_PEER_ADDRESS: "mock", + ERROR_TYPE: "TimeoutException", + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_requests_timeout_exception_both_semconv(self): + url = "http://mock:8080/exception" + with respx.mock, self.assertRaises(httpx.TimeoutException): + respx.get(url).mock(side_effect=httpx.TimeoutException) + self.perform_request(url) + + span = self.assert_span() + self.assertEqual( + span.attributes, + { + SpanAttributes.HTTP_METHOD: "GET", + HTTP_REQUEST_METHOD: "GET", + SpanAttributes.HTTP_URL: url, + URL_FULL: url, + SpanAttributes.HTTP_HOST: "mock", + SERVER_ADDRESS: "mock", + NETWORK_PEER_ADDRESS: "mock", + SpanAttributes.NET_PEER_PORT: 8080, + SERVER_PORT: 8080, + NETWORK_PEER_PORT: 8080, + ERROR_TYPE: "TimeoutException", + }, + ) + self.assertEqual(span.status.status_code, StatusCode.ERROR) def test_requests_timeout_exception(self): with respx.mock, self.assertRaises(httpx.TimeoutException): @@ -373,6 +621,28 @@ def test_not_recording(self): self.assertFalse(mock_span.set_attribute.called) self.assertFalse(mock_span.set_status.called) + @respx.mock + def test_not_recording_not_set_attribute_in_exception_new_semconv( + self, + ): + respx.get(self.URL).mock(side_effect=httpx.TimeoutException) + with mock.patch("opentelemetry.trace.INVALID_SPAN") as mock_span: + transport = self.create_transport( + tracer_provider=trace.NoOpTracerProvider() + ) + client = self.create_client(transport) + mock_span.is_recording.return_value = False + try: + self.perform_request(self.URL, client=client) + except httpx.TimeoutException: + pass + + self.assert_span(None, 0) + self.assertFalse(mock_span.is_recording()) + self.assertTrue(mock_span.is_recording.called) + self.assertFalse(mock_span.set_attribute.called) + self.assertFalse(mock_span.set_status.called) + class BaseInstrumentorTest(BaseTest, metaclass=abc.ABCMeta): @abc.abstractmethod def create_client(