From 002279b8d30b2895d0fdb09d0e93a7942d010e7b Mon Sep 17 00:00:00 2001 From: Andrew Xue Date: Mon, 18 May 2020 22:20:13 -0400 Subject: [PATCH] Add Cloud Trace Exporter --- docs-requirements.txt | 5 + docs/examples/cloud_trace_exporter/README.rst | 34 ++ .../cloud_trace_exporter/basic_trace.py | 14 + docs/ext/cloud_trace/cloud_trace.rst | 7 + .../README.rst | 43 +++ .../setup.cfg | 11 +- .../setup.py | 4 +- .../exporter/cloud_trace/__init__.py | 299 ++++++++++++++++++ .../exporter/cloud_trace}/version.py | 4 +- .../tests/__init__.py | 0 .../tests/test_cloud_trace_exporter.py | 256 +++++++++++++++ .../src/opentelemetry/ext/datadog/exporter.py | 3 +- .../tests/test_datadog_exporter.py | 1 - .../opentelemetry/ext/requests/__init__.py | 13 +- ext/opentelemetry-ext-stackdriver/README.rst | 44 --- .../examples/client.py | 32 -- .../examples/server.py | 44 --- .../examples/trace.py | 25 -- .../ext/stackdriver/trace/__init__.py | 281 ---------------- .../tests/test_stackdriver_exporter.py | 113 ------- scripts/coverage.sh | 2 +- tox.ini | 2 +- 22 files changed, 681 insertions(+), 556 deletions(-) create mode 100644 docs/examples/cloud_trace_exporter/README.rst create mode 100644 docs/examples/cloud_trace_exporter/basic_trace.py create mode 100644 docs/ext/cloud_trace/cloud_trace.rst create mode 100644 ext/opentelemetry-exporter-cloud-trace/README.rst rename ext/{opentelemetry-ext-stackdriver => opentelemetry-exporter-cloud-trace}/setup.cfg (85%) rename ext/{opentelemetry-ext-stackdriver => opentelemetry-exporter-cloud-trace}/setup.py (87%) create mode 100644 ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/__init__.py rename ext/{opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver => opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace}/version.py (89%) rename ext/{opentelemetry-ext-stackdriver => opentelemetry-exporter-cloud-trace}/tests/__init__.py (100%) create mode 100644 ext/opentelemetry-exporter-cloud-trace/tests/test_cloud_trace_exporter.py delete mode 100644 ext/opentelemetry-ext-stackdriver/README.rst delete mode 100644 ext/opentelemetry-ext-stackdriver/examples/client.py delete mode 100644 ext/opentelemetry-ext-stackdriver/examples/server.py delete mode 100644 ext/opentelemetry-ext-stackdriver/examples/trace.py delete mode 100644 ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/trace/__init__.py delete mode 100644 ext/opentelemetry-ext-stackdriver/tests/test_stackdriver_exporter.py diff --git a/docs-requirements.txt b/docs-requirements.txt index 358316fa7e7..adb7b696f3f 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -19,3 +19,8 @@ redis>=2.6 sqlalchemy>=1.0 thrift>=0.10.0 wrapt >=1.0.0,<2.0.0 +google-cloud-core >=1.3.0 +google-api-core >=1.17.0 +google-cloud-trace >=0.23.0 +google-cloud-monitoring >=0.36.0 +grpcio >=1.28.1 \ No newline at end of file diff --git a/docs/examples/cloud_trace_exporter/README.rst b/docs/examples/cloud_trace_exporter/README.rst new file mode 100644 index 00000000000..871422356a7 --- /dev/null +++ b/docs/examples/cloud_trace_exporter/README.rst @@ -0,0 +1,34 @@ +Cloud Trace Exporter Example +============================ + +These examples show how to use OpenTelemetry to send tracing data to Cloud Trace. + + +Basic Example +------------- + +To use this exporter you first need to: + * A Google Cloud project. You can `create one here. `_ + * Enable Cloud Trace API (aka StackDriver Trace API) in the project `here. `_ + * Enable `Default Application Credentials. `_ + +* Installation + +.. code-block:: sh + + pip install opentelemetry-api + pip install opentelemetry-sdk + pip install opentelemetry-exporter-cloud-trace + +* Run example + +.. code-block:: sh + + python basic_trace.py + +Checking Output +-------------------------- + +After running any of these examples, you can go to `Cloud Trace overview `_ to see the results. + +* `More information about exporters in general `_ \ No newline at end of file diff --git a/docs/examples/cloud_trace_exporter/basic_trace.py b/docs/examples/cloud_trace_exporter/basic_trace.py new file mode 100644 index 00000000000..76840a291ec --- /dev/null +++ b/docs/examples/cloud_trace_exporter/basic_trace.py @@ -0,0 +1,14 @@ +from opentelemetry import trace +from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor + +trace.set_tracer_provider(TracerProvider()) + +cloud_trace_exporter = CloudTraceSpanExporter() +trace.get_tracer_provider().add_span_processor( + SimpleExportSpanProcessor(cloud_trace_exporter) +) +tracer = trace.get_tracer(__name__) +with tracer.start_as_current_span("foo"): + print("Hello world!") diff --git a/docs/ext/cloud_trace/cloud_trace.rst b/docs/ext/cloud_trace/cloud_trace.rst new file mode 100644 index 00000000000..5914b00d1a4 --- /dev/null +++ b/docs/ext/cloud_trace/cloud_trace.rst @@ -0,0 +1,7 @@ +OpenTelemetry Cloud Trace Exporter +================================== + +.. automodule:: opentelemetry.exporter.cloud_trace + :members: + :undoc-members: + :show-inheritance: diff --git a/ext/opentelemetry-exporter-cloud-trace/README.rst b/ext/opentelemetry-exporter-cloud-trace/README.rst new file mode 100644 index 00000000000..001f163007e --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/README.rst @@ -0,0 +1,43 @@ +OpenTelemetry Cloud Trace Exporters +=================================== + +This library provides classes for exporting trace data to Google Cloud Trace. + +Installation +------------ + +:: + + pip install opentelemetry-exporter-cloud-trace + +Usage +----- + +.. code:: python + + from opentelemetry import trace + from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import ( + SimpleExportSpanProcessor, + ) + + trace.set_tracer_provider(TracerProvider()) + + cloud_trace_exporter = CloudTraceSpanExporter( + project_id='my-gcloud-project', + ) + trace.get_tracer_provider().add_span_processor( + SimpleExportSpanProcessor(cloud_trace_exporter) + ) + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span('foo'): + print('Hello world!') + + + +References +---------- + +* `Cloud Trace `_ +* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-stackdriver/setup.cfg b/ext/opentelemetry-exporter-cloud-trace/setup.cfg similarity index 85% rename from ext/opentelemetry-ext-stackdriver/setup.cfg rename to ext/opentelemetry-exporter-cloud-trace/setup.cfg index d3307eae772..df6c2ce587b 100644 --- a/ext/opentelemetry-ext-stackdriver/setup.cfg +++ b/ext/opentelemetry-exporter-cloud-trace/setup.cfg @@ -1,4 +1,4 @@ -# Copyright 2019, OpenTelemetry Authors +# Copyright OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,17 +13,17 @@ # limitations under the License. # [metadata] -name = opentelemetry-ext-stackdriver -description = Stackdriver integration for OpenTelemetry +name = opentelemetry-exporter-cloud-trace +description = Cloud Trace integration for OpenTelemetry long_description = file: README.rst long_description_content_type = text/x-rst author = OpenTelemetry Authors author_email = cncf-opentelemetry-contributors@lists.cncf.io -url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-ext-stackdriver +url = https://github.com/open-telemetry/opentelemetry-python/ext/opentelemetry-exporter-cloud-trace platforms = any license = Apache-2.0 classifiers = - Development Status :: 3 - Alpha + Development Status :: 4 - Beta Intended Audience :: Developers License :: OSI Approved :: Apache Software License Programming Language :: Python @@ -41,7 +41,6 @@ packages=find_namespace: install_requires = opentelemetry-api opentelemetry-sdk - google-cloud-monitoring google-cloud-trace [options.packages.find] diff --git a/ext/opentelemetry-ext-stackdriver/setup.py b/ext/opentelemetry-exporter-cloud-trace/setup.py similarity index 87% rename from ext/opentelemetry-ext-stackdriver/setup.py rename to ext/opentelemetry-exporter-cloud-trace/setup.py index 8d43c44ffde..332cf41d01c 100644 --- a/ext/opentelemetry-ext-stackdriver/setup.py +++ b/ext/opentelemetry-exporter-cloud-trace/setup.py @@ -1,4 +1,4 @@ -# Copyright 2019, OpenTelemetry Authors +# Copyright OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ BASE_DIR = os.path.dirname(__file__) VERSION_FILENAME = os.path.join( - BASE_DIR, "src", "opentelemetry", "ext", "stackdriver", "version.py" + BASE_DIR, "src", "opentelemetry", "exporter", "cloud_trace", "version.py" ) PACKAGE_INFO = {} with open(VERSION_FILENAME) as f: diff --git a/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/__init__.py b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/__init__.py new file mode 100644 index 00000000000..12a0cc01304 --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/__init__.py @@ -0,0 +1,299 @@ +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Cloud Trace Span Exporter for OpenTelemetry. Uses Cloud Trace Client's REST +API to export traces and spans for viewing in Cloud Trace. + +Usage +----- + +.. code-block:: python + + from opentelemetry import trace + from opentelemetry.exporter.cloud_trace import CloudTraceSpanExporter + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor + + trace.set_tracer_provider(TracerProvider()) + + cloud_trace_exporter = CloudTraceSpanExporter() + trace.get_tracer_provider().add_span_processor( + SimpleExportSpanProcessor(cloud_trace_exporter) + ) + tracer = trace.get_tracer(__name__) + with tracer.start_as_current_span("foo"): + print("Hello world!") + + +API +--- +""" + +import logging +from typing import Any, Dict, List, Sequence, Tuple + +import google.auth +from google.cloud.trace_v2 import TraceServiceClient +from google.cloud.trace_v2.proto.trace_pb2 import AttributeValue +from google.cloud.trace_v2.proto.trace_pb2 import Span as ProtoSpan +from google.cloud.trace_v2.proto.trace_pb2 import TruncatableString +from google.rpc.status_pb2 import Status + +import opentelemetry.trace as trace_api +from opentelemetry.sdk.trace import Event +from opentelemetry.sdk.trace.export import Span, SpanExporter, SpanExportResult +from opentelemetry.util import types + +logger = logging.getLogger(__name__) + +# Max length is 128 bytes for a truncatable string. +MAX_LENGTH = 128 + + +class CloudTraceSpanExporter(SpanExporter): + """Cloud Trace span exporter for OpenTelemetry. + + Args: + project_id: ID of the cloud project that will receive the traces. + client: Cloud Trace client. If not given, will be taken from gcloud + default credentials + """ + + def __init__( + self, project_id=None, client=None, + ): + self.client = client or TraceServiceClient() + if not project_id: + _, self.project_id = google.auth.default() + else: + self.project_id = project_id + + def export(self, spans: Sequence[Span]) -> SpanExportResult: + """Export the spans to Cloud Trace. + + See: https://cloud.google.com/trace/docs/reference/v2/rest/v2/projects.traces/batchWrite + + Args: + spans: Tuple of spans to export + """ + cloud_trace_spans = [] + for span in self._translate_to_cloud_trace(spans): + try: + cloud_trace_spans.append(self.client.create_span(**span)) + # pylint: disable=broad-except + except Exception as ex: + logger.error("Error when creating span %s", span, exc_info=ex) + + try: + self.client.batch_write_spans( + "projects/{}".format(self.project_id), cloud_trace_spans, + ) + # pylint: disable=broad-except + except Exception as ex: + logger.error("Error while writing to Cloud Trace", exc_info=ex) + return SpanExportResult.FAILURE + + return SpanExportResult.SUCCESS + + def _translate_to_cloud_trace( + self, spans: Sequence[Span] + ) -> List[Dict[str, Any]]: + """Translate the spans to Cloud Trace format. + + Args: + spans: Tuple of spans to convert + """ + + cloud_trace_spans = [] + + for span in spans: + ctx = span.get_context() + trace_id = _get_hexadecimal_trace_id(ctx.trace_id) + span_id = _get_hexadecimal_span_id(ctx.span_id) + span_name = "projects/{}/traces/{}/spans/{}".format( + self.project_id, trace_id, span_id + ) + + parent_id = None + if span.parent: + parent_id = _get_hexadecimal_span_id(span.parent.span_id) + + start_time = _get_time_from_ns(span.start_time) + end_time = _get_time_from_ns(span.end_time) + + attributes = _extract_attributes(span.attributes) + + cloud_trace_spans.append( + { + "name": span_name, + "span_id": span_id, + "display_name": _get_truncatable_str_object(span.name), + "start_time": start_time, + "end_time": end_time, + "parent_span_id": parent_id, + "attributes": attributes, + "links": _extract_links(span.links), + "status": _extract_status(span.status), + "time_events": _extract_events(span.events), + } + ) + # TODO: Leverage more of the Cloud Trace API, e.g. + # same_process_as_parent_span and child_span_count + + return cloud_trace_spans + + def shutdown(self): + pass + + +def _get_hexadecimal_trace_id(trace_id: int) -> str: + return "{:032x}".format(trace_id) + + +def _get_hexadecimal_span_id(span_id: int) -> str: + return "{:016x}".format(span_id) + + +def _get_time_from_ns(nanoseconds: int) -> Dict: + """Given epoch nanoseconds, split into epoch milliseconds and remaining + nanoseconds""" + if not nanoseconds: + return None + seconds, nanos = divmod(nanoseconds, 1e9) + return {"seconds": int(seconds), "nanos": int(nanos)} + + +def _get_truncatable_str_object( + str_to_convert: str, max_length: int = MAX_LENGTH +): + """Truncate the string if it exceeds the length limit and record the + truncated bytes count.""" + truncated, truncated_byte_count = _truncate_str(str_to_convert, max_length) + + return TruncatableString( + value=truncated, truncated_byte_count=truncated_byte_count + ) + + +def _truncate_str( + str_to_check: str, limit: int = MAX_LENGTH +) -> Tuple[str, int]: + """Check the length of a string. If exceeds limit, then truncate it.""" + str_bytes = str_to_check.encode("utf-8") + str_len = len(str_bytes) + truncated_byte_count = 0 + + if str_len > limit: + truncated_byte_count = str_len - limit + str_bytes = str_bytes[:limit] + + result = str(str_bytes.decode("utf-8", errors="ignore")) + + return result, truncated_byte_count + + +def _extract_status(status: trace_api.Status) -> Status: + """Convert a Status object to protobuf object.""" + if not status: + return None + status_dict = {"details": None, "code": status.canonical_code.value} + + if status.description is not None: + status_dict["message"] = status.description + + return Status(**status_dict) + + +def _extract_links(links: Sequence[trace_api.Link]) -> ProtoSpan.Links: + """Convert span.links""" + if not links: + return None + extracted_links = [] + for link in links: + trace_id = _get_hexadecimal_trace_id(link.context.trace_id) + span_id = _get_hexadecimal_span_id(link.context.span_id) + extracted_links.append( + { + "trace_id": trace_id, + "span_id": span_id, + "type": "TYPE_UNSPECIFIED", + "attributes": _extract_attributes(link.attributes), + } + ) + return ProtoSpan.Links(link=extracted_links, dropped_links_count=0) + + +def _extract_events(events: Sequence[Event]) -> ProtoSpan.TimeEvents: + """Convert span.events to dict.""" + if not events: + return None + logs = [] + for event in events: + if len(event.attributes) > 4: + logger.warning( + "Event %s has more then 4 attributes, some will be truncated", + event.name, + ) + logs.append( + { + "time": _get_time_from_ns(event.timestamp), + "annotation": { + "description": _get_truncatable_str_object( + event.name, 256 + ), + "attributes": _extract_attributes(event.attributes), + }, + } + ) + return ProtoSpan.TimeEvents( + time_event=logs, + dropped_annotations_count=0, + dropped_message_events_count=0, + ) + + +def _extract_attributes(attrs: types.Attributes) -> ProtoSpan.Attributes: + """Convert span.attributes to dict.""" + attributes_dict = {} + + for key, value in attrs.items(): + key = _truncate_str(key)[0] + value = _format_attribute_value(value) + + if value is not None: + attributes_dict[key] = value + return ProtoSpan.Attributes(attribute_map=attributes_dict) + + +def _format_attribute_value(value: types.AttributeValue) -> AttributeValue: + if isinstance(value, bool): + value_type = "bool_value" + elif isinstance(value, int): + value_type = "int_value" + elif isinstance(value, str): + value_type = "string_value" + value = _get_truncatable_str_object(value) + elif isinstance(value, float): + value_type = "string_value" + value = _get_truncatable_str_object("{:0.4f}".format(value)) + else: + logger.warning( + "ignoring attribute value %s of type %s. Values type must be one " + "of bool, int, string or float", + value, + type(value), + ) + return None + + return AttributeValue(**{value_type: value}) diff --git a/ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/version.py b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py similarity index 89% rename from ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/version.py rename to ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py index 93ef792d051..ec792e9af10 100644 --- a/ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/version.py +++ b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py @@ -1,4 +1,4 @@ -# Copyright 2019, OpenTelemetry Authors +# Copyright OpenTelemetry Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "0.3.dev0" +__version__ = "0.8.dev0" diff --git a/ext/opentelemetry-ext-stackdriver/tests/__init__.py b/ext/opentelemetry-exporter-cloud-trace/tests/__init__.py similarity index 100% rename from ext/opentelemetry-ext-stackdriver/tests/__init__.py rename to ext/opentelemetry-exporter-cloud-trace/tests/__init__.py diff --git a/ext/opentelemetry-exporter-cloud-trace/tests/test_cloud_trace_exporter.py b/ext/opentelemetry-exporter-cloud-trace/tests/test_cloud_trace_exporter.py new file mode 100644 index 00000000000..ce68c842e1e --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/tests/test_cloud_trace_exporter.py @@ -0,0 +1,256 @@ +# Copyright OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from unittest import mock + +from google.cloud.trace_v2.proto.trace_pb2 import AttributeValue +from google.cloud.trace_v2.proto.trace_pb2 import Span as ProtoSpan +from google.cloud.trace_v2.proto.trace_pb2 import TruncatableString +from google.rpc.status_pb2 import Status + +from opentelemetry.exporter.cloud_trace import ( + CloudTraceSpanExporter, + _extract_attributes, + _extract_events, + _extract_links, + _extract_status, + _truncate_str, +) +from opentelemetry.sdk.trace import Event, Span +from opentelemetry.trace import DefaultSpan, Link, SpanContext, SpanKind +from opentelemetry.trace.status import Status as SpanStatus +from opentelemetry.trace.status import StatusCanonicalCode + + +class TestCloudTraceSpanExporter(unittest.TestCase): + def setUp(self): + self.client_patcher = mock.patch( + "opentelemetry.exporter.cloud_trace.TraceServiceClient" + ) + self.client_patcher.start() + self.project_id = "PROJECT" + self.attributes_variety_pack = { + "str_key": "str_value", + "bool_key": False, + "double_key": 1.421, + "int_key": 123, + "int_key2": 1234, + } + self.extracted_attributes_variety_pack = ProtoSpan.Attributes( + attribute_map={ + "str_key": AttributeValue( + string_value=TruncatableString( + value="str_value", truncated_byte_count=0 + ) + ), + "bool_key": AttributeValue(bool_value=False), + "double_key": AttributeValue( + string_value=TruncatableString( + value="1.421", truncated_byte_count=0 + ) + ), + "int_key": AttributeValue(int_value=123), + "int_key2": AttributeValue(int_value=1234), + } + ) + + def tearDown(self): + self.client_patcher.stop() + + def test_constructor_default(self): + exporter = CloudTraceSpanExporter(self.project_id) + self.assertEqual(exporter.project_id, self.project_id) + + def test_constructor_explicit(self): + client = mock.Mock() + exporter = CloudTraceSpanExporter(self.project_id, client=client) + + self.assertIs(exporter.client, client) + self.assertEqual(exporter.project_id, self.project_id) + + def test_export(self): + trace_id = "6e0c63257de34c92bf9efcd03927272e" + span_id = "95bb5edabd45950f" + span_datas = [ + Span( + name="span_name", + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=False, + ), + parent=None, + kind=SpanKind.INTERNAL, + ) + ] + + cloud_trace_spans = { + "name": "projects/{}/traces/{}/spans/{}".format( + self.project_id, trace_id, span_id + ), + "span_id": span_id, + "parent_span_id": None, + "display_name": TruncatableString( + value="span_name", truncated_byte_count=0 + ), + "attributes": ProtoSpan.Attributes(attribute_map={}), + "links": None, + "status": None, + "time_events": None, + "start_time": None, + "end_time": None, + } + + client = mock.Mock() + + exporter = CloudTraceSpanExporter(self.project_id, client=client) + + exporter.export(span_datas) + + client.create_span.assert_called_with(**cloud_trace_spans) + self.assertTrue(client.create_span.called) + + def test_extract_status(self): + self.assertIsNone(_extract_status(None)) + self.assertEqual( + _extract_status(SpanStatus(canonical_code=StatusCanonicalCode.OK)), + Status(details=None, code=0), + ) + self.assertEqual( + _extract_status( + SpanStatus( + canonical_code=StatusCanonicalCode.UNKNOWN, + description="error_desc", + ) + ), + Status(details=None, code=2, message="error_desc"), + ) + + def test_extract_attributes(self): + self.assertEqual( + _extract_attributes({}), ProtoSpan.Attributes(attribute_map={}) + ) + self.assertEqual( + _extract_attributes(self.attributes_variety_pack), + self.extracted_attributes_variety_pack, + ) + # Test ignoring attributes with illegal value type + self.assertEqual( + _extract_attributes({"illegal_attribute_value": dict()}), + ProtoSpan.Attributes(attribute_map={}), + ) + + def test_extract_events(self): + self.assertIsNone(_extract_events([])) + time_in_ns1 = 1589919268850900051 + time_in_ms_and_ns1 = {"seconds": 1589919268, "nanos": 850899968} + time_in_ns2 = 1589919438550020326 + time_in_ms_and_ns2 = {"seconds": 1589919438, "nanos": 550020352} + event1 = Event( + name="event1", + attributes=self.attributes_variety_pack, + timestamp=time_in_ns1, + ) + event2 = Event( + name="event2", + attributes={"illegal_attr_value": dict()}, + timestamp=time_in_ns2, + ) + self.assertEqual( + _extract_events([event1, event2]), + ProtoSpan.TimeEvents( + time_event=[ + { + "time": time_in_ms_and_ns1, + "annotation": { + "description": TruncatableString( + value="event1", truncated_byte_count=0 + ), + "attributes": self.extracted_attributes_variety_pack, + }, + }, + { + "time": time_in_ms_and_ns2, + "annotation": { + "description": TruncatableString( + value="event2", truncated_byte_count=0 + ), + "attributes": ProtoSpan.Attributes( + attribute_map={} + ), + }, + }, + ] + ), + ) + + def test_extract_links(self): + self.assertIsNone(_extract_links([])) + trace_id = "6e0c63257de34c92bf9efcd03927272e" + span_id1 = "95bb5edabd45950f" + span_id2 = "b6b86ad2915c9ddc" + link1 = Link( + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id1, 16), + is_remote=False, + ), + attributes={}, + ) + link2 = Link( + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id1, 16), + is_remote=False, + ), + attributes=self.attributes_variety_pack, + ) + link3 = Link( + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id2, 16), + is_remote=False, + ), + attributes={"illegal_attr_value": dict(), "int_attr_value": 123}, + ) + self.assertEqual( + _extract_links([link1, link2, link3]), + ProtoSpan.Links( + link=[ + { + "trace_id": trace_id, + "span_id": span_id1, + "type": "TYPE_UNSPECIFIED", + "attributes": ProtoSpan.Attributes(attribute_map={}), + }, + { + "trace_id": trace_id, + "span_id": span_id1, + "type": "TYPE_UNSPECIFIED", + "attributes": self.extracted_attributes_variety_pack, + }, + { + "trace_id": trace_id, + "span_id": span_id2, + "type": "TYPE_UNSPECIFIED", + "attributes": { + "attribute_map": { + "int_attr_value": AttributeValue(int_value=123) + } + }, + }, + ] + ), + ) diff --git a/ext/opentelemetry-ext-datadog/src/opentelemetry/ext/datadog/exporter.py b/ext/opentelemetry-ext-datadog/src/opentelemetry/ext/datadog/exporter.py index 01a01919962..1b3c713b5cb 100644 --- a/ext/opentelemetry-ext-datadog/src/opentelemetry/ext/datadog/exporter.py +++ b/ext/opentelemetry-ext-datadog/src/opentelemetry/ext/datadog/exporter.py @@ -16,11 +16,10 @@ import os from urllib.parse import urlparse +import opentelemetry.trace as trace_api from ddtrace.ext import SpanTypes as DatadogSpanTypes from ddtrace.internal.writer import AgentWriter from ddtrace.span import Span as DatadogSpan - -import opentelemetry.trace as trace_api from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult from opentelemetry.trace.status import StatusCanonicalCode diff --git a/ext/opentelemetry-ext-datadog/tests/test_datadog_exporter.py b/ext/opentelemetry-ext-datadog/tests/test_datadog_exporter.py index 14fd5507891..a0fe874a179 100644 --- a/ext/opentelemetry-ext-datadog/tests/test_datadog_exporter.py +++ b/ext/opentelemetry-ext-datadog/tests/test_datadog_exporter.py @@ -19,7 +19,6 @@ from unittest import mock from ddtrace.internal.writer import AgentWriter - from opentelemetry import trace as trace_api from opentelemetry.ext import datadog from opentelemetry.sdk import trace diff --git a/ext/opentelemetry-ext-requests/src/opentelemetry/ext/requests/__init__.py b/ext/opentelemetry-ext-requests/src/opentelemetry/ext/requests/__init__.py index 1621c4a95e6..098fc441220 100644 --- a/ext/opentelemetry-ext-requests/src/opentelemetry/ext/requests/__init__.py +++ b/ext/opentelemetry-ext-requests/src/opentelemetry/ext/requests/__init__.py @@ -50,9 +50,15 @@ from opentelemetry import context, propagators, trace from opentelemetry.auto_instrumentation.instrumentor import BaseInstrumentor from opentelemetry.ext.requests.version import __version__ -from opentelemetry.trace import SpanKind, get_tracer +from opentelemetry.trace import SpanKind from opentelemetry.trace.status import Status, StatusCanonicalCode +# StackDriver exporter spins up a new thread (that doesn't inherit the +# "suppress_instrumentation" context) that makes a request call. We need to +# manually blacklist the url to avoid falling into an infinite loop. +# https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/issues/3 +SUPPRESSION_BLACKLIST = ["https://oauth2.googleapis.com/token"] + # pylint: disable=unused-argument def _instrument(tracer_provider=None, span_callback=None): @@ -73,7 +79,10 @@ def _instrument(tracer_provider=None, span_callback=None): @functools.wraps(wrapped) def instrumented_request(self, method, url, *args, **kwargs): - if context.get_value("suppress_instrumentation"): + if ( + context.get_value("suppress_instrumentation") + or url in SUPPRESSION_BLACKLIST + ): return wrapped(self, method, url, *args, **kwargs) # See diff --git a/ext/opentelemetry-ext-stackdriver/README.rst b/ext/opentelemetry-ext-stackdriver/README.rst deleted file mode 100644 index fcf2a08e014..00000000000 --- a/ext/opentelemetry-ext-stackdriver/README.rst +++ /dev/null @@ -1,44 +0,0 @@ -OpenTelemetry Stackdriver Exporters -===================================== - -This library provides integration with Google Cloud Stackdriver. - -Installation ------------- - -:: - - pip install opentelemetry-ext-stackdriver - -Usage ------ - -.. code:: python - - from opentelemetry import trace - from opentelemetry.ext import stackdriver - from opentelemetry.sdk.trace import Tracer - from opentelemetry.sdk.trace.export import BatchExportSpanProcessor - - trace.set_preferred_tracer_implementation(lambda T: Tracer()) - tracer = trace.tracer() - - # create a StackdriverSpanExporter - stackdriver_exporter = stackdriver.trace.StackdriverSpanExporter( - project_id='my-helloworld-project', - ) - - # Create a BatchExportSpanProcessor and add the exporter to it - span_processor = BatchExportSpanProcessor(stackdriver_exporter) - - # add to the tracer - tracer.add_span_processor(span_processor) - - with tracer.start_as_current_span('foo'): - print('Hello world!') - -References ----------- - -* `Stackdriver `_ -* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-stackdriver/examples/client.py b/ext/opentelemetry-ext-stackdriver/examples/client.py deleted file mode 100644 index ee6206d9470..00000000000 --- a/ext/opentelemetry-ext-stackdriver/examples/client.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2019, OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import requests - -from opentelemetry import trace -from opentelemetry.ext import http_requests -from opentelemetry.ext.stackdriver.trace import StackdriverSpanExporter -from opentelemetry.sdk.trace import Tracer -from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor - -trace.set_preferred_tracer_implementation(lambda T: Tracer()) -tracer = trace.tracer() -span_processor = SimpleExportSpanProcessor( - StackdriverSpanExporter(project_id="my-helloworld-project") -) -tracer.add_span_processor(span_processor) - -http_requests.enable(tracer) -response = requests.get(url="http://localhost:7777/hello") -span_processor.shutdown() diff --git a/ext/opentelemetry-ext-stackdriver/examples/server.py b/ext/opentelemetry-ext-stackdriver/examples/server.py deleted file mode 100644 index 8cd74a653e3..00000000000 --- a/ext/opentelemetry-ext-stackdriver/examples/server.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2019, OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import flask -import requests - -from opentelemetry import trace -from opentelemetry.ext import http_requests -from opentelemetry.ext.stackdriver.trace import StackdriverSpanExporter -from opentelemetry.ext.wsgi import OpenTelemetryMiddleware -from opentelemetry.sdk.trace import Tracer -from opentelemetry.sdk.trace.export import BatchExportSpanProcessor - -trace.set_preferred_tracer_implementation(lambda T: Tracer()) - -span_processor = BatchExportSpanProcessor(StackdriverSpanExporter()) -http_requests.enable(trace.tracer()) -trace.tracer().add_span_processor(span_processor) - -app = flask.Flask(__name__) -app.wsgi_app = OpenTelemetryMiddleware(app.wsgi_app) - - -@app.route("/") -def hello(): - with trace.tracer().start_as_current_span("parent"): - requests.get("https://www.wikipedia.org/wiki/Rabbit") - return "hello" - - -if __name__ == "__main__": - app.run(debug=True) - span_processor.shutdown() diff --git a/ext/opentelemetry-ext-stackdriver/examples/trace.py b/ext/opentelemetry-ext-stackdriver/examples/trace.py deleted file mode 100644 index 1cdb41ecc0e..00000000000 --- a/ext/opentelemetry-ext-stackdriver/examples/trace.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2019, OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from opentelemetry import trace -from opentelemetry.ext.stackdriver.trace import StackdriverSpanExporter -from opentelemetry.sdk.trace import Tracer -from opentelemetry.sdk.trace.export import SimpleExportSpanProcessor - -trace.set_preferred_tracer_implementation(lambda T: Tracer()) -tracer = trace.tracer() -tracer.add_span_processor(SimpleExportSpanProcessor(StackdriverSpanExporter())) - -with tracer.start_as_current_span("hello") as span: - print("Hello, World!") diff --git a/ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/trace/__init__.py b/ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/trace/__init__.py deleted file mode 100644 index 6995cbe8633..00000000000 --- a/ext/opentelemetry-ext-stackdriver/src/opentelemetry/ext/stackdriver/trace/__init__.py +++ /dev/null @@ -1,281 +0,0 @@ -# Copyright 2019, OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Stackdriver Span Exporter for OpenTelemetry.""" - -import logging -import typing - -from google.cloud.trace import trace_service_client -from google.cloud.trace.client import Client -from google.cloud.trace_v2.proto import trace_pb2 - -import opentelemetry.trace as trace_api -from opentelemetry.context import Context -from opentelemetry.sdk.trace.export import Span, SpanExporter, SpanExportResult -from opentelemetry.sdk.util import ns_to_iso_str -from opentelemetry.util import types - -from ..version import __version__ - -logger = logging.getLogger(__name__) - -AGENT = "opentelemetry-python [{}]".format(__version__) -# Max length is 128 bytes for a truncatable string. -MAX_LENGTH = 128 - - -class StackdriverSpanExporter(SpanExporter): - """Stackdriver span exporter for OpenTelemetry. - - Args: - client: Stackdriver Trace client. - project_id: project_id to create the Trace client. - """ - - def __init__( - self, client=None, project_id=None, - ): - if client is None: - client = Client(project=project_id) - self.client = client - self.project_id = self.client.project - - def export(self, spans: typing.Sequence[Span]) -> SpanExportResult: - """Export the spans to Stackdriver. - - See: https://cloud.google.com/trace/docs/reference/v2/rest/v2/ - projects.traces/batchWrite - - Args: - spans: Tuple of spans to export - """ - stackdriver_spans = self.translate_to_stackdriver(spans) - - try: - self.client.batch_write_spans( - "projects/{}".format(self.project_id), - {"spans": stackdriver_spans}, - ) - except Exception as ex: - logger.warning("Error while writing to stackdriver: %s", ex) - return SpanExportResult.FAILED_RETRYABLE - - return SpanExportResult.SUCCESS - - def translate_to_stackdriver( - self, spans: typing.Sequence[Span] - ) -> typing.List[typing.Dict[str, typing.Any]]: - """Translate the spans to Stackdriver format. - - Args: - spans: Tuple of spans to convert - """ - - stackdriver_spans = [] - - for span in spans: - ctx = span.get_context() - trace_id = "{:032x}".format(ctx.trace_id) - span_id = "{:016x}".format(ctx.span_id) - span_name = "projects/{}/traces/{}/spans/{}".format( - self.project_id, trace_id, span_id - ) - - parent_id = None - if isinstance(span.parent, trace_api.Span): - parent_id = "{:016x}".format(span.parent.get_context().span_id) - elif isinstance(span.parent, trace_api.SpanContext): - parent_id = "{:016x}".format(span.parent.span_id) - - start_time = None - if span.start_time: - start_time = ns_to_iso_str(span.start_time) - end_time = None - if span.end_time: - end_time = ns_to_iso_str(span.end_time) - - span.attributes["g.co/agent"] = AGENT - attr_map = extract_attributes(span.attributes) - - sd_span = { - "name": span_name, - "spanId": span_id, - "parentSpanId": parent_id, - "displayName": get_truncatable_str(span.name), - "attributes": map_attributes(attr_map), - "links": extract_links(span.links), - "status": extract_status(span.status), - "timeEvents": extract_events(span.events), - "startTime": start_time, - "endTime": end_time, - } - - stackdriver_spans.append(sd_span) - - return stackdriver_spans - - def shutdown(self): - pass - - -def get_truncatable_str(str_to_convert): - """Truncate a string if exceed limit and record the truncated bytes - count. - """ - truncated, truncated_byte_count = check_str_length( - str_to_convert, MAX_LENGTH - ) - - result = { - "value": truncated, - "truncated_byte_count": truncated_byte_count, - } - return result - - -def check_str_length(str_to_check, limit=MAX_LENGTH): - """Check the length of a string. If exceeds limit, then truncate it. - """ - str_bytes = str_to_check.encode("utf-8") - str_len = len(str_bytes) - truncated_byte_count = 0 - - if str_len > limit: - truncated_byte_count = str_len - limit - str_bytes = str_bytes[:limit] - - result = str(str_bytes.decode("utf-8", errors="ignore")) - - return (result, truncated_byte_count) - - -def extract_status(status: trace_api.Status): - """Convert a Status object to dict.""" - status_json = {"details": None} - - status_json["code"] = status.canonical_code.value - - if status.description is not None: - status_json["message"] = status.description - - return status_json - - -def extract_links(links): - """Convert span.links to set.""" - if not links: - return None - - links = [] - for link in links: - trace_id = link.context.trace_id - span_id = link.context.span_id - links.append( - {trace_id: trace_id, span_id: span_id, type: "CHILD_LINKED_SPAN"} - ) - return set(links) - - -def extract_events(events): - """Convert span.events to dict.""" - if not events: - return None - - logs = [] - - for event in events: - annotation_json = {"description": get_truncatable_str(event.name)} - if event.attributes is not None: - annotation_json["attributes"] = extract_attributes( - event.attributes - ) - - logs.append( - { - "time": ns_to_iso_str(event.timestamp), - "annotation": annotation_json, - } - ) - return {"timeEvent": logs} - - -def extract_attributes(attrs: types.Attributes): - """Convert span.attributes to dict.""" - attributes_json = {} - - for key, value in attrs.items(): - key = check_str_length(key)[0] - value = _format_attribute_value(value) - - if value is not None: - attributes_json[key] = value - - result = {"attributeMap": attributes_json} - - return result - - -def map_attributes(attribute_map): - """Convert the attributes to stackdriver attributes.""" - if attribute_map is None: - return attribute_map - for (key, value) in attribute_map.items(): - if key != "attributeMap": - continue - for attribute_key in list(value.keys()): - if attribute_key in ATTRIBUTE_MAPPING: - new_key = ATTRIBUTE_MAPPING.get(attribute_key) - value[new_key] = value.pop(attribute_key) - return attribute_map - - -def _format_attribute_value(value): - if isinstance(value, bool): - value_type = "bool_value" - elif isinstance(value, int): - value_type = "int_value" - elif isinstance(value, str): - value_type = "string_value" - value = get_truncatable_str(value) - elif isinstance(value, float): - value_type = "double_value" - else: - return None - - return {value_type: value} - - -ATTRIBUTE_MAPPING = { - "component": "/component", - "error.message": "/error/message", - "error.name": "/error/name", - "http.client_city": "/http/client_city", - "http.client_country": "/http/client_country", - "http.client_protocol": "/http/client_protocol", - "http.client_region": "/http/client_region", - "http.host": "/http/host", - "http.method": "/http/method", - "http.redirected_url": "/http/redirected_url", - "http.request_size": "/http/request/size", - "http.response_size": "/http/response/size", - "http.status_code": "/http/status_code", - "http.url": "/http/url", - "http.user_agent": "/http/user_agent", - "pid": "/pid", - "stacktrace": "/stacktrace", - "tid": "/tid", - "grpc.host_port": "/grpc/host_port", - "grpc.method": "/grpc/method", -} diff --git a/ext/opentelemetry-ext-stackdriver/tests/test_stackdriver_exporter.py b/ext/opentelemetry-ext-stackdriver/tests/test_stackdriver_exporter.py deleted file mode 100644 index d86ef4986a0..00000000000 --- a/ext/opentelemetry-ext-stackdriver/tests/test_stackdriver_exporter.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2019, OpenTelemetry Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest -from unittest import mock - -import opentelemetry.ext.stackdriver.trace as sd_exporter -from opentelemetry.sdk.trace import Span -from opentelemetry.trace import SpanContext, SpanKind -from opentelemetry.util.version import __version__ - - -class TestStackdriverSpanExporter(unittest.TestCase): - def setUp(self): - self.client_patcher = mock.patch( - "opentelemetry.ext.stackdriver.trace.Client" - ) - self.client_patcher.start() - - def tearDown(self): - self.client_patcher.stop() - - def test_constructor_default(self): - exporter = sd_exporter.StackdriverSpanExporter() - self.assertEqual(exporter.project_id, exporter.client.project) - - def test_constructor_explicit(self): - client = mock.Mock() - project_id = "PROJECT" - client.project = project_id - - exporter = sd_exporter.StackdriverSpanExporter( - client=client, project_id=project_id - ) - - self.assertIs(exporter.client, client) - self.assertEqual(exporter.project_id, project_id) - - def test_export(self): - trace_id = "6e0c63257de34c92bf9efcd03927272e" - span_id = "95bb5edabd45950f" - # start_times = 683647322 * 10 ** 9 # in ns - # durations = 50 * 10 ** 6 - # end_times = start_times + durations - span_datas = [ - Span( - name="span_name", - context=SpanContext( - trace_id=int(trace_id, 16), span_id=int(span_id, 16) - ), - parent=None, - kind=SpanKind.INTERNAL, - ) - ] - - stackdriver_spans = { - "spans": [ - { - "name": "projects/PROJECT/traces/{}/spans/{}".format( - trace_id, span_id - ), - "spanId": span_id, - "parentSpanId": None, - "displayName": { - "value": "span_name", - "truncated_byte_count": 0, - }, - "attributes": { - "attributeMap": { - "g.co/agent": { - "string_value": { - "value": "opentelemetry-python [{}]".format( - __version__ - ), - "truncated_byte_count": 0, - } - } - } - }, - "links": None, - "status": {"details": None, "code": 0}, - "timeEvents": None, - "startTime": None, - "endTime": None, - } - ] - } - - client = mock.Mock() - project_id = "PROJECT" - client.project = project_id - - exporter = sd_exporter.StackdriverSpanExporter( - client=client, project_id=project_id - ) - - exporter.export(span_datas) - - name = "projects/{}".format(project_id) - - client.batch_write_spans.assert_called_with(name, stackdriver_spans) - self.assertTrue(client.batch_write_spans.called) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 72da6469a39..aa716f388da 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -24,7 +24,7 @@ cov ext/opentelemetry-ext-flask cov ext/opentelemetry-ext-requests cov ext/opentelemetry-ext-jaeger cov ext/opentelemetry-ext-opentracing-shim -cov ext/opentelemetry-ext-stackdriver +cov ext/opentelemetry-exporter-cloud-trace cov ext/opentelemetry-ext-wsgi cov ext/opentelemetry-ext-zipkin cov docs/examples/opentelemetry-example-app diff --git a/tox.ini b/tox.ini index 1a832d0f128..11a1bfdc744 100644 --- a/tox.ini +++ b/tox.ini @@ -150,7 +150,7 @@ changedir = test-ext-opencensusexporter: ext/opentelemetry-ext-opencensusexporter/tests test-ext-prometheus: ext/opentelemetry-ext-prometheus/tests test-ext-pymongo: ext/opentelemetry-ext-pymongo/tests - test-ext-stackdriver: ext/opentelemetry-ext-stackdriver/tests + test-exporter-cloud-trace: ext/opentelemetry-exporter-cloud-trace/tests test-ext-psycopg2: ext/opentelemetry-ext-psycopg2/tests test-ext-pymysql: ext/opentelemetry-ext-pymysql/tests test-ext-asgi: ext/opentelemetry-ext-asgi/tests