diff --git a/ext/opentelemetry-ext-zipkin/CHANGELOG.md b/ext/opentelemetry-ext-zipkin/CHANGELOG.md new file mode 100644 index 00000000000..617d979ab29 --- /dev/null +++ b/ext/opentelemetry-ext-zipkin/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changelog + +## Unreleased + diff --git a/ext/opentelemetry-ext-zipkin/README.rst b/ext/opentelemetry-ext-zipkin/README.rst new file mode 100644 index 00000000000..f91d0c2c6a7 --- /dev/null +++ b/ext/opentelemetry-ext-zipkin/README.rst @@ -0,0 +1,67 @@ +OpenTelemetry Zipkin Exporter +============================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-ext-zipkin.svg + :target: https://pypi.org/project/opentelemetry-ext-zipkin/ + +This library allows to export tracing data to `Zipkin `_. + +Installation +------------ + +:: + + pip install opentelemetry-ext-zipkin + + +Usage +----- + +The **OpenTelemetry Zipkin Exporter** allows to export `OpenTelemetry`_ traces to `Zipkin`_. +This exporter always send traces to the configured Zipkin collector using HTTP. + + +.. _Zipkin: https://zipkin.io/ +.. _OpenTelemetry: https://github.com/open-telemetry/opentelemetry-python/ + +.. code:: python + + from opentelemetry import trace + from opentelemetry.ext import zipkin + from opentelemetry.sdk.trace import TracerSource + from opentelemetry.sdk.trace.export import BatchExportSpanProcessor + + trace.set_preferred_tracer_source_implementation(lambda T: TracerSource()) + tracer = trace.tracer_source().get_tracer(__name__) + + # create a ZipkinSpanExporter + zipkin_exporter = zipkin.ZipkinSpanExporter( + service_name="my-helloworld-service", + # optional: + # host_name="localhost", + # port=9411, + # endpoint="/api/v2/spans", + # protocol="http", + # ipv4="", + # ipv6="", + # retry=False, + ) + + # Create a BatchExportSpanProcessor and add the exporter to it + span_processor = BatchExportSpanProcessor(zipkin_exporter) + + # add to the tracer + trace.tracer_source().add_span_processor(span_processor) + + with tracer.start_as_current_span("foo"): + print("Hello world!") + +The `examples <./examples>`_ folder contains more elaborated examples. + +References +---------- + +* `Zipkin `_ +* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-zipkin/setup.cfg b/ext/opentelemetry-ext-zipkin/setup.cfg new file mode 100644 index 00000000000..89d60d149a7 --- /dev/null +++ b/ext/opentelemetry-ext-zipkin/setup.cfg @@ -0,0 +1,47 @@ +# 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. +# +[metadata] +name = opentelemetry-ext-zipkin +description = Zipkin Span Exporter 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-zipkin +platforms = any +license = Apache-2.0 +classifiers = + Development Status :: 3 - Alpha + 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 = + requests~=2.7 + opentelemetry-api + opentelemetry-sdk + +[options.packages.find] +where = src diff --git a/ext/opentelemetry-ext-zipkin/setup.py b/ext/opentelemetry-ext-zipkin/setup.py new file mode 100644 index 00000000000..f93bbad4490 --- /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 index 00000000000..e0b5791d1e1 --- /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 index 00000000000..93ef792d051 --- /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" diff --git a/ext/opentelemetry-ext-zipkin/tests/__init__.py b/ext/opentelemetry-ext-zipkin/tests/__init__.py new file mode 100644 index 00000000000..d853a7bcf65 --- /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 index 00000000000..745c662f53b --- /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) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index bddf39a90cd..9b981b0817c 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -17,10 +17,12 @@ coverage erase cov opentelemetry-api cov opentelemetry-sdk +cov ext/opentelemetry-ext-flask cov ext/opentelemetry-ext-http-requests cov ext/opentelemetry-ext-jaeger cov ext/opentelemetry-ext-opentracing-shim cov ext/opentelemetry-ext-wsgi +cov ext/opentelemetry-ext-zipkin cov examples/opentelemetry-example-app coverage report diff --git a/tox.ini b/tox.ini index 2ded7d1b753..8a4321a95c5 100644 --- a/tox.ini +++ b/tox.ini @@ -2,10 +2,10 @@ skipsdist = True skip_missing_interpreters = True envlist = - py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} - pypy3-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} - py3{4,5,6,7,8}-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} - pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} + py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,ext-zipkin,opentracing-shim} + pypy3-test-{api,sdk,example-app,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,ext-zipkin,opentracing-shim} + py3{4,5,6,7,8}-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,ext-zipkin,opentracing-shim} + pypy3-test-{api,sdk,example-app,example-basic-tracer,example-http,ext-wsgi,ext-flask,ext-http-requests,ext-jaeger,ext-pymongo,ext-zipkin,opentracing-shim} py3{4,5,6,7,8}-coverage ; Coverage is temporarily disabled for pypy3 due to the pytest bug. @@ -38,6 +38,7 @@ changedir = test-ext-jaeger: ext/opentelemetry-ext-jaeger/tests test-ext-pymongo: ext/opentelemetry-ext-pymongo/tests test-ext-wsgi: ext/opentelemetry-ext-wsgi/tests + test-ext-zipkin: ext/opentelemetry-ext-zipkin/tests test-ext-flask: ext/opentelemetry-ext-flask/tests test-example-app: examples/opentelemetry-example-app/tests test-example-basic-tracer: examples/basic_tracer/tests @@ -71,6 +72,7 @@ commands_pre = jaeger: pip install {toxinidir}/opentelemetry-sdk jaeger: pip install {toxinidir}/ext/opentelemetry-ext-jaeger opentracing-shim: pip install {toxinidir}/opentelemetry-sdk {toxinidir}/ext/opentelemetry-ext-opentracing-shim + zipkin: pip install {toxinidir}/ext/opentelemetry-ext-zipkin ; In order to get a healthy coverage report, ; we have to install packages in editable mode.