diff --git a/devstack/lib/osprofiler b/devstack/lib/osprofiler index 6dc7e0e..d5b4755 100644 --- a/devstack/lib/osprofiler +++ b/devstack/lib/osprofiler @@ -58,19 +58,29 @@ function install_redis() { pip_install_gr redis } -function install_jaeger() { +function install_jaeger_backend() { if is_ubuntu; then install_package docker.io start_service docker add_user_to_group $STACK_USER docker - sg docker -c "docker run -d --name jaeger -p 6831:6831/udp -p 16686:16686 jaegertracing/all-in-one:1.7" + sg docker -c "docker run -d --name jaeger -e COLLECTOR_OTLP_ENABLED=true -p 6831:6831/udp -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:1.42" else exit_distro_not_supported "docker.io installation" fi +} +function install_jaeger() { + install_jaeger_backend pip_install jaeger-client } +function install_otlp() { + # For OTLP we use Jaeger backend but any OTLP compatible backend + # can be used. + install_jaeger_backend + pip_install opentelemetry-sdk opentelemetry-exporter-otlp +} + function drop_jaeger() { sg docker -c 'docker rm jaeger --force' } @@ -112,6 +122,9 @@ function install_osprofiler_collector() { elif [ "$OSPROFILER_COLLECTOR" == "jaeger" ]; then install_jaeger OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"jaeger://localhost:6831"} + elif [ "$OSPROFILER_COLLECTOR" == "otlp" ]; then + install_otlp + OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"otlp://localhost:4318"} elif [ "$OSPROFILER_COLLECTOR" == "elasticsearch" ]; then install_elasticsearch OSPROFILER_CONNECTION_STRING=${OSPROFILER_CONNECTION_STRING:-"elasticsearch://elastic:changeme@localhost:9200"} diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 705d6ce..49cd670 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -20,7 +20,8 @@ elif [[ "$1" == "stack" && "$2" == "test-config" ]]; then configure_osprofiler_in_tempest elif [[ "$1" == "unstack" ]]; then - if [[ "$OSPROFILER_COLLECTOR" == "jaeger" ]]; then + if [[ "$OSPROFILER_COLLECTOR" == "jaeger" || \ + "$OSPROFILER_COLLECTOR" == "otlp" ]]; then echo_summary "Deleting jaeger docker container" drop_jaeger fi diff --git a/doc/source/user/collectors.rst b/doc/source/user/collectors.rst index a2c11e3..dd26326 100644 --- a/doc/source/user/collectors.rst +++ b/doc/source/user/collectors.rst @@ -71,3 +71,25 @@ to create tables and select and insert rows. - MySQL 5.7.8 .. _SQLAlchemy understands: https://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls + + +OTLP +---- + +Use OTLP exporter. Can be used with any comptable backend that support +OTLP. + +Usage +===== +To use the driver, the `connection_string` in the `[osprofiler]` config section +needs to be set:: + + [osprofiler] + connection_string = otlp://192.168.192.81:4318 + +Example: By default, jaeger is listening OTLP on 4318. + +.. note:: + + Curently the exporter is only supporting HTTP. In future some work + may happen to support gRPC. diff --git a/lower-constraints.txt b/lower-constraints.txt index aafaad0..91c4dcd 100644 --- a/lower-constraints.txt +++ b/lower-constraints.txt @@ -5,6 +5,8 @@ dulwich===0.15.0 elasticsearch===2.0.0 importlib_metadata==1.7.0 jaeger-client==3.8.0 +opentelemetry-exporter-otlp==1.16.0 +opentelemetry-sdk==1.16.0 netaddr===0.7.18 openstackdocstheme==2.2.1 oslo.concurrency===3.26.0 diff --git a/osprofiler/_utils.py b/osprofiler/_utils.py index 7563fb5..d903b9b 100644 --- a/osprofiler/_utils.py +++ b/osprofiler/_utils.py @@ -161,3 +161,15 @@ def shorten_id(span_id): # Return a new short id for this short_id = shorten_id(uuidutils.generate_uuid()) return short_id + + +def uuid_to_int128(span_uuid): + """Convert from uuid4 to 128 bit id for OpenTracing""" + if isinstance(span_uuid, int): + return span_uuid + try: + span_int = uuid.UUID(span_uuid).int + except ValueError: + # Return a new short id for this + span_int = uuid_to_int128(uuidutils.generate_uuid()) + return span_int diff --git a/osprofiler/drivers/__init__.py b/osprofiler/drivers/__init__.py index 022b094..6e3b93d 100644 --- a/osprofiler/drivers/__init__.py +++ b/osprofiler/drivers/__init__.py @@ -1,6 +1,7 @@ from osprofiler.drivers import base # noqa from osprofiler.drivers import elasticsearch_driver # noqa from osprofiler.drivers import jaeger # noqa +from osprofiler.drivers import otlp # noqa from osprofiler.drivers import loginsight # noqa from osprofiler.drivers import messaging # noqa from osprofiler.drivers import mongodb # noqa diff --git a/osprofiler/drivers/otlp.py b/osprofiler/drivers/otlp.py new file mode 100644 index 0000000..c1d210b --- /dev/null +++ b/osprofiler/drivers/otlp.py @@ -0,0 +1,179 @@ +# All Rights Reserved. +# +# 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 collections +from urllib import parse as parser + +from oslo_config import cfg +from oslo_serialization import jsonutils + +from osprofiler import _utils as utils +from osprofiler.drivers import base +from osprofiler import exc + + +class OTLP(base.Driver): + def __init__(self, connection_str, project=None, service=None, host=None, + conf=cfg.CONF, **kwargs): + """OTLP driver using OTLP exporters.""" + + super(OTLP, self).__init__(connection_str, project=project, + service=service, host=host, + conf=conf, **kwargs) + try: + from opentelemetry import trace as trace_api + + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter # noqa + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.sdk.trace import TracerProvider + + self.trace_api = trace_api + except ImportError: + raise exc.CommandError( + "To use OSProfiler with OTLP exporters, " + "please install `opentelemetry-sdk` and " + "opentelemetry-exporter-otlp libraries. " + "To install with pip:\n `pip install opentelemetry-sdk " + "opentelemetry-exporter-otlp`.") + + service_name = self._get_service_name(conf, project, service) + resource = Resource(attributes={ + "service.name": service_name + }) + + parsed_url = parser.urlparse(connection_str) + # TODO("sahid"): We also want to handle https scheme? + parsed_url = parsed_url._replace(scheme="http") + + self.trace_api.set_tracer_provider( + TracerProvider(resource=resource)) + self.tracer = self.trace_api.get_tracer(__name__) + + exporter = OTLPSpanExporter("{}/v1/traces".format( + parsed_url.geturl())) + self.trace_api.get_tracer_provider().add_span_processor( + BatchSpanProcessor(exporter)) + + self.spans = collections.deque() + + def _get_service_name(self, conf, project, service): + prefix = conf.profiler_otlp.service_name_prefix + if prefix: + return "{}-{}-{}".format(prefix, project, service) + return "{}-{}".format(project, service) + + @classmethod + def get_name(cls): + return "otlp" + + def _kind(self, name): + if "wsgi" in name: + return self.trace_api.SpanKind.SERVER + elif ("db" in name or "http_client" in name or "api" in name): + return self.trace_api.SpanKind.CLIENT + return self.trace_api.SpanKind.INTERNAL + + def _name(self, payload): + info = payload["info"] + if info.get("request"): + return "{}_{}".format( + info["request"]["method"], info["request"]["path"]) + elif info.get("db"): + return "SQL_{}".format( + info["db"]["statement"].split(' ', 1)[0].upper()) + return payload["name"].rstrip("-start") + + def notify(self, payload): + if payload["name"].endswith("start"): + parent = self.trace_api.SpanContext( + trace_id=utils.uuid_to_int128(payload["base_id"]), + span_id=utils.shorten_id(payload["parent_id"]), + is_remote=False, + trace_flags=self.trace_api.TraceFlags( + self.trace_api.TraceFlags.SAMPLED)) + + ctx = self.trace_api.set_span_in_context( + self.trace_api.NonRecordingSpan(parent)) + + # OTLP Tracing span + span = self.tracer.start_span( + name=self._name(payload), + kind=self._kind(payload['name']), + attributes=self.create_span_tags(payload), + context=ctx) + + span._context = self.trace_api.SpanContext( + trace_id=span.context.trace_id, + span_id=utils.shorten_id(payload["trace_id"]), + is_remote=span.context.is_remote, + trace_flags=span.context.trace_flags, + trace_state=span.context.trace_state) + + self.spans.append(span) + else: + span = self.spans.pop() + + # Store result of db call and function call + for call in ("db", "function"): + if payload.get("info", {}).get(call): + span.set_attribute( + "result", payload["info"][call]["result"]) + # Span error tag and log + if payload["info"].get("etype"): + span.set_attribute("error", True) + span.add_event("log", { + "error.kind": payload["info"]["etype"], + "message": payload["info"]["message"]}) + span.end() + + def get_report(self, base_id): + return self._parse_results() + + def list_traces(self, fields=None): + return [] + + def list_error_traces(self): + return [] + + def create_span_tags(self, payload): + """Create tags an OpenTracing compatible span. + + :param info: Information from OSProfiler trace. + :returns tags: A dictionary contains standard tags + from OpenTracing sematic conventions, + and some other custom tags related to http, db calls. + """ + tags = {} + info = payload["info"] + + if info.get("db"): + # DB calls + tags["db.statement"] = info["db"]["statement"] + tags["db.params"] = jsonutils.dumps(info["db"]["params"]) + elif info.get("request"): + # WSGI call + tags["http.path"] = info["request"]["path"] + tags["http.query"] = info["request"]["query"] + tags["http.method"] = info["request"]["method"] + tags["http.scheme"] = info["request"]["scheme"] + elif info.get("function"): + # RPC, function calls + if "args" in info["function"]: + tags["args"] = info["function"]["args"] + if "kwargs" in info["function"]: + tags["kwargs"] = info["function"]["kwargs"] + tags["name"] = info["function"]["name"] + + return tags diff --git a/osprofiler/opts.py b/osprofiler/opts.py index af8d4c4..b12e2b5 100644 --- a/osprofiler/opts.py +++ b/osprofiler/opts.py @@ -195,6 +195,22 @@ _JAEGER_OPTS = [ cfg.CONF.register_opts(_JAEGER_OPTS, group=_jaegerprofiler_opt_group) +_otlp_profiler_opt_group = cfg.OptGroup( + "profiler_otlp", + title="OTLP's profiler driver related options") + +_otlp_service_name_prefix = cfg.StrOpt( + "service_name_prefix", + help=""" +Set service name prefix to OTLP exporters. +""") + +_OTLP_OPTS = [ + _otlp_service_name_prefix, +] + +cfg.CONF.register_opts(_OTLP_OPTS, group=_otlp_profiler_opt_group) + def set_defaults(conf, enabled=None, trace_sqlalchemy=None, hmac_keys=None, connection_string=None, es_doc_type=None, @@ -265,4 +281,5 @@ def disable_web_trace(conf=None): def list_opts(): return [(_profiler_opt_group.name, _PROFILER_OPTS), - (_jaegerprofiler_opt_group, _JAEGER_OPTS)] + (_jaegerprofiler_opt_group, _JAEGER_OPTS), + (_otlp_profiler_opt_group, _OTLP_OPTS)] diff --git a/osprofiler/tests/unit/drivers/test_otlp.py b/osprofiler/tests/unit/drivers/test_otlp.py new file mode 100644 index 0000000..4162822 --- /dev/null +++ b/osprofiler/tests/unit/drivers/test_otlp.py @@ -0,0 +1,84 @@ +# All Rights Reserved. +# +# 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 unittest import mock + +from oslo_config import cfg + +from osprofiler.drivers import otlp +from osprofiler import opts +from osprofiler.tests import test + + +class OTLPTestCase(test.TestCase): + + def setUp(self): + super(OTLPTestCase, self).setUp() + + opts.set_defaults(cfg.CONF) + + self.payload_start = { + "name": "api-start", + "base_id": "4e3e0ec6-2938-40b1-8504-09eb1d4b0dee", + "trace_id": "1c089ea8-28fe-4f3d-8c00-f6daa2bc32f1", + "parent_id": "e2715537-3d1c-4f0c-b3af-87355dc5fc5b", + "timestamp": "2018-05-03T04:31:51.781381", + "info": { + "host": "test" + } + } + + self.payload_stop = { + "name": "api-stop", + "base_id": "4e3e0ec6-2938-40b1-8504-09eb1d4b0dee", + "trace_id": "1c089ea8-28fe-4f3d-8c00-f6daa2bc32f1", + "parent_id": "e2715537-3d1c-4f0c-b3af-87355dc5fc5b", + "timestamp": "2018-05-03T04:31:51.781381", + "info": { + "host": "test", + "function": { + "result": 1 + } + } + } + + self.driver = otlp.OTLP( + "otlp://127.0.0.1:6831", + project="nova", service="api", + conf=cfg.CONF) + + def test_notify_start(self): + self.driver.notify(self.payload_start) + self.assertEqual(1, len(self.driver.spans)) + + def test_notify_stop(self): + mock_end = mock.MagicMock() + self.driver.notify(self.payload_start) + self.driver.spans[0].end = mock_end + self.driver.notify(self.payload_stop) + mock_end.assert_called_once() + + def test_service_name_default(self): + self.assertEqual("pr1-svc1", self.driver._get_service_name( + cfg.CONF, "pr1", "svc1")) + + def test_service_name_prefix(self): + cfg.CONF.set_default( + "service_name_prefix", "prx1", "profiler_otlp") + self.assertEqual("prx1-pr1-svc1", self.driver._get_service_name( + cfg.CONF, "pr1", "svc1")) + + def test_process_tags(self): + # Need to be implemented. + pass diff --git a/releasenotes/notes/otlp-driver-cb932038ad580ac2.yaml b/releasenotes/notes/otlp-driver-cb932038ad580ac2.yaml new file mode 100644 index 0000000..e1a0cfc --- /dev/null +++ b/releasenotes/notes/otlp-driver-cb932038ad580ac2.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + An OTLP (OpenTelemetry) exporter is now supported. The current + support is experimental but the aim is to deprecate and remove + legacy Jaeger driver which is using the already deprecated python + library jaeger client. Operators who want to use it should enable + `otlp`. OTLP is comptatible with Jaeger backend. diff --git a/test-requirements.txt b/test-requirements.txt index 3e1bbd8..096429d 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -20,5 +20,7 @@ redis>=2.10.0 # MIT # For Jaeger Tracing jaeger-client>=3.8.0 # Apache-2.0 +opentelemetry-exporter-otlp>=1.16.0 +opentelemetry-sdk>=1.16.0 pre-commit>=2.6.0 # MIT