Skip to content

Commit a940fc4

Browse files
Add DB-API instrumentor support for MySQL driver sqlcommenting (#2897)
1 parent f6b68d0 commit a940fc4

File tree

3 files changed

+275
-17
lines changed

3 files changed

+275
-17
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434
([#2922](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2922))
3535
- `opentelemetry-instrumentation-celery` Don't detach context without a None token
3636
([#2927](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2927))
37+
- `opentelemetry-instrumentation-dbapi` sqlcommenter key values created from PostgreSQL, MySQL systems
38+
([#2897](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/2897))
3739

3840
### Breaking changes
3941

instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py

+85-14
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@
5353
)
5454
from opentelemetry.semconv.trace import SpanAttributes
5555
from opentelemetry.trace import SpanKind, TracerProvider, get_tracer
56+
from opentelemetry.util._importlib_metadata import version as util_version
57+
58+
_DB_DRIVER_ALIASES = {
59+
"MySQLdb": "mysqlclient",
60+
}
5661

5762
_logger = logging.getLogger(__name__)
5863

@@ -275,6 +280,70 @@ def __init__(
275280
self.name = ""
276281
self.database = ""
277282
self.connect_module = connect_module
283+
self.commenter_data = self.calculate_commenter_data()
284+
285+
def _get_db_version(
286+
self,
287+
db_driver,
288+
):
289+
if db_driver in _DB_DRIVER_ALIASES:
290+
return util_version(_DB_DRIVER_ALIASES[db_driver])
291+
db_version = ""
292+
try:
293+
db_version = self.connect_module.__version__
294+
except AttributeError:
295+
db_version = "unknown"
296+
return db_version
297+
298+
def calculate_commenter_data(
299+
self,
300+
):
301+
commenter_data = {}
302+
if not self.enable_commenter:
303+
return commenter_data
304+
305+
db_driver = getattr(self.connect_module, "__name__", "unknown")
306+
db_version = self._get_db_version(db_driver)
307+
308+
commenter_data = {
309+
"db_driver": f"{db_driver}:{db_version.split(' ')[0]}",
310+
# PEP 249-compliant drivers should have the following attributes.
311+
# We can assume apilevel "1.0" if not given.
312+
# We use "unknown" for others to prevent uncaught AttributeError.
313+
# https://peps.python.org/pep-0249/#globals
314+
"dbapi_threadsafety": getattr(
315+
self.connect_module, "threadsafety", "unknown"
316+
),
317+
"dbapi_level": getattr(self.connect_module, "apilevel", "1.0"),
318+
"driver_paramstyle": getattr(
319+
self.connect_module, "paramstyle", "unknown"
320+
),
321+
}
322+
323+
if self.database_system == "postgresql":
324+
if hasattr(self.connect_module, "__libpq_version__"):
325+
libpq_version = self.connect_module.__libpq_version__
326+
else:
327+
libpq_version = self.connect_module.pq.__build_version__
328+
commenter_data.update(
329+
{
330+
"libpq_version": libpq_version,
331+
}
332+
)
333+
elif self.database_system == "mysql":
334+
mysqlc_version = ""
335+
if db_driver == "MySQLdb":
336+
mysqlc_version = self.connect_module._mysql.get_client_info()
337+
elif db_driver == "pymysql":
338+
mysqlc_version = self.connect_module.get_client_info()
339+
340+
commenter_data.update(
341+
{
342+
"mysql_client_version": mysqlc_version,
343+
}
344+
)
345+
346+
return commenter_data
278347

279348
def wrapped_connection(
280349
self,
@@ -427,21 +496,23 @@ def traced_execution(
427496
if args and self._commenter_enabled:
428497
try:
429498
args_list = list(args)
430-
if hasattr(self._connect_module, "__libpq_version__"):
431-
libpq_version = self._connect_module.__libpq_version__
432-
else:
433-
libpq_version = (
434-
self._connect_module.pq.__build_version__
435-
)
436499

437-
commenter_data = {
438-
# Psycopg2/framework information
439-
"db_driver": f"psycopg2:{self._connect_module.__version__.split(' ')[0]}",
440-
"dbapi_threadsafety": self._connect_module.threadsafety,
441-
"dbapi_level": self._connect_module.apilevel,
442-
"libpq_version": libpq_version,
443-
"driver_paramstyle": self._connect_module.paramstyle,
444-
}
500+
# lazy capture of mysql-connector client version using cursor
501+
if (
502+
self._db_api_integration.database_system == "mysql"
503+
and self._db_api_integration.connect_module.__name__
504+
== "mysql.connector"
505+
and not self._db_api_integration.commenter_data[
506+
"mysql_client_version"
507+
]
508+
):
509+
self._db_api_integration.commenter_data[
510+
"mysql_client_version"
511+
] = cursor._cnx._cmysql.get_client_info()
512+
513+
commenter_data = dict(
514+
self._db_api_integration.commenter_data
515+
)
445516
if self._commenter_options.get(
446517
"opentelemetry_values", True
447518
):

instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py

+188-3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from opentelemetry.test.test_base import TestBase
2525

2626

27+
# pylint: disable=too-many-public-methods
2728
class TestDBApiIntegration(TestBase):
2829
def setUp(self):
2930
super().setUp()
@@ -252,6 +253,7 @@ def test_executemany(self):
252253

253254
def test_executemany_comment(self):
254255
connect_module = mock.MagicMock()
256+
connect_module.__name__ = "test"
255257
connect_module.__version__ = mock.MagicMock()
256258
connect_module.__libpq_version__ = 123
257259
connect_module.apilevel = 123
@@ -260,7 +262,7 @@ def test_executemany_comment(self):
260262

261263
db_integration = dbapi.DatabaseApiIntegration(
262264
"testname",
263-
"testcomponent",
265+
"postgresql",
264266
enable_commenter=True,
265267
commenter_options={"db_driver": False, "dbapi_level": False},
266268
connect_module=connect_module,
@@ -275,8 +277,38 @@ def test_executemany_comment(self):
275277
r"Select 1 /\*dbapi_threadsafety=123,driver_paramstyle='test',libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
276278
)
277279

280+
def test_executemany_comment_non_pep_249_compliant(self):
281+
class MockConnectModule:
282+
def __getattr__(self, name):
283+
if name == "__name__":
284+
return "test"
285+
if name == "__version__":
286+
return mock.MagicMock()
287+
if name == "__libpq_version__":
288+
return 123
289+
raise AttributeError("attribute missing")
290+
291+
connect_module = MockConnectModule()
292+
db_integration = dbapi.DatabaseApiIntegration(
293+
"testname",
294+
"postgresql",
295+
enable_commenter=True,
296+
connect_module=connect_module,
297+
commenter_options={"db_driver": False},
298+
)
299+
mock_connection = db_integration.wrapped_connection(
300+
mock_connect, {}, {}
301+
)
302+
cursor = mock_connection.cursor()
303+
cursor.executemany("Select 1;")
304+
self.assertRegex(
305+
cursor.query,
306+
r"Select 1 /\*dbapi_level='1.0',dbapi_threadsafety='unknown',driver_paramstyle='unknown',libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
307+
)
308+
278309
def test_compatible_build_version_psycopg_psycopg2_libpq(self):
279310
connect_module = mock.MagicMock()
311+
connect_module.__name__ = "test"
280312
connect_module.__version__ = mock.MagicMock()
281313
connect_module.pq = mock.MagicMock()
282314
connect_module.pq.__build_version__ = 123
@@ -286,7 +318,7 @@ def test_compatible_build_version_psycopg_psycopg2_libpq(self):
286318

287319
db_integration = dbapi.DatabaseApiIntegration(
288320
"testname",
289-
"testcomponent",
321+
"postgresql",
290322
enable_commenter=True,
291323
commenter_options={"db_driver": False, "dbapi_level": False},
292324
connect_module=connect_module,
@@ -301,8 +333,150 @@ def test_compatible_build_version_psycopg_psycopg2_libpq(self):
301333
r"Select 1 /\*dbapi_threadsafety=123,driver_paramstyle='test',libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
302334
)
303335

336+
def test_executemany_psycopg2_integration_comment(self):
337+
connect_module = mock.MagicMock()
338+
connect_module.__name__ = "psycopg2"
339+
connect_module.__version__ = "1.2.3"
340+
connect_module.__libpq_version__ = 123
341+
connect_module.apilevel = 123
342+
connect_module.threadsafety = 123
343+
connect_module.paramstyle = "test"
344+
345+
db_integration = dbapi.DatabaseApiIntegration(
346+
"testname",
347+
"postgresql",
348+
enable_commenter=True,
349+
commenter_options={"db_driver": True, "dbapi_level": False},
350+
connect_module=connect_module,
351+
)
352+
mock_connection = db_integration.wrapped_connection(
353+
mock_connect, {}, {}
354+
)
355+
cursor = mock_connection.cursor()
356+
cursor.executemany("Select 1;")
357+
self.assertRegex(
358+
cursor.query,
359+
r"Select 1 /\*db_driver='psycopg2%%3A1.2.3',dbapi_threadsafety=123,driver_paramstyle='test',libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
360+
)
361+
362+
def test_executemany_psycopg_integration_comment(self):
363+
connect_module = mock.MagicMock()
364+
connect_module.__name__ = "psycopg"
365+
connect_module.__version__ = "1.2.3"
366+
connect_module.pq = mock.MagicMock()
367+
connect_module.pq.__build_version__ = 123
368+
connect_module.apilevel = 123
369+
connect_module.threadsafety = 123
370+
connect_module.paramstyle = "test"
371+
372+
db_integration = dbapi.DatabaseApiIntegration(
373+
"testname",
374+
"postgresql",
375+
enable_commenter=True,
376+
commenter_options={"db_driver": True, "dbapi_level": False},
377+
connect_module=connect_module,
378+
)
379+
mock_connection = db_integration.wrapped_connection(
380+
mock_connect, {}, {}
381+
)
382+
cursor = mock_connection.cursor()
383+
cursor.executemany("Select 1;")
384+
self.assertRegex(
385+
cursor.query,
386+
r"Select 1 /\*db_driver='psycopg%%3A1.2.3',dbapi_threadsafety=123,driver_paramstyle='test',libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
387+
)
388+
389+
def test_executemany_mysqlconnector_integration_comment(self):
390+
connect_module = mock.MagicMock()
391+
connect_module.__name__ = "mysql.connector"
392+
connect_module.__version__ = "1.2.3"
393+
connect_module.apilevel = 123
394+
connect_module.threadsafety = 123
395+
connect_module.paramstyle = "test"
396+
397+
db_integration = dbapi.DatabaseApiIntegration(
398+
"testname",
399+
"mysql",
400+
enable_commenter=True,
401+
commenter_options={"db_driver": True, "dbapi_level": False},
402+
connect_module=connect_module,
403+
)
404+
405+
mock_connection = db_integration.wrapped_connection(
406+
mock_connect, {}, {}
407+
)
408+
cursor = mock_connection.cursor()
409+
cursor.executemany("Select 1;")
410+
self.assertRegex(
411+
cursor.query,
412+
r"Select 1 /\*db_driver='mysql.connector%%3A1.2.3',dbapi_threadsafety=123,driver_paramstyle='test',mysql_client_version='1.2.3',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
413+
)
414+
415+
@mock.patch("opentelemetry.instrumentation.dbapi.util_version")
416+
def test_executemany_mysqlclient_integration_comment(
417+
self,
418+
mock_dbapi_util_version,
419+
):
420+
mock_dbapi_util_version.return_value = "1.2.3"
421+
connect_module = mock.MagicMock()
422+
connect_module.__name__ = "MySQLdb"
423+
connect_module.__version__ = "1.2.3"
424+
connect_module.apilevel = 123
425+
connect_module.threadsafety = 123
426+
connect_module.paramstyle = "test"
427+
connect_module._mysql = mock.MagicMock()
428+
connect_module._mysql.get_client_info = mock.MagicMock(
429+
return_value="123"
430+
)
431+
432+
db_integration = dbapi.DatabaseApiIntegration(
433+
"testname",
434+
"mysql",
435+
enable_commenter=True,
436+
commenter_options={"db_driver": True, "dbapi_level": False},
437+
connect_module=connect_module,
438+
)
439+
440+
mock_connection = db_integration.wrapped_connection(
441+
mock_connect, {}, {}
442+
)
443+
cursor = mock_connection.cursor()
444+
cursor.executemany("Select 1;")
445+
self.assertRegex(
446+
cursor.query,
447+
r"Select 1 /\*db_driver='MySQLdb%%3A1.2.3',dbapi_threadsafety=123,driver_paramstyle='test',mysql_client_version='123',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
448+
)
449+
450+
def test_executemany_pymysql_integration_comment(self):
451+
connect_module = mock.MagicMock()
452+
connect_module.__name__ = "pymysql"
453+
connect_module.__version__ = "1.2.3"
454+
connect_module.apilevel = 123
455+
connect_module.threadsafety = 123
456+
connect_module.paramstyle = "test"
457+
connect_module.get_client_info = mock.MagicMock(return_value="123")
458+
459+
db_integration = dbapi.DatabaseApiIntegration(
460+
"testname",
461+
"mysql",
462+
enable_commenter=True,
463+
commenter_options={"db_driver": True, "dbapi_level": False},
464+
connect_module=connect_module,
465+
)
466+
467+
mock_connection = db_integration.wrapped_connection(
468+
mock_connect, {}, {}
469+
)
470+
cursor = mock_connection.cursor()
471+
cursor.executemany("Select 1;")
472+
self.assertRegex(
473+
cursor.query,
474+
r"Select 1 /\*db_driver='pymysql%%3A1.2.3',dbapi_threadsafety=123,driver_paramstyle='test',mysql_client_version='123',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
475+
)
476+
304477
def test_executemany_flask_integration_comment(self):
305478
connect_module = mock.MagicMock()
479+
connect_module.__name__ = "test"
306480
connect_module.__version__ = mock.MagicMock()
307481
connect_module.__libpq_version__ = 123
308482
connect_module.apilevel = 123
@@ -311,7 +485,7 @@ def test_executemany_flask_integration_comment(self):
311485

312486
db_integration = dbapi.DatabaseApiIntegration(
313487
"testname",
314-
"testcomponent",
488+
"postgresql",
315489
enable_commenter=True,
316490
commenter_options={"db_driver": False, "dbapi_level": False},
317491
connect_module=connect_module,
@@ -332,6 +506,11 @@ def test_executemany_flask_integration_comment(self):
332506
r"Select 1 /\*dbapi_threadsafety=123,driver_paramstyle='test',flask=1,libpq_version=123,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;",
333507
)
334508

509+
clear_context = context.set_value(
510+
"SQLCOMMENTER_ORM_TAGS_AND_VALUES", {}, current_context
511+
)
512+
context.attach(clear_context)
513+
335514
def test_callproc(self):
336515
db_integration = dbapi.DatabaseApiIntegration(
337516
"testname", "testcomponent"
@@ -415,6 +594,12 @@ class MockCursor:
415594
def __init__(self) -> None:
416595
self.query = ""
417596
self.params = None
597+
# Mock mysql.connector modules and method
598+
self._cnx = mock.MagicMock()
599+
self._cnx._cmysql = mock.MagicMock()
600+
self._cnx._cmysql.get_client_info = mock.MagicMock(
601+
return_value="1.2.3"
602+
)
418603

419604
# pylint: disable=unused-argument, no-self-use
420605
def execute(self, query, params=None, throw_exception=False):

0 commit comments

Comments
 (0)