diff --git a/ext/opentelemetry-ext-pymongo/README.rst b/ext/opentelemetry-ext-pymongo/README.rst new file mode 100644 index 00000000000..1e8011f4c22 --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/README.rst @@ -0,0 +1,27 @@ +OpenTelemetry pymongo integration +================================= + +The integration with MongoDB supports the `pymongo`_ library and is specified +to ``trace_integration`` using ``'pymongo'``. + +.. _pymongo: https://pypi.org/project/pymongo + +Usage +----- + +.. code:: python + + from pymongo import MongoClient + from opentelemetry.trace import tracer + from opentelemetry.trace.ext.pymongo import trace_integration + + trace_integration(tracer()) + client = MongoClient() + db = client["MongoDB_Database"] + collection = db["MongoDB_Collection"] + collection.find_one() + +References +---------- + +* `OpenTelemetry Project `_ diff --git a/ext/opentelemetry-ext-pymongo/setup.cfg b/ext/opentelemetry-ext-pymongo/setup.cfg new file mode 100644 index 00000000000..f9362c75b35 --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/setup.cfg @@ -0,0 +1,46 @@ +# 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-pymongo +description = OpenTelemetry pymongo integration +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-pymongo +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 = + opentelemetry-api >= 0.3.dev0 + pymongo ~= 3.1 + +[options.packages.find] +where = src diff --git a/ext/opentelemetry-ext-pymongo/setup.py b/ext/opentelemetry-ext-pymongo/setup.py new file mode 100644 index 00000000000..ed63ddf42da --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/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", "pymongo", "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-pymongo/src/opentelemetry/ext/pymongo/__init__.py b/ext/opentelemetry-ext-pymongo/src/opentelemetry/ext/pymongo/__init__.py new file mode 100644 index 00000000000..fa1cc1583e1 --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/src/opentelemetry/ext/pymongo/__init__.py @@ -0,0 +1,109 @@ +# 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. + +""" +The opentelemetry-ext-pymongo package allows tracing commands made by the +pymongo library. +""" + +from pymongo import monitoring + +from opentelemetry.trace import SpanKind +from opentelemetry.trace.status import Status, StatusCanonicalCode + +DATABASE_TYPE = "mongodb" +COMMAND_ATTRIBUTES = ["filter", "sort", "skip", "limit", "pipeline"] + + +def trace_integration(tracer=None): + """Integrate with pymongo to trace it using event listener. + https://api.mongodb.com/python/current/api/pymongo/monitoring.html + """ + + monitoring.register(CommandTracer(tracer)) + + +class CommandTracer(monitoring.CommandListener): + def __init__(self, tracer): + if tracer is None: + raise ValueError("The tracer is not provided.") + self._tracer = tracer + self._span_dict = {} + + def started(self, event: monitoring.CommandStartedEvent): + command = event.command.get(event.command_name, "") + name = DATABASE_TYPE + "." + event.command_name + statement = event.command_name + if command: + name += "." + command + statement += " " + command + + try: + span = self._tracer.start_span(name, kind=SpanKind.CLIENT) + span.set_attribute("component", DATABASE_TYPE) + span.set_attribute("db.type", DATABASE_TYPE) + span.set_attribute("db.instance", event.database_name) + span.set_attribute("db.statement", statement) + if event.connection_id is not None: + span.set_attribute("peer.hostname", event.connection_id[0]) + span.set_attribute("peer.port", event.connection_id[1]) + + # pymongo specific, not specified by spec + span.set_attribute("db.mongo.operation_id", event.operation_id) + span.set_attribute("db.mongo.request_id", event.request_id) + + for attr in COMMAND_ATTRIBUTES: + _attr = event.command.get(attr) + if _attr is not None: + span.set_attribute("db.mongo." + attr, str(_attr)) + + # Add Span to dictionary + self._span_dict[_get_span_dict_key(event)] = span + except Exception as ex: # noqa pylint: disable=broad-except + if span is not None: + span.set_status(Status(StatusCanonicalCode.INTERNAL, str(ex))) + span.end() + self._remove_span(event) + + def succeeded(self, event: monitoring.CommandSucceededEvent): + span = self._get_span(event) + if span is not None: + span.set_attribute( + "db.mongo.duration_micros", event.duration_micros + ) + span.set_status(Status(StatusCanonicalCode.OK, event.reply)) + span.end() + self._remove_span(event) + + def failed(self, event: monitoring.CommandFailedEvent): + span = self._get_span(event) + if span is not None: + span.set_attribute( + "db.mongo.duration_micros", event.duration_micros + ) + span.set_status(Status(StatusCanonicalCode.UNKNOWN, event.failure)) + span.end() + self._remove_span(event) + + def _get_span(self, event): + return self._span_dict.get(_get_span_dict_key(event)) + + def _remove_span(self, event): + self._span_dict.pop(_get_span_dict_key(event)) + + +def _get_span_dict_key(event): + if event.connection_id is not None: + return (event.request_id, event.connection_id) + return event.request_id diff --git a/ext/opentelemetry-ext-pymongo/src/opentelemetry/ext/pymongo/version.py b/ext/opentelemetry-ext-pymongo/src/opentelemetry/ext/pymongo/version.py new file mode 100644 index 00000000000..93ef792d051 --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/src/opentelemetry/ext/pymongo/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-pymongo/tests/__init__.py b/ext/opentelemetry-ext-pymongo/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ext/opentelemetry-ext-pymongo/tests/test_pymongo_integration.py b/ext/opentelemetry-ext-pymongo/tests/test_pymongo_integration.py new file mode 100644 index 00000000000..95f0ae3413a --- /dev/null +++ b/ext/opentelemetry-ext-pymongo/tests/test_pymongo_integration.py @@ -0,0 +1,187 @@ +# 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 + +from opentelemetry import trace as trace_api +from opentelemetry.ext.pymongo import CommandTracer, trace_integration +from opentelemetry.util import time_ns + + +class TestPymongoIntegration(unittest.TestCase): + def test_trace_integration(self): + mock_register = mock.Mock() + patch = mock.patch( + "pymongo.monitoring.register", side_effect=mock_register + ) + mock_tracer = MockTracer() + with patch: + trace_integration(mock_tracer) + + self.assertTrue(mock_register.called) + + def test_started(self): + command_attrs = { + "filter": "filter", + "sort": "sort", + "limit": "limit", + "pipeline": "pipeline", + "command_name": "find", + } + mock_tracer = MockTracer() + command_tracer = CommandTracer(mock_tracer) + mock_event = MockEvent( + command_attrs, ("test.com", "1234"), "test_request_id" + ) + command_tracer.started(event=mock_event) + # pylint: disable=protected-access + span = command_tracer._get_span(mock_event) + self.assertIs(span.kind, trace_api.SpanKind.CLIENT) + self.assertEqual(span.name, "mongodb.command_name.find") + self.assertEqual(span.attributes["component"], "mongodb") + self.assertEqual(span.attributes["db.type"], "mongodb") + self.assertEqual(span.attributes["db.instance"], "database_name") + self.assertEqual(span.attributes["db.statement"], "command_name find") + self.assertEqual(span.attributes["peer.hostname"], "test.com") + self.assertEqual(span.attributes["peer.port"], "1234") + self.assertEqual( + span.attributes["db.mongo.operation_id"], "operation_id" + ) + self.assertEqual( + span.attributes["db.mongo.request_id"], "test_request_id" + ) + + self.assertEqual(span.attributes["db.mongo.filter"], "filter") + self.assertEqual(span.attributes["db.mongo.sort"], "sort") + self.assertEqual(span.attributes["db.mongo.limit"], "limit") + self.assertEqual(span.attributes["db.mongo.pipeline"], "pipeline") + + def test_succeeded(self): + mock_tracer = MockTracer() + mock_event = MockEvent({}) + command_tracer = CommandTracer(mock_tracer) + command_tracer.started(event=mock_event) + # pylint: disable=protected-access + span = command_tracer._get_span(mock_event) + command_tracer.succeeded(event=mock_event) + self.assertEqual( + span.attributes["db.mongo.duration_micros"], "duration_micros" + ) + self.assertIs( + span.status.canonical_code, trace_api.status.StatusCanonicalCode.OK + ) + self.assertEqual(span.status.description, "reply") + self.assertIsNotNone(span.end_time) + + def test_failed(self): + mock_tracer = MockTracer() + mock_event = MockEvent({}) + command_tracer = CommandTracer(mock_tracer) + command_tracer.started(event=mock_event) + # pylint: disable=protected-access + span = command_tracer._get_span(mock_event) + command_tracer.failed(event=mock_event) + self.assertEqual( + span.attributes["db.mongo.duration_micros"], "duration_micros" + ) + self.assertIs( + span.status.canonical_code, + trace_api.status.StatusCanonicalCode.UNKNOWN, + ) + self.assertEqual(span.status.description, "failure") + self.assertIsNotNone(span.end_time) + + def test_multiple_commands(self): + mock_tracer = MockTracer() + first_mock_event = MockEvent({}, ("firstUrl", "123"), "first") + second_mock_event = MockEvent({}, ("secondUrl", "456"), "second") + command_tracer = CommandTracer(mock_tracer) + command_tracer.started(event=first_mock_event) + # pylint: disable=protected-access + first_span = command_tracer._get_span(first_mock_event) + command_tracer.started(event=second_mock_event) + # pylint: disable=protected-access + second_span = command_tracer._get_span(second_mock_event) + command_tracer.succeeded(event=first_mock_event) + command_tracer.failed(event=second_mock_event) + + self.assertEqual(first_span.attributes["db.mongo.request_id"], "first") + self.assertIs( + first_span.status.canonical_code, + trace_api.status.StatusCanonicalCode.OK, + ) + self.assertEqual( + second_span.attributes["db.mongo.request_id"], "second" + ) + self.assertIs( + second_span.status.canonical_code, + trace_api.status.StatusCanonicalCode.UNKNOWN, + ) + + +class MockCommand: + def __init__(self, command_attrs): + self.command_attrs = command_attrs + + def get(self, key, default=""): + return self.command_attrs.get(key, default) + + +class MockEvent: + def __init__(self, command_attrs, connection_id=None, request_id=""): + self.command = MockCommand(command_attrs) + self.connection_id = connection_id + self.request_id = request_id + + def __getattr__(self, item): + return item + + +class MockSpan: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def __init__(self): + self.status = None + self.name = "" + self.kind = trace_api.SpanKind.INTERNAL + self.attributes = None + self.end_time = None + + def set_attribute(self, key, value): + self.attributes[key] = value + + def set_status(self, status): + self.status = status + + def end(self, end_time=None): + self.end_time = end_time if end_time is not None else time_ns() + + +class MockTracer: + def __init__(self): + self.end_span = mock.Mock() + + # pylint: disable=no-self-use + def start_span(self, name, kind): + span = MockSpan() + span.attributes = {} + span.status = None + span.name = name + span.kind = kind + return span diff --git a/tox.ini b/tox.ini index 70afcb120ca..600d6b86c5f 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ skipsdist = True skip_missing_interpreters = True envlist = - py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger,opentracing-shim} - pypy3-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger,opentracing-shim} + py3{4,5,6,7,8}-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} + pypy3-test-{api,sdk,example-app,ext-wsgi,ext-http-requests,ext-jaeger,ext-pymongo,opentracing-shim} py3{4,5,6,7,8}-coverage ; Coverage is temporarily disabled for pypy3 due to the pytest bug. @@ -32,6 +32,7 @@ changedir = test-sdk: opentelemetry-sdk/tests test-ext-http-requests: ext/opentelemetry-ext-http-requests/tests 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-example-app: examples/opentelemetry-example-app/tests test-opentracing-shim: ext/opentelemetry-ext-opentracing-shim/tests @@ -47,6 +48,7 @@ commands_pre = example-app: pip install {toxinidir}/examples/opentelemetry-example-app ext: pip install {toxinidir}/opentelemetry-api wsgi: pip install {toxinidir}/ext/opentelemetry-ext-wsgi + pymongo: pip install {toxinidir}/ext/opentelemetry-ext-pymongo http-requests: pip install {toxinidir}/ext/opentelemetry-ext-http-requests jaeger: pip install {toxinidir}/opentelemetry-sdk jaeger: pip install {toxinidir}/ext/opentelemetry-ext-jaeger @@ -94,6 +96,7 @@ commands_pre = pip install -e {toxinidir}/ext/opentelemetry-ext-azure-monitor pip install -e {toxinidir}/ext/opentelemetry-ext-http-requests pip install -e {toxinidir}/ext/opentelemetry-ext-jaeger + pip install -e {toxinidir}/ext/opentelemetry-ext-pymongo pip install -e {toxinidir}/ext/opentelemetry-ext-wsgi pip install -e {toxinidir}/examples/opentelemetry-example-app pip install -e {toxinidir}/ext/opentelemetry-ext-opentracing-shim @@ -115,6 +118,8 @@ commands = ext/opentelemetry-ext-jaeger/tests/ \ ext/opentelemetry-ext-opentracing-shim/src/ \ ext/opentelemetry-ext-opentracing-shim/tests/ \ + ext/opentelemetry-ext-pymongo/src/opentelemetry \ + ext/opentelemetry-ext-pymongo/tests/ \ ext/opentelemetry-ext-wsgi/tests/ \ examples/opentelemetry-example-app/src/opentelemetry_example_app/ \ examples/opentelemetry-example-app/tests/