Skip to content

Commit ca2b884

Browse files
Add mysql-connector integration factory, own get_traced_* methods
1 parent d8b1ebf commit ca2b884

File tree

1 file changed

+225
-25
lines changed
  • instrumentation/opentelemetry-instrumentation-mysql/src/opentelemetry/instrumentation/mysql

1 file changed

+225
-25
lines changed

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

Lines changed: 225 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -107,15 +107,28 @@
107107
---
108108
"""
109109

110-
from typing import Collection
110+
import logging
111+
from typing import (
112+
Any,
113+
Callable,
114+
Collection,
115+
Dict,
116+
Tuple,
117+
)
111118

112119
import mysql.connector
120+
import wrapt
121+
from mysql.connector.cursor_cext import CMySQLCursor
113122

123+
from opentelemetry import trace as trace_api
114124
from opentelemetry.instrumentation import dbapi
115125
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
116126
from opentelemetry.instrumentation.mysql.package import _instruments
117127
from opentelemetry.instrumentation.mysql.version import __version__
118128

129+
_logger = logging.getLogger(__name__)
130+
_OTEL_CURSOR_FACTORY_KEY = "_otel_orig_cursor_factory"
131+
119132

120133
class MySQLInstrumentor(BaseInstrumentor):
121134
_CONNECTION_ATTRIBUTES = {
@@ -146,6 +159,7 @@ def _instrument(self, **kwargs):
146159
self._CONNECTION_ATTRIBUTES,
147160
version=__version__,
148161
tracer_provider=tracer_provider,
162+
db_api_integration_factory=DatabaseApiIntegration,
149163
enable_commenter=enable_sqlcommenter,
150164
commenter_options=commenter_options,
151165
)
@@ -162,34 +176,220 @@ def instrument_connection(
162176
enable_commenter=None,
163177
commenter_options=None,
164178
):
165-
"""Enable instrumentation in a MySQL connection.
179+
if not hasattr(connection, "_is_instrumented_by_opentelemetry"):
180+
connection._is_instrumented_by_opentelemetry = False
181+
182+
if not connection._is_instrumented_by_opentelemetry:
183+
setattr(
184+
connection, _OTEL_CURSOR_FACTORY_KEY, connection.cursor_factory
185+
)
186+
connection.cursor_factory = _new_cursor_factory(
187+
tracer_provider=tracer_provider
188+
)
189+
connection._is_instrumented_by_opentelemetry = True
190+
else:
191+
_logger.warning(
192+
"Attempting to instrument mysql-connector connection while already instrumented"
193+
)
194+
return connection
195+
196+
def uninstrument_connection(
197+
self,
198+
connection,
199+
):
200+
connection.cursor_factory = getattr(
201+
connection, _OTEL_CURSOR_FACTORY_KEY, None
202+
)
166203

167-
Args:
168-
connection: The connection to instrument.
169-
tracer_provider: The optional tracer provider to use. If omitted
170-
the current globally configured one is used.
204+
return connection
171205

172-
Returns:
173-
An instrumented connection.
174-
"""
175-
return dbapi.instrument_connection(
206+
207+
class DatabaseApiIntegration(dbapi.DatabaseApiIntegration):
208+
def wrapped_connection(
209+
self,
210+
connect_method: Callable[..., Any],
211+
args: Tuple[Any, Any],
212+
kwargs: Dict[Any, Any],
213+
):
214+
"""Add object proxy to connection object."""
215+
connection = connect_method(*args, **kwargs)
216+
self.get_connection_attributes(connection)
217+
return get_traced_connection_proxy(connection, self)
218+
219+
220+
def get_traced_connection_proxy(
221+
connection, db_api_integration, *args, **kwargs
222+
):
223+
# pylint: disable=abstract-method
224+
class TracedConnectionProxy(wrapt.ObjectProxy):
225+
# pylint: disable=unused-argument
226+
def __init__(self, connection, *args, **kwargs):
227+
wrapt.ObjectProxy.__init__(self, connection)
228+
229+
def __getattribute__(self, name):
230+
if object.__getattribute__(self, name):
231+
return object.__getattribute__(self, name)
232+
233+
return object.__getattribute__(
234+
object.__getattribute__(self, "_connection"), name
235+
)
236+
237+
def cursor(self, *args, **kwargs):
238+
wrapped_cursor = self.__wrapped__.cursor(*args, **kwargs)
239+
240+
# It's common to have multiple db client cursors per app,
241+
# so enable_commenter is set at the cursor level and used
242+
# during traced query execution.
243+
enable_commenter_cursor = db_api_integration.enable_commenter
244+
245+
# If a mysql-connector cursor was created with prepared=True,
246+
# then MySQL statements will be prepared and executed natively.
247+
# 1:1 sqlcomment and span correlation in instrumentation would
248+
# break, so sqlcomment is not supported for this use case.
249+
# This is here because wrapped cursor is created when application
250+
# side creates cursor. After that, the instrumentor knows what
251+
# kind of cursor was initialized.
252+
if enable_commenter_cursor:
253+
is_prepared = False
254+
if (
255+
db_api_integration.database_system == "mysql"
256+
and db_api_integration.connect_module.__name__
257+
== "mysql.connector"
258+
):
259+
is_prepared = self.is_mysql_connector_cursor_prepared(
260+
wrapped_cursor
261+
)
262+
if is_prepared:
263+
_logger.warning(
264+
"sqlcomment is not supported for query statements executed by cursors with native prepared statement support. Disabling sqlcommenting for instrumentation of %s.",
265+
db_api_integration.connect_module.__name__,
266+
)
267+
enable_commenter_cursor = False
268+
return get_traced_cursor_proxy(
269+
wrapped_cursor,
270+
db_api_integration,
271+
enable_commenter=enable_commenter_cursor,
272+
)
273+
274+
def is_mysql_connector_cursor_prepared(self, cursor): # pylint: disable=no-self-use
275+
try:
276+
from mysql.connector.cursor_cext import ( # pylint: disable=import-outside-toplevel
277+
CMySQLCursorPrepared,
278+
CMySQLCursorPreparedDict,
279+
CMySQLCursorPreparedNamedTuple,
280+
CMySQLCursorPreparedRaw,
281+
)
282+
283+
if type(cursor) in [
284+
CMySQLCursorPrepared,
285+
CMySQLCursorPreparedDict,
286+
CMySQLCursorPreparedNamedTuple,
287+
CMySQLCursorPreparedRaw,
288+
]:
289+
return True
290+
291+
except ImportError as exc:
292+
_logger.warning(
293+
"Could not verify mysql.connector cursor, skipping prepared cursor check: %s",
294+
exc,
295+
)
296+
297+
return False
298+
299+
def __enter__(self):
300+
self.__wrapped__.__enter__()
301+
return self
302+
303+
def __exit__(self, *args, **kwargs):
304+
self.__wrapped__.__exit__(*args, **kwargs)
305+
306+
return TracedConnectionProxy(connection, *args, **kwargs)
307+
308+
309+
class CursorTracer(dbapi.CursorTracer):
310+
def __init__(
311+
self,
312+
db_api_integration: DatabaseApiIntegration,
313+
enable_commenter: bool = False,
314+
) -> None:
315+
super().__init__(db_api_integration)
316+
# It's common to have multiple db client cursors per app,
317+
# so enable_commenter is set at the cursor level and used
318+
# during traced query execution for mysql-connector
319+
self._commenter_enabled = enable_commenter
320+
321+
322+
def get_traced_cursor_proxy(cursor, db_api_integration, *args, **kwargs):
323+
enable_commenter = kwargs.get("enable_commenter", False)
324+
_cursor_tracer = CursorTracer(db_api_integration, enable_commenter)
325+
326+
# pylint: disable=abstract-method
327+
class TracedCursorProxy(wrapt.ObjectProxy):
328+
# pylint: disable=unused-argument
329+
def __init__(self, cursor, *args, **kwargs):
330+
wrapt.ObjectProxy.__init__(self, cursor)
331+
332+
def execute(self, *args, **kwargs):
333+
return _cursor_tracer.traced_execution(
334+
self.__wrapped__, self.__wrapped__.execute, *args, **kwargs
335+
)
336+
337+
def executemany(self, *args, **kwargs):
338+
return _cursor_tracer.traced_execution(
339+
self.__wrapped__, self.__wrapped__.executemany, *args, **kwargs
340+
)
341+
342+
def callproc(self, *args, **kwargs):
343+
return _cursor_tracer.traced_execution(
344+
self.__wrapped__, self.__wrapped__.callproc, *args, **kwargs
345+
)
346+
347+
def __enter__(self):
348+
self.__wrapped__.__enter__()
349+
return self
350+
351+
def __exit__(self, *args, **kwargs):
352+
self.__wrapped__.__exit__(*args, **kwargs)
353+
354+
return TracedCursorProxy(cursor, *args, **kwargs)
355+
356+
357+
def _new_cursor_factory(
358+
db_api: DatabaseApiIntegration = None,
359+
base_factory: CMySQLCursor = None,
360+
tracer_provider: trace_api.TracerProvider = None,
361+
enable_commenter: bool = False,
362+
):
363+
if not db_api:
364+
db_api = DatabaseApiIntegration(
176365
__name__,
177-
connection,
178-
self._DATABASE_SYSTEM,
179-
self._CONNECTION_ATTRIBUTES,
366+
MySQLInstrumentor._DATABASE_SYSTEM,
367+
MySQLInstrumentor._CONNECTION_ATTRIBUTES,
180368
version=__version__,
181369
tracer_provider=tracer_provider,
182-
enable_commenter=enable_commenter,
183-
commenter_options=commenter_options,
184370
)
185371

186-
def uninstrument_connection(self, connection):
187-
"""Disable instrumentation in a MySQL connection.
188-
189-
Args:
190-
connection: The connection to uninstrument.
191-
192-
Returns:
193-
An uninstrumented connection.
194-
"""
195-
return dbapi.uninstrument_connection(connection)
372+
# Latter is base class for all mysql-connector cursors
373+
base_factory = base_factory or CMySQLCursor
374+
_cursor_tracer = CursorTracer(
375+
db_api,
376+
enable_commenter,
377+
)
378+
379+
class TracedCursorFactory(base_factory):
380+
def execute(self, *args, **kwargs):
381+
return _cursor_tracer.traced_execution(
382+
self, super().execute, *args, **kwargs
383+
)
384+
385+
def executemany(self, *args, **kwargs):
386+
return _cursor_tracer.traced_execution(
387+
self, super().executemany, *args, **kwargs
388+
)
389+
390+
def callproc(self, *args, **kwargs):
391+
return _cursor_tracer.traced_execution(
392+
self, super().callproc, *args, **kwargs
393+
)
394+
395+
return TracedCursorFactory

0 commit comments

Comments
 (0)