diff --git a/CHANGELOG.md b/CHANGELOG.md index daf3cc8eca..62e449cff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `opentelemetry-instrumentation-pymssql` Add pymssql instrumentation + ([#394](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/394)) + ### Fixed - `opentelemetry-instrumentation-boto3sqs` Make propagation compatible with other SQS instrumentations, add 'messaging.url' span attribute, and fix missing package dependencies. diff --git a/docs/instrumentation/pymssql/pymssql.rst b/docs/instrumentation/pymssql/pymssql.rst new file mode 100644 index 0000000000..0b1b589cb9 --- /dev/null +++ b/docs/instrumentation/pymssql/pymssql.rst @@ -0,0 +1,7 @@ +OpenTelemetry pymssql Instrumentation +===================================== + +.. automodule:: opentelemetry.instrumentation.pymssql + :members: + :undoc-members: + :show-inheritance: diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/README.rst b/instrumentation/opentelemetry-instrumentation-pymssql/README.rst new file mode 100644 index 0000000000..b885d6b5c5 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/README.rst @@ -0,0 +1,21 @@ +OpenTelemetry pymssql Instrumentation +===================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-pymssql.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-pymssql/ + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-pymssql + + +References +---------- +* `OpenTelemetry pymssql Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/setup.cfg b/instrumentation/opentelemetry-instrumentation-pymssql/setup.cfg new file mode 100644 index 0000000000..86ce58bc0e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/setup.cfg @@ -0,0 +1,55 @@ +# Copyright The 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-instrumentation-pymssql +description = OpenTelemetry pymssql instrumentation +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-contrib/tree/main/instrumentation/opentelemetry-instrumentation-pymssql +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.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + +[options] +python_requires = >=3.7 +package_dir= + =src +packages=find_namespace: +install_requires = + opentelemetry-api ~= 1.12 + opentelemetry-instrumentation-dbapi == 0.33b0 + opentelemetry-instrumentation == 0.33b0 + +[options.extras_require] +test = + opentelemetry-test == 0.33b0 + +[options.packages.find] +where = src + +[options.entry_points] +opentelemetry_instrumentor = + pymssql = opentelemetry.instrumentation.pymssql:PyMSSQLInstrumentor diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/setup.py b/instrumentation/opentelemetry-instrumentation-pymssql/setup.py new file mode 100644 index 0000000000..1014f65be8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/setup.py @@ -0,0 +1,99 @@ +# Copyright The 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. + + +# DO NOT EDIT. THIS FILE WAS AUTOGENERATED FROM templates/instrumentation_setup.py.txt. +# RUN `python scripts/generate_setup.py` TO REGENERATE. + + +import distutils.cmd +import json +import os +from configparser import ConfigParser + +import setuptools + +config = ConfigParser() +config.read("setup.cfg") + +# We provide extras_require parameter to setuptools.setup later which +# overwrites the extra_require section from setup.cfg. To support extra_require +# secion in setup.cfg, we load it here and merge it with the extra_require param. +extras_require = {} +if "options.extras_require" in config: + for key, value in config["options.extras_require"].items(): + extras_require[key] = [v for v in value.split("\n") if v.strip()] + +BASE_DIR = os.path.dirname(__file__) +PACKAGE_INFO = {} + +VERSION_FILENAME = os.path.join( + BASE_DIR, + "src", + "opentelemetry", + "instrumentation", + "pymssql", + "version.py", +) +with open(VERSION_FILENAME, encoding="utf-8") as f: + exec(f.read(), PACKAGE_INFO) + +PACKAGE_FILENAME = os.path.join( + BASE_DIR, + "src", + "opentelemetry", + "instrumentation", + "pymssql", + "package.py", +) +with open(PACKAGE_FILENAME, encoding="utf-8") as f: + exec(f.read(), PACKAGE_INFO) + +# Mark any instruments/runtime dependencies as test dependencies as well. +extras_require["instruments"] = PACKAGE_INFO["_instruments"] +test_deps = extras_require.get("test", []) +for dep in extras_require["instruments"]: + test_deps.append(dep) + +extras_require["test"] = test_deps + + +class JSONMetadataCommand(distutils.cmd.Command): + + description = ( + "print out package metadata as JSON. This is used by OpenTelemetry dev scripts to ", + "auto-generate code in other places", + ) + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + metadata = { + "name": config["metadata"]["name"], + "version": PACKAGE_INFO["__version__"], + "instruments": PACKAGE_INFO["_instruments"], + } + print(json.dumps(metadata)) + + +setuptools.setup( + cmdclass={"meta": JSONMetadataCommand}, + version=PACKAGE_INFO["__version__"], + extras_require=extras_require, +) diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/__init__.py b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/__init__.py new file mode 100644 index 0000000000..ec7e456d90 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/__init__.py @@ -0,0 +1,171 @@ +# Copyright The 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 integration with pymssql supports the `pymssql`_ library and can be enabled +by using ``PyMSSQLInstrumentor``. + +.. _pymssql: https://pypi.org/project/pymssql/ + +Usage +----- + +.. code:: python + + import pymssql + from opentelemetry.instrumentation.pymssql import PyMSSQLInstrumentor + + PyMSSQLInstrumentor().instrument() + + cnx = pymssql.connect(database="MSSQL_Database") + cursor = cnx.cursor() + cursor.execute("INSERT INTO test (testField) VALUES (123)" + cnx.commit() + cursor.close() + cnx.close() + +API +--- +""" +import typing + +import pymssql + +from opentelemetry.instrumentation import dbapi +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.pymssql.package import _instruments +from opentelemetry.instrumentation.pymssql.version import __version__ + + +class PyMSSQLConnectMethodArgsTuple(typing.NamedTuple): + server: str = None + user: str = None + password: str = None + database: str = None + timeout: int = None + login_timeout: int = None + charset: str = None + as_dict: bool = None + host: str = None + appname: str = None + port: str = None + conn_properties: str = None + autocommit: bool = None + tds_version: str = None + + +class PyMSSQLDatabaseApiIntegration(dbapi.DatabaseApiIntegration): + def wrapped_connection( + self, + connect_method: typing.Callable[..., typing.Any], + args: typing.Tuple[typing.Any, typing.Any], + kwargs: typing.Dict[typing.Any, typing.Any], + ): + """Add object proxy to connection object.""" + connection = connect_method(*args, **kwargs) + connect_method_args = PyMSSQLConnectMethodArgsTuple(*args) + + self.name = self.database_system + self.database = kwargs.get("database") or connect_method_args.database + + user = kwargs.get("user") or connect_method_args.user + if user is not None: + self.span_attributes["db.user"] = user + + port = kwargs.get("port") or connect_method_args.port + host = kwargs.get("server") or connect_method_args.server + if host is None: + host = kwargs.get("host") or connect_method_args.host + if host is not None: + # The host string can include the port, separated by either a coma or + # a column + for sep in (":", ","): + if sep in host: + tokens = host.rsplit(sep) + host = tokens[0] + if len(tokens) > 1: + port = tokens[1] + if host is not None: + self.span_attributes["net.peer.name"] = host + if port is not None: + self.span_attributes["net.peer.port"] = port + + charset = kwargs.get("charset") or connect_method_args.charset + if charset is not None: + self.span_attributes["db.charset"] = charset + + tds_version = ( + kwargs.get("tds_version") or connect_method_args.tds_version + ) + if tds_version is not None: + self.span_attributes["db.protocol.tds.version"] = tds_version + + return dbapi.get_traced_connection_proxy(connection, self) + + +class PyMSSQLInstrumentor(BaseInstrumentor): + _DATABASE_SYSTEM = "mssql" + + def instrumentation_dependencies(self) -> typing.Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + """Integrate with the pymssql library. + https://github.com/pymssql/pymssql/ + """ + tracer_provider = kwargs.get("tracer_provider") + + dbapi.wrap_connect( + __name__, + pymssql, + "connect", + self._DATABASE_SYSTEM, + version=__version__, + tracer_provider=tracer_provider, + db_api_integration_factory=PyMSSQLDatabaseApiIntegration, + ) + + def _uninstrument(self, **kwargs): + """"Disable pymssql instrumentation""" + dbapi.unwrap_connect(pymssql, "connect") + + # pylint:disable=no-self-use + def instrument_connection(self, connection): + """Enable instrumentation in a pymssql connection. + + Args: + connection: The connection to instrument. + + Returns: + An instrumented connection. + """ + + return dbapi.instrument_connection( + __name__, + connection, + self._DATABASE_SYSTEM, + self._CONNECTION_ATTRIBUTES, + version=__version__, + ) + + def uninstrument_connection(self, connection): + """Disable instrumentation in a pymssql connection. + + Args: + connection: The connection to uninstrument. + + Returns: + An uninstrumented connection. + """ + return dbapi.uninstrument_connection(connection) diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/package.py b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/package.py new file mode 100644 index 0000000000..e631f2d4b1 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/package.py @@ -0,0 +1,16 @@ +# Copyright The 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. + + +_instruments = ("pymssql > 2, < 3",) diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py new file mode 100644 index 0000000000..6b2801561b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/src/opentelemetry/instrumentation/pymssql/version.py @@ -0,0 +1,15 @@ +# Copyright The 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.33b0" diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-pymssql/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-pymssql/tests/test_pymssql_integration.py b/instrumentation/opentelemetry-instrumentation-pymssql/tests/test_pymssql_integration.py new file mode 100644 index 0000000000..d3be512781 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-pymssql/tests/test_pymssql_integration.py @@ -0,0 +1,125 @@ +# Copyright The 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. + +from unittest.mock import Mock, patch + +import pymssql + +import opentelemetry.instrumentation.pymssql +from opentelemetry.instrumentation.pymssql import PyMSSQLInstrumentor +from opentelemetry.sdk import resources +from opentelemetry.test.test_base import TestBase + + +def mock_connect(*args, **kwargs): + class MockConnection: + def cursor(self): + # pylint: disable=no-self-use + return Mock() + + return MockConnection() + + +class TestPyMSSQLIntegration(TestBase): + def tearDown(self): + super().tearDown() + with self.disable_logging(): + PyMSSQLInstrumentor().uninstrument() + + @patch("pymssql.connect", new=mock_connect) + # pylint: disable=unused-argument + def test_instrumentor(self): + PyMSSQLInstrumentor().instrument() + + cnx = pymssql.connect(database="test") + cursor = cnx.cursor() + query = "SELECT * FROM test" + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + span = spans_list[0] + + # Check version and name in span's instrumentation info + self.assertEqualSpanInstrumentationInfo( + span, opentelemetry.instrumentation.pymssql + ) + + # check that no spans are generated after uninstrument + PyMSSQLInstrumentor().uninstrument() + + cnx = pymssql.connect(database="test") + cursor = cnx.cursor() + query = "SELECT * FROM test" + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + @patch("pymssql.connect", new=mock_connect) + # pylint: disable=unused-argument + def test_custom_tracer_provider(self): + resource = resources.Resource.create({}) + result = self.create_tracer_provider(resource=resource) + tracer_provider, exporter = result + + PyMSSQLInstrumentor().instrument(tracer_provider=tracer_provider) + + cnx = pymssql.connect(database="test") + cursor = cnx.cursor() + query = "SELECT * FROM test" + cursor.execute(query) + + spans_list = exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + span = spans_list[0] + + self.assertIs(span.resource, resource) + + @patch("pymssql.connect", new=mock_connect) + # pylint: disable=unused-argument + def test_instrument_connection(self): + cnx = pymssql.connect(database="test") + query = "SELECT * FROM test" + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 0) + + cnx = PyMSSQLInstrumentor().instrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + @patch("pymssql.connect", new=mock_connect) + # pylint: disable=unused-argument + def test_uninstrument_connection(self): + PyMSSQLInstrumentor().instrument() + cnx = pymssql.connect(database="test") + query = "SELECT * FROM test" + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) + + cnx = PyMSSQLInstrumentor().uninstrument_connection(cnx) + cursor = cnx.cursor() + cursor.execute(query) + + spans_list = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans_list), 1) diff --git a/tox.ini b/tox.ini index 80fd488dce..0a00d06073 100644 --- a/tox.ini +++ b/tox.ini @@ -119,6 +119,10 @@ envlist = py3{7,8,9,10}-test-instrumentation-pymongo pypy3-test-instrumentation-pymongo + ; opentelemetry-instrumentation-pymssql + py3{7,8,9,10}-test-instrumentation-pymssql + pypy3-test-instrumentation-pymssql + ; opentelemetry-instrumentation-pymysql py3{7,8,9,10}-test-instrumentation-pymysql pypy3-test-instrumentation-pymysql @@ -280,6 +284,7 @@ changedir = test-instrumentation-psycopg2: instrumentation/opentelemetry-instrumentation-psycopg2/tests test-instrumentation-pymemcache{135,200,300,342}: instrumentation/opentelemetry-instrumentation-pymemcache/tests test-instrumentation-pymongo: instrumentation/opentelemetry-instrumentation-pymongo/tests + test-instrumentation-pymssql: instrumentation/opentelemetry-instrumentation-pymssql/tests test-instrumentation-pymysql: instrumentation/opentelemetry-instrumentation-pymysql/tests test-instrumentation-pyramid: instrumentation/opentelemetry-instrumentation-pyramid/tests test-instrumentation-redis: instrumentation/opentelemetry-instrumentation-redis/tests @@ -357,6 +362,8 @@ commands_pre = psycopg2: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi {toxinidir}/instrumentation/opentelemetry-instrumentation-psycopg2[test] + pymssql: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi {toxinidir}/instrumentation/opentelemetry-instrumentation-pymssql[test] + pymysql: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-dbapi {toxinidir}/instrumentation/opentelemetry-instrumentation-pymysql[test] pyramid: pip install {toxinidir}/instrumentation/opentelemetry-instrumentation-pyramid[test]