HTTP semantic convention stability migration for httpx (#2631)

This commit is contained in:
Emídio Neto
2024-07-03 16:43:47 -03:00
committed by GitHub
parent c9bad6269c
commit 7da7f554ea
5 changed files with 488 additions and 69 deletions

View File

@ -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))

View File

@ -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

View File

@ -196,6 +196,19 @@ from types import TracebackType
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 @@ from opentelemetry.instrumentation.utils import (
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()
method = sanitize_method(method.upper().strip())
if method == "_OTHER":
method = "HTTP"
def _apply_status_code(span: Span, status_code: int) -> None:
if not span.is_recording():
return
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 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
) -> None:
self._transport.__exit__(exc_type, exc_value, traceback)
# pylint: disable=R0914
def handle_request(
self,
*args,
@ -355,39 +442,64 @@ class SyncOpenTelemetryTransport(httpx.BaseTransport):
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)
try:
response = self._transport.handle_request(*args, **kwargs)
except Exception as exc: # pylint: disable=W0703
exception = exc
response = getattr(exc, "response", None)
if self._response_hook is not None:
self._response_hook(
span,
request_info,
ResponseInfo(status_code, headers, stream, extensions),
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 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
) -> 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 @@ class AsyncOpenTelemetryTransport(httpx.AsyncBaseTransport):
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

View File

@ -14,3 +14,7 @@
_instruments = ("httpx >= 0.18.0",)
_supports_metrics = False
_semconv_status = "migration"

View File

@ -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 respx
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.httpx import (
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 BaseTestCases:
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 @@ class BaseTestCases:
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 @@ class BaseTestCases:
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 @@ class BaseTestCases:
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 @@ class BaseTestCases:
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(