Repository: open-telemetry/opentelemetry-python

Issue:
Add Zipkin exporter
The task is to create a trace exporter for exporting to Zipkin


Discussion:

Resolving patch:
diff --git a/ext/opentelemetry-ext-zipkin/setup.py b/ext/opentelemetry-ext-zipkin/setup.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-zipkin/setup.py
@@ -0,0 +1,26 @@
+# 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 os
+
+import setuptools
+
+BASE_DIR = os.path.dirname(__file__)
+VERSION_FILENAME = os.path.join(
+    BASE_DIR, "src", "opentelemetry", "ext", "zipkin", "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-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/__init__.py
@@ -0,0 +1,184 @@
+# 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.
+
+"""Zipkin Span Exporter for OpenTelemetry."""
+
+import json
+import logging
+from typing import Optional, Sequence
+
+import requests
+
+from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
+from opentelemetry.trace import Span, SpanContext, SpanKind
+
+DEFAULT_ENDPOINT = "/api/v2/spans"
+DEFAULT_HOST_NAME = "localhost"
+DEFAULT_PORT = 9411
+DEFAULT_PROTOCOL = "http"
+DEFAULT_RETRY = False
+ZIPKIN_HEADERS = {"Content-Type": "application/json"}
+
+SPAN_KIND_MAP = {
+    SpanKind.INTERNAL: None,
+    SpanKind.SERVER: "SERVER",
+    SpanKind.CLIENT: "CLIENT",
+    SpanKind.PRODUCER: "PRODUCER",
+    SpanKind.CONSUMER: "CONSUMER",
+}
+
+SUCCESS_STATUS_CODES = (200, 202)
+
+logger = logging.getLogger(__name__)
+
+
+class ZipkinSpanExporter(SpanExporter):
+    """Zipkin span exporter for OpenTelemetry.
+
+    Args:
+        service_name: Service that logged an annotation in a trace.Classifier
+            when query for spans.
+        host_name: The host name of the Zipkin server
+        port: The port of the Zipkin server
+        endpoint: The endpoint of the Zipkin server
+        protocol: The protocol used for the request.
+        ipv4: Primary IPv4 address associated with this connection.
+        ipv6: Primary IPv6 address associated with this connection.
+        retry: Set to True to configure the exporter to retry on failure.
+    """
+
+    def __init__(
+        self,
+        service_name: str,
+        host_name: str = DEFAULT_HOST_NAME,
+        port: int = DEFAULT_PORT,
+        endpoint: str = DEFAULT_ENDPOINT,
+        protocol: str = DEFAULT_PROTOCOL,
+        ipv4: Optional[str] = None,
+        ipv6: Optional[str] = None,
+        retry: Optional[str] = DEFAULT_RETRY,
+    ):
+        self.service_name = service_name
+        self.host_name = host_name
+        self.port = port
+        self.endpoint = endpoint
+        self.protocol = protocol
+        self.url = "{}://{}:{}{}".format(
+            self.protocol, self.host_name, self.port, self.endpoint
+        )
+        self.ipv4 = ipv4
+        self.ipv6 = ipv6
+        self.retry = retry
+
+    def export(self, spans: Sequence[Span]) -> SpanExportResult:
+        zipkin_spans = self._translate_to_zipkin(spans)
+        result = requests.post(
+            url=self.url, data=json.dumps(zipkin_spans), headers=ZIPKIN_HEADERS
+        )
+
+        if result.status_code not in SUCCESS_STATUS_CODES:
+            logger.error(
+                "Traces cannot be uploaded; status code: %s, message %s",
+                result.status_code,
+                result.text,
+            )
+
+            if self.retry:
+                return SpanExportResult.FAILED_RETRYABLE
+            return SpanExportResult.FAILED_NOT_RETRYABLE
+        return SpanExportResult.SUCCESS
+
+    def _translate_to_zipkin(self, spans: Sequence[Span]):
+
+        local_endpoint = {
+            "serviceName": self.service_name,
+            "port": self.port,
+        }
+
+        if self.ipv4 is not None:
+            local_endpoint["ipv4"] = self.ipv4
+
+        if self.ipv6 is not None:
+            local_endpoint["ipv6"] = self.ipv6
+
+        zipkin_spans = []
+        for span in spans:
+            context = span.get_context()
+            trace_id = context.trace_id
+            span_id = context.span_id
+
+            # Timestamp in zipkin spans is int of microseconds.
+            # see: https://zipkin.io/pages/instrumenting.html
+            start_timestamp_mus = _nsec_to_usec_round(span.start_time)
+            duration_mus = _nsec_to_usec_round(span.end_time - span.start_time)
+
+            zipkin_span = {
+                "traceId": format(trace_id, "x"),
+                "id": format(span_id, "x"),
+                "name": span.name,
+                "timestamp": start_timestamp_mus,
+                "duration": duration_mus,
+                "localEndpoint": local_endpoint,
+                "kind": SPAN_KIND_MAP[span.kind],
+                "tags": _extract_tags_from_span(span.attributes),
+                "annotations": _extract_annotations_from_events(span.events),
+            }
+
+            if context.trace_options.sampled:
+                zipkin_span["debug"] = 1
+
+            if isinstance(span.parent, Span):
+                zipkin_span["parentId"] = format(
+                    span.parent.get_context().span_id, "x"
+                )
+            elif isinstance(span.parent, SpanContext):
+                zipkin_span["parentId"] = format(span.parent.span_id, "x")
+
+            zipkin_spans.append(zipkin_span)
+        return zipkin_spans
+
+    def shutdown(self) -> None:
+        pass
+
+
+def _extract_tags_from_span(attr):
+    if not attr:
+        return None
+    tags = {}
+    for attribute_key, attribute_value in attr.items():
+        if isinstance(attribute_value, (int, bool, float)):
+            value = str(attribute_value)
+        elif isinstance(attribute_value, str):
+            value = attribute_value[:128]
+        else:
+            logger.warning("Could not serialize tag %s", attribute_key)
+            continue
+        tags[attribute_key] = value
+    return tags
+
+
+def _extract_annotations_from_events(events):
+    return (
+        [
+            {"timestamp": _nsec_to_usec_round(e.timestamp), "value": e.name}
+            for e in events
+        ]
+        if events
+        else None
+    )
+
+
+def _nsec_to_usec_round(nsec):
+    """Round nanoseconds to microseconds"""
+    return (nsec + 500) // 10 ** 3
diff --git a/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/version.py b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/version.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-zipkin/src/opentelemetry/ext/zipkin/version.py
@@ -0,0 +1,15 @@
+# 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.
+
+__version__ = "0.3.dev0"


Test patch:
diff --git a/ext/opentelemetry-ext-zipkin/tests/__init__.py b/ext/opentelemetry-ext-zipkin/tests/__init__.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-zipkin/tests/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py b/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py
new file mode 100644
--- /dev/null
+++ b/ext/opentelemetry-ext-zipkin/tests/test_zipkin_exporter.py
@@ -0,0 +1,242 @@
+# 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 json
+import unittest
+from unittest.mock import MagicMock, patch
+
+from opentelemetry import trace as trace_api
+from opentelemetry.ext.zipkin import ZipkinSpanExporter
+from opentelemetry.sdk import trace
+from opentelemetry.sdk.trace.export import SpanExportResult
+from opentelemetry.trace import TraceOptions
+
+
+class MockResponse:
+    def __init__(self, status_code):
+        self.status_code = status_code
+        self.text = status_code
+
+
+class TestZipkinSpanExporter(unittest.TestCase):
+    def setUp(self):
+        # create and save span to be used in tests
+        context = trace_api.SpanContext(
+            trace_id=0x000000000000000000000000DEADBEEF,
+            span_id=0x00000000DEADBEF0,
+        )
+
+        self._test_span = trace.Span("test_span", context=context)
+        self._test_span.start()
+        self._test_span.end()
+
+    def test_constructor_default(self):
+        """Test the default values assigned by constructor."""
+        service_name = "my-service-name"
+        host_name = "localhost"
+        port = 9411
+        endpoint = "/api/v2/spans"
+        exporter = ZipkinSpanExporter(service_name)
+        ipv4 = None
+        ipv6 = None
+        protocol = "http"
+        url = "http://localhost:9411/api/v2/spans"
+
+        self.assertEqual(exporter.service_name, service_name)
+        self.assertEqual(exporter.host_name, host_name)
+        self.assertEqual(exporter.port, port)
+        self.assertEqual(exporter.endpoint, endpoint)
+        self.assertEqual(exporter.ipv4, ipv4)
+        self.assertEqual(exporter.ipv6, ipv6)
+        self.assertEqual(exporter.protocol, protocol)
+        self.assertEqual(exporter.url, url)
+
+    def test_constructor_explicit(self):
+        """Test the constructor passing all the options."""
+        service_name = "my-opentelemetry-zipkin"
+        host_name = "opentelemetry.io"
+        port = 15875
+        endpoint = "/myapi/traces?format=zipkin"
+        ipv4 = "1.2.3.4"
+        ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
+        protocol = "https"
+        url = "https://opentelemetry.io:15875/myapi/traces?format=zipkin"
+        exporter = ZipkinSpanExporter(
+            service_name=service_name,
+            host_name=host_name,
+            port=port,
+            endpoint=endpoint,
+            ipv4=ipv4,
+            ipv6=ipv6,
+            protocol=protocol,
+        )
+
+        self.assertEqual(exporter.service_name, service_name)
+        self.assertEqual(exporter.host_name, host_name)
+        self.assertEqual(exporter.port, port)
+        self.assertEqual(exporter.endpoint, endpoint)
+        self.assertEqual(exporter.ipv4, ipv4)
+        self.assertEqual(exporter.ipv6, ipv6)
+        self.assertEqual(exporter.protocol, protocol)
+        self.assertEqual(exporter.url, url)
+
+    # pylint: disable=too-many-locals
+    def test_export(self):
+
+        span_names = ("test1", "test2", "test3")
+        trace_id = 0x6E0C63257DE34C926F9EFCD03927272E
+        span_id = 0x34BF92DEEFC58C92
+        parent_id = 0x1111111111111111
+        other_id = 0x2222222222222222
+
+        base_time = 683647322 * 10 ** 9  # in ns
+        start_times = (
+            base_time,
+            base_time + 150 * 10 ** 6,
+            base_time + 300 * 10 ** 6,
+        )
+        durations = (50 * 10 ** 6, 100 * 10 ** 6, 200 * 10 ** 6)
+        end_times = (
+            start_times[0] + durations[0],
+            start_times[1] + durations[1],
+            start_times[2] + durations[2],
+        )
+
+        span_context = trace_api.SpanContext(
+            trace_id,
+            span_id,
+            trace_options=TraceOptions(TraceOptions.SAMPLED),
+        )
+        parent_context = trace_api.SpanContext(trace_id, parent_id)
+        other_context = trace_api.SpanContext(trace_id, other_id)
+
+        event_attributes = {
+            "annotation_bool": True,
+            "annotation_string": "annotation_test",
+            "key_float": 0.3,
+        }
+
+        event_timestamp = base_time + 50 * 10 ** 6
+        event = trace_api.Event(
+            name="event0",
+            timestamp=event_timestamp,
+            attributes=event_attributes,
+        )
+
+        link_attributes = {"key_bool": True}
+
+        link = trace_api.Link(
+            context=other_context, attributes=link_attributes
+        )
+
+        otel_spans = [
+            trace.Span(
+                name=span_names[0],
+                context=span_context,
+                parent=parent_context,
+                events=(event,),
+                links=(link,),
+            ),
+            trace.Span(
+                name=span_names[1], context=parent_context, parent=None
+            ),
+            trace.Span(name=span_names[2], context=other_context, parent=None),
+        ]
+
+        otel_spans[0].start_time = start_times[0]
+        # added here to preserve order
+        otel_spans[0].set_attribute("key_bool", False)
+        otel_spans[0].set_attribute("key_string", "hello_world")
+        otel_spans[0].set_attribute("key_float", 111.22)
+        otel_spans[0].end_time = end_times[0]
+
+        otel_spans[1].start_time = start_times[1]
+        otel_spans[1].end_time = end_times[1]
+
+        otel_spans[2].start_time = start_times[2]
+        otel_spans[2].end_time = end_times[2]
+
+        service_name = "test-service"
+        local_endpoint = {
+            "serviceName": service_name,
+            "port": 9411,
+        }
+
+        exporter = ZipkinSpanExporter(service_name)
+        expected = [
+            {
+                "traceId": format(trace_id, "x"),
+                "id": format(span_id, "x"),
+                "name": span_names[0],
+                "timestamp": start_times[0] // 10 ** 3,
+                "duration": durations[0] // 10 ** 3,
+                "localEndpoint": local_endpoint,
+                "kind": None,
+                "tags": {
+                    "key_bool": "False",
+                    "key_string": "hello_world",
+                    "key_float": "111.22",
+                },
+                "annotations": [
+                    {
+                        "timestamp": event_timestamp // 10 ** 3,
+                        "value": "event0",
+                    }
+                ],
+                "debug": 1,
+                "parentId": format(parent_id, "x"),
+            },
+            {
+                "traceId": format(trace_id, "x"),
+                "id": format(parent_id, "x"),
+                "name": span_names[1],
+                "timestamp": start_times[1] // 10 ** 3,
+                "duration": durations[1] // 10 ** 3,
+                "localEndpoint": local_endpoint,
+                "kind": None,
+                "tags": None,
+                "annotations": None,
+            },
+            {
+                "traceId": format(trace_id, "x"),
+                "id": format(other_id, "x"),
+                "name": span_names[2],
+                "timestamp": start_times[2] // 10 ** 3,
+                "duration": durations[2] // 10 ** 3,
+                "localEndpoint": local_endpoint,
+                "kind": None,
+                "tags": None,
+                "annotations": None,
+            },
+        ]
+
+        mock_post = MagicMock()
+        with patch("requests.post", mock_post):
+            mock_post.return_value = MockResponse(200)
+            status = exporter.export(otel_spans)
+            self.assertEqual(SpanExportResult.SUCCESS, status)
+
+        mock_post.assert_called_with(
+            url="http://localhost:9411/api/v2/spans",
+            data=json.dumps(expected),
+            headers={"Content-Type": "application/json"},
+        )
+
+    @patch("requests.post")
+    def test_invalid_response(self, mock_post):
+        mock_post.return_value = MockResponse(404)
+        spans = []
+        exporter = ZipkinSpanExporter("test-service")
+        status = exporter.export(spans)
+        self.assertEqual(SpanExportResult.FAILED_NOT_RETRYABLE, status)