diff --git a/CHANGELOG.md b/CHANGELOG.md index 024990c91d..7c5d57b229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3615](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3615)) - `opentelemetry-instrumentation-fastapi`: Don't pass bounded server_request_hook when using `FastAPIInstrumentor.instrument()` ([#3701](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3701)) +- `opentelemetry-instrumentation-dbapi`: Fix sqlcomment calculation of mysql_client_version field if connection reassignment, with "unknown" fallback + ([#3729](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3729)) ### Added diff --git a/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py b/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py index ec088e1aa5..663f382b5d 100644 --- a/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py @@ -623,9 +623,24 @@ def _capture_mysql_version(self, cursor) -> None: "mysql_client_version" ] ): - self._db_api_integration.commenter_data["mysql_client_version"] = ( - cursor._cnx._cmysql.get_client_info() - ) + try: + # Autoinstrumentation and some programmatic calls + self._db_api_integration.commenter_data[ + "mysql_client_version" + ] = cursor._cnx._cmysql.get_client_info() + except AttributeError: + # Other programmatic instrumentation with reassigned wrapped connection + try: + self._db_api_integration.commenter_data[ + "mysql_client_version" + ] = cursor._connection._cmysql.get_client_info() + except AttributeError as exc: + _logger.error( + "Could not set mysql_client_version: %s", exc + ) + self._db_api_integration.commenter_data[ + "mysql_client_version" + ] = "unknown" def _get_commenter_data(self) -> dict: """Uses DB-API integration to return commenter data for sqlcomment""" diff --git a/instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py b/instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py index 7d3396353a..d17cad4a10 100644 --- a/instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py +++ b/instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py @@ -1188,6 +1188,89 @@ def test_non_string_sql_conversion(self): spans_list = self.memory_exporter.get_finished_spans() self.assertEqual(len(spans_list), 1) + def test_capture_mysql_version_primary_success(self): + connect_module = mock.MagicMock() + connect_module.__name__ = "mysql.connector" + connect_module.__version__ = "2.2.9" + db_integration = dbapi.DatabaseApiIntegration( + "instrumenting_module_test_name", + "mysql", + enable_commenter=True, + connect_module=connect_module, + ) + mock_cursor = mock.MagicMock() + mock_cursor._cnx._cmysql.get_client_info.return_value = "8.0.32" + mock_connection = db_integration.wrapped_connection( + mock_connect, {}, {} + ) + cursor = mock_connection.cursor() + cursor._cnx = mock_cursor._cnx + cursor.execute("SELECT 1;") + mock_cursor._cnx._cmysql.get_client_info.assert_called_once() + self.assertEqual( + db_integration.commenter_data["mysql_client_version"], "8.0.32" + ) + + def test_capture_mysql_version_fallback_success(self): + connect_module = mock.MagicMock() + connect_module.__name__ = "mysql.connector" + connect_module.__version__ = "2.2.9" + db_integration = dbapi.DatabaseApiIntegration( + "instrumenting_module_test_name", + "mysql", + enable_commenter=True, + connect_module=connect_module, + ) + mock_cursor = mock.MagicMock() + mock_cursor._cnx._cmysql.get_client_info.side_effect = AttributeError( + "Primary method failed" + ) + mock_cursor._connection._cmysql.get_client_info.return_value = "8.0.33" + mock_connection = db_integration.wrapped_connection( + mock_connect, {}, {} + ) + cursor = mock_connection.cursor() + cursor._cnx = mock_cursor._cnx + cursor._connection = mock_cursor._connection + cursor.execute("SELECT 1;") + mock_cursor._cnx._cmysql.get_client_info.assert_called_once() + mock_cursor._connection._cmysql.get_client_info.assert_called_once() + self.assertEqual( + db_integration.commenter_data["mysql_client_version"], "8.0.33" + ) + + @mock.patch("opentelemetry.instrumentation.dbapi._logger") + def test_capture_mysql_version_fallback(self, mock_logger): + connect_module = mock.MagicMock() + connect_module.__name__ = "mysql.connector" + connect_module.__version__ = "2.2.9" + db_integration = dbapi.DatabaseApiIntegration( + "instrumenting_module_test_name", + "mysql", + enable_commenter=True, + connect_module=connect_module, + ) + mock_cursor = mock.MagicMock() + mock_cursor._cnx._cmysql.get_client_info.side_effect = AttributeError( + "Primary method failed" + ) + mock_cursor._connection._cmysql.get_client_info.side_effect = ( + AttributeError("Fallback method failed") + ) + mock_connection = db_integration.wrapped_connection( + mock_connect, {}, {} + ) + cursor = mock_connection.cursor() + cursor._cnx = mock_cursor._cnx + cursor._connection = mock_cursor._connection + cursor.execute("SELECT 1;") + mock_cursor._cnx._cmysql.get_client_info.assert_called_once() + mock_cursor._connection._cmysql.get_client_info.assert_called_once() + mock_logger.error.assert_called_once() + self.assertEqual( + db_integration.commenter_data["mysql_client_version"], "unknown" + ) + # pylint: disable=unused-argument def mock_connect(*args, **kwargs): diff --git a/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_sqlcommenter.py b/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_sqlcommenter.py new file mode 100644 index 0000000000..5c669b19e5 --- /dev/null +++ b/tests/opentelemetry-docker-tests/tests/mysql/test_mysql_sqlcommenter.py @@ -0,0 +1,81 @@ +# Copyright 2025, 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 mysql.connector + +from opentelemetry.instrumentation.mysql import MySQLInstrumentor +from opentelemetry.test.test_base import TestBase + +MYSQL_USER = os.getenv("MYSQL_USER", "testuser") +MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "testpassword") +MYSQL_HOST = os.getenv("MYSQL_HOST", "localhost") +MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306")) +MYSQL_DB_NAME = os.getenv("MYSQL_DB_NAME", "opentelemetry-tests") + + +class TestFunctionalMySqlCommenter(TestBase): + def test_commenter_enabled_direct_reference(self): + MySQLInstrumentor().instrument(enable_commenter=True) + cnx = mysql.connector.connect( + user=MYSQL_USER, + password=MYSQL_PASSWORD, + host=MYSQL_HOST, + port=MYSQL_PORT, + database=MYSQL_DB_NAME, + ) + cursor = cnx.cursor() + + cursor.execute("SELECT 1;") + cursor.fetchall() + self.assertRegex( + cursor.statement, + r"SELECT 1 /\*db_driver='mysql\.connector[^']*',dbapi_level='\d\.\d',dbapi_threadsafety=\d,driver_paramstyle='[^']*',mysql_client_version='[^']*',traceparent='[^']*'\*/;", + ) + self.assertRegex( + cursor.statement, r"mysql_client_version='(?!unknown)[^']+" + ) + + cursor.close() + cnx.close() + MySQLInstrumentor().uninstrument() + + def test_commenter_enabled_connection_proxy(self): + cnx = mysql.connector.connect( + user=MYSQL_USER, + password=MYSQL_PASSWORD, + host=MYSQL_HOST, + port=MYSQL_PORT, + database=MYSQL_DB_NAME, + ) + instrumented_cnx = MySQLInstrumentor().instrument_connection( + connection=cnx, + enable_commenter=True, + ) + cursor = instrumented_cnx.cursor() + + cursor.execute("SELECT 1;") + cursor.fetchall() + self.assertRegex( + cursor.statement, + r"SELECT 1 /\*db_driver='mysql\.connector[^']*',dbapi_level='\d\.\d',dbapi_threadsafety=\d,driver_paramstyle='[^']*',mysql_client_version='[^']*',traceparent='[^']*'\*/;", + ) + self.assertRegex( + cursor.statement, r"mysql_client_version='(?!unknown)[^']+" + ) + + cursor.close() + MySQLInstrumentor().uninstrument_connection(instrumented_cnx) + cnx.close()