diff --git a/docs-requirements.txt b/docs-requirements.txt index a61f0beedb3..db10f6f9ee4 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -21,3 +21,4 @@ thrift>=0.10.0 wrapt>=1.0.0,<2.0.0 psutil~=5.7.0 boto~=2.0 +google-cloud-trace >=0.23.0 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-exporter-cloud-trace/setup.cfg b/ext/opentelemetry-exporter-cloud-trace/setup.cfg new file mode 100644 index 00000000000..df6c2ce587b --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/setup.cfg @@ -0,0 +1,47 @@ +# 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. +# +[metadata] +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-exporter-cloud-trace +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 4 - Beta + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + +[options] +python_requires = >=3.4 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api + opentelemetry-sdk + google-cloud-trace + +[options.packages.find] +where = src diff --git a/ext/opentelemetry-exporter-cloud-trace/setup.py b/ext/opentelemetry-exporter-cloud-trace/setup.py new file mode 100644 index 00000000000..332cf41d01c --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/setup.py @@ -0,0 +1,26 @@ +# 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 os + +import setuptools + +BASE_DIR = os.path.dirname(__file__) +VERSION_FILENAME = os.path.join( + BASE_DIR, "src", "opentelemetry", "exporter", "cloud_trace", "version.py" +) +PACKAGE_INFO = {} +with open(VERSION_FILENAME) as f: + exec(f.read(), PACKAGE_INFO) + +setuptools.setup(version=PACKAGE_INFO["__version__"]) 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..7e7aa017cfd --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/__init__.py @@ -0,0 +1,332 @@ +# 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, Optional, 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.sdk.util import BoundedDict +from opentelemetry.util import types + +logger = logging.getLogger(__name__) + +MAX_NUM_LINKS = 128 +MAX_NUM_EVENTS = 32 +MAX_EVENT_ATTRS = 4 +MAX_LINK_ATTRS = 32 +MAX_SPAN_ATTRS = 32 + + +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) + + if len(span.attributes) > MAX_SPAN_ATTRS: + logger.warning( + "Span has more then %s attributes, some will be truncated", + MAX_SPAN_ATTRS, + ) + + cloud_trace_spans.append( + { + "name": span_name, + "span_id": span_id, + "display_name": _get_truncatable_str_object( + span.name, 128 + ), + "start_time": start_time, + "end_time": end_time, + "parent_span_id": parent_id, + "attributes": _extract_attributes( + span.attributes, MAX_SPAN_ATTRS + ), + "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): + """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) -> Tuple[str, int]: + """Check the length of a string. If exceeds limit, then truncate it.""" + encoded = str_to_check.encode("utf-8") + truncated_str = encoded[:limit].decode("utf-8", errors="ignore") + return truncated_str, len(encoded) - len(truncated_str.encode("utf-8")) + + +def _extract_status(status: trace_api.Status) -> Optional[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 = [] + dropped_links = 0 + if len(links) > MAX_NUM_LINKS: + logger.warning( + "Exporting more then %s links, some will be truncated", + MAX_NUM_LINKS, + ) + dropped_links = len(links) - MAX_NUM_LINKS + links = links[:MAX_NUM_LINKS] + for link in links: + if len(link.attributes) > MAX_LINK_ATTRS: + logger.warning( + "Link has more then %s attributes, some will be truncated", + MAX_LINK_ATTRS, + ) + 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, MAX_LINK_ATTRS + ), + } + ) + return ProtoSpan.Links( + link=extracted_links, dropped_links_count=dropped_links + ) + + +def _extract_events(events: Sequence[Event]) -> ProtoSpan.TimeEvents: + """Convert span.events to dict.""" + if not events: + return None + logs = [] + dropped_annontations = 0 + if len(events) > MAX_NUM_EVENTS: + logger.warning( + "Exporting more then %s annotations, some will be truncated", + MAX_NUM_EVENTS, + ) + dropped_annontations = len(events) - MAX_NUM_EVENTS + events = events[:MAX_NUM_EVENTS] + for event in events: + if len(event.attributes) > MAX_EVENT_ATTRS: + logger.warning( + "Event %s has more then %s attributes, some will be truncated", + event.name, + MAX_EVENT_ATTRS, + ) + logs.append( + { + "time": _get_time_from_ns(event.timestamp), + "annotation": { + "description": _get_truncatable_str_object( + event.name, 256 + ), + "attributes": _extract_attributes( + event.attributes, MAX_EVENT_ATTRS + ), + }, + } + ) + return ProtoSpan.TimeEvents( + time_event=logs, + dropped_annotations_count=dropped_annontations, + dropped_message_events_count=0, + ) + + +def _extract_attributes( + attrs: types.Attributes, num_attrs_limit: int +) -> ProtoSpan.Attributes: + """Convert span.attributes to dict.""" + attributes_dict = BoundedDict(num_attrs_limit) + + for key, value in attrs.items(): + key = _truncate_str(key, 128)[0] + value = _format_attribute_value(value) + + if value is not None: + attributes_dict[key] = value + return ProtoSpan.Attributes( + attribute_map=attributes_dict, + dropped_attributes_count=len(attrs) - len(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, 256) + elif isinstance(value, float): + value_type = "string_value" + value = _get_truncatable_str_object("{:0.4f}".format(value), 256) + 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-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py new file mode 100644 index 00000000000..f83f20e7bac --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/src/opentelemetry/exporter/cloud_trace/version.py @@ -0,0 +1,15 @@ +# 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. + +__version__ = "0.9.dev0" diff --git a/ext/opentelemetry-exporter-cloud-trace/tests/__init__.py b/ext/opentelemetry-exporter-cloud-trace/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d 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..5ebd5f3b649 --- /dev/null +++ b/ext/opentelemetry-exporter-cloud-trace/tests/test_cloud_trace_exporter.py @@ -0,0 +1,401 @@ +# 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 ( + MAX_EVENT_ATTRS, + MAX_LINK_ATTRS, + MAX_NUM_EVENTS, + MAX_NUM_LINKS, + CloudTraceSpanExporter, + _extract_attributes, + _extract_events, + _extract_links, + _extract_status, + _format_attribute_value, + _truncate_str, +) +from opentelemetry.sdk.trace import Event, Span +from opentelemetry.trace import 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, + } + 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.4210", truncated_byte_count=0 + ) + ), + "int_key": AttributeValue(int_value=123), + } + ) + + 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({}, 4), ProtoSpan.Attributes(attribute_map={}) + ) + self.assertEqual( + _extract_attributes(self.attributes_variety_pack, 4), + self.extracted_attributes_variety_pack, + ) + # Test ignoring attributes with illegal value type + self.assertEqual( + _extract_attributes({"illegal_attribute_value": dict()}, 4), + ProtoSpan.Attributes(attribute_map={}, dropped_attributes_count=1), + ) + + too_many_attrs = {} + for attr_key in range(5): + too_many_attrs[str(attr_key)] = 0 + proto_attrs = _extract_attributes(too_many_attrs, 4) + self.assertEqual(proto_attrs.dropped_attributes_count, 1) + + 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={}, dropped_attributes_count=1 + ), + }, + }, + ] + ), + ) + + 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) + }, + "dropped_attributes_count": 1, + }, + }, + ] + ), + ) + + # pylint:disable=too-many-locals + def test_truncate(self): + """Cloud Trace API imposes limits on the length of many things, + e.g. strings, number of events, number of attributes. We truncate + these things before sending it to the API as an optimization. + """ + str_300 = "a" * 300 + str_256 = "a" * 256 + str_128 = "a" * 128 + self.assertEqual(_truncate_str("aaaa", 1), ("a", 3)) + self.assertEqual(_truncate_str("aaaa", 5), ("aaaa", 0)) + self.assertEqual(_truncate_str("aaaa", 4), ("aaaa", 0)) + self.assertEqual(_truncate_str("中文翻译", 4), ("中", 9)) + + self.assertEqual( + _format_attribute_value(str_300), + AttributeValue( + string_value=TruncatableString( + value=str_256, truncated_byte_count=300 - 256 + ) + ), + ) + + self.assertEqual( + _extract_attributes({str_300: str_300}, 4), + ProtoSpan.Attributes( + attribute_map={ + str_128: AttributeValue( + string_value=TruncatableString( + value=str_256, truncated_byte_count=300 - 256 + ) + ) + } + ), + ) + + time_in_ns1 = 1589919268850900051 + time_in_ms_and_ns1 = {"seconds": 1589919268, "nanos": 850899968} + event1 = Event(name=str_300, attributes={}, timestamp=time_in_ns1) + self.assertEqual( + _extract_events([event1]), + ProtoSpan.TimeEvents( + time_event=[ + { + "time": time_in_ms_and_ns1, + "annotation": { + "description": TruncatableString( + value=str_256, truncated_byte_count=300 - 256 + ), + "attributes": {}, + }, + }, + ] + ), + ) + + trace_id = "6e0c63257de34c92bf9efcd03927272e" + span_id = "95bb5edabd45950f" + link = Link( + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=False, + ), + attributes={}, + ) + too_many_links = [link] * (MAX_NUM_LINKS + 1) + self.assertEqual( + _extract_links(too_many_links), + ProtoSpan.Links( + link=[ + { + "trace_id": trace_id, + "span_id": span_id, + "type": "TYPE_UNSPECIFIED", + "attributes": {}, + } + ] + * MAX_NUM_LINKS, + dropped_links_count=len(too_many_links) - MAX_NUM_LINKS, + ), + ) + + link_attrs = {} + for attr_key in range(MAX_LINK_ATTRS + 1): + link_attrs[str(attr_key)] = 0 + attr_link = Link( + context=SpanContext( + trace_id=int(trace_id, 16), + span_id=int(span_id, 16), + is_remote=False, + ), + attributes=link_attrs, + ) + + proto_link = _extract_links([attr_link]) + self.assertEqual( + len(proto_link.link[0].attributes.attribute_map), MAX_LINK_ATTRS + ) + + too_many_events = [event1] * (MAX_NUM_EVENTS + 1) + self.assertEqual( + _extract_events(too_many_events), + ProtoSpan.TimeEvents( + time_event=[ + { + "time": time_in_ms_and_ns1, + "annotation": { + "description": TruncatableString( + value=str_256, truncated_byte_count=300 - 256 + ), + "attributes": {}, + }, + }, + ] + * MAX_NUM_EVENTS, + dropped_annotations_count=len(too_many_events) + - MAX_NUM_EVENTS, + ), + ) + + time_in_ns1 = 1589919268850900051 + event_attrs = {} + for attr_key in range(MAX_EVENT_ATTRS + 1): + event_attrs[str(attr_key)] = 0 + proto_events = _extract_events( + [Event(name="a", attributes=event_attrs, timestamp=time_in_ns1)] + ) + self.assertEqual( + len( + proto_events.time_event[0].annotation.attributes.attribute_map + ), + MAX_EVENT_ATTRS, + ) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index 0b45fbf643b..1794cdf01b7 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -36,6 +36,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-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 381e8604e1e..026d01cc0c1 100644 --- a/tox.ini +++ b/tox.ini @@ -159,6 +159,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-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