Skip to content

Commit a6eaae0

Browse files
authored
Merge pull request #57 from DataDog/christian/mysql-integration
MySQL integration (supports mysql.connector API)
2 parents 25ea6aa + 3a92d3a commit a6eaae0

File tree

11 files changed

+687
-4
lines changed

11 files changed

+687
-4
lines changed

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ TEST_POSTGRES_PORT=55432
44
TEST_POSTGRES_USER=postgres
55
TEST_POSTGRES_PASSWORD=postgres
66
TEST_POSTGRES_DB=postgres
7+
TEST_MYSQL_ROOT_PASSWORD=admin
8+
TEST_MYSQL_PASSWORD=test
9+
TEST_MYSQL_USER=test
10+
TEST_MYSQL_DATABASE=test
11+
TEST_MYSQL_PORT=53306
712
TEST_REDIS_PORT=56379
813
TEST_MONGO_PORT=57017
914
TEST_MEMCACHED_PORT=51211

ddtrace/contrib/mysql/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
The MySQL mysql.connector integration works by creating patched
3+
MySQL connection classes which will trace API calls. For basic usage::
4+
5+
from ddtrace import tracer
6+
from ddtrace.contrib.mysql import get_traced_mysql_connection
7+
8+
# Trace the mysql.connector.connection.MySQLConnection class ...
9+
MySQL = get_traced_mysql_connection(tracer, service="my-mysql-server")
10+
conn = MySQL(user="alice", password="b0b", host="localhost", port=3306, database="test")
11+
cursor = conn.cursor()
12+
cursor.execute("SELECT 6*7 AS the_answer;")
13+
14+
This package works for mysql.connector version 2.1.x.
15+
Only the default full-Python integration works. The binary C connector,
16+
provided by _mysql_connector, is not supported yet.
17+
18+
Help on mysql.connector can be found on:
19+
https://dev.mysql.com/doc/connector-python/en/
20+
"""
21+
22+
from ..util import require_modules
23+
24+
required_modules = ['mysql.connector']
25+
26+
with require_modules(required_modules) as missing_modules:
27+
if not missing_modules:
28+
from .tracers import get_traced_mysql_connection
29+
30+
__all__ = ['get_traced_mysql_connection']

ddtrace/contrib/mysql/tracers.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""
2+
tracers exposed publicly
3+
"""
4+
# stdlib
5+
import time
6+
7+
from mysql.connector.connection import MySQLConnection
8+
from mysql.connector.cursor import MySQLCursor
9+
from mysql.connector.cursor import MySQLCursorRaw
10+
from mysql.connector.cursor import MySQLCursorBuffered
11+
from mysql.connector.cursor import MySQLCursorBufferedRaw
12+
from mysql.connector.errors import NotSupportedError
13+
from mysql.connector.errors import ProgrammingError
14+
15+
# dogtrace
16+
from ...ext import net
17+
from ...ext import db
18+
from ...ext import sql as sqlx
19+
from ...ext import AppTypes
20+
21+
22+
DEFAULT_SERVICE = 'mysql'
23+
_TRACEABLE_EXECUTE_FUNCS = {"callproc",
24+
"execute",
25+
"executemany"}
26+
_TRACEABLE_FETCH_FUNCS = {"fetchall",
27+
"fetchone",
28+
"fetchmany",
29+
"fetchwarnings"}
30+
_TRACEABLE_FUNCS = _TRACEABLE_EXECUTE_FUNCS.union(_TRACEABLE_FETCH_FUNCS)
31+
32+
def get_traced_mysql_connection(ddtracer, service=DEFAULT_SERVICE, meta=None, trace_fetch=False):
33+
"""Return a class which can be used to instanciante MySQL connections.
34+
35+
Keyword arguments:
36+
ddtracer -- the tracer to use
37+
service -- the service name
38+
meta -- your custom meta data
39+
trace_fetch -- set to True if you want fetchall, fetchone,
40+
fetchmany and fetchwarnings to be traced. By default
41+
only execute, executemany and callproc are traced.
42+
"""
43+
if trace_fetch:
44+
traced_funcs = _TRACEABLE_FUNCS
45+
else:
46+
traced_funcs = _TRACEABLE_EXECUTE_FUNCS
47+
return _get_traced_mysql_connection(ddtracer, MySQLConnection, service, meta, traced_funcs)
48+
49+
# # _mysql_connector unsupported for now, main reason being:
50+
# # not widespread yet, not easily instalable on our test envs.
51+
# # Once this is fixed, no reason not to support it.
52+
# def get_traced_mysql_connection_from(ddtracer, baseclass, service=DEFAULT_SERVICE, meta=None):
53+
# return _get_traced_mysql_connection(ddtracer, baseclass, service, meta, traced_funcs)
54+
55+
# pylint: disable=protected-access
56+
def _get_traced_mysql_connection(ddtracer, connection_baseclass, service, meta, traced_funcs):
57+
ddtracer.set_service_info(
58+
service=service,
59+
app='mysql',
60+
app_type=AppTypes.db,
61+
)
62+
63+
class TracedMySQLConnection(connection_baseclass):
64+
_datadog_tracer = ddtracer
65+
_datadog_service = service
66+
_datadog_conn_meta = meta
67+
68+
@classmethod
69+
def set_datadog_meta(cls, meta):
70+
cls._datadog_conn_meta = meta
71+
72+
def __init__(self, *args, **kwargs):
73+
self._datadog_traced_funcs = traced_funcs
74+
super(TracedMySQLConnection, self).__init__(*args, **kwargs)
75+
self._datadog_tags = {}
76+
for v in ((net.TARGET_HOST, "host"),
77+
(net.TARGET_PORT, "port"),
78+
(db.NAME, "database"),
79+
(db.USER, "user")):
80+
if v[1] in kwargs:
81+
self._datadog_tags[v[0]] = kwargs[v[1]]
82+
self._datadog_cursor_kwargs = {}
83+
for v in ("buffered", "raw"):
84+
if v in kwargs:
85+
self._datadog_cursor_kwargs[v] = kwargs[v]
86+
87+
def cursor(self, buffered=None, raw=None, cursor_class=None):
88+
db = self
89+
90+
if db._datadog_cursor_kwargs.get("buffered"):
91+
buffered = True
92+
if db._datadog_cursor_kwargs.get("raw"):
93+
raw = True
94+
# using MySQLCursor* constructors instead of super cursor
95+
# method as this one does not give a direct access to the
96+
# class makes overriding tricky
97+
if cursor_class:
98+
cursor_baseclass = cursor_class
99+
else:
100+
if raw:
101+
if buffered:
102+
cursor_baseclass = MySQLCursorBufferedRaw
103+
else:
104+
cursor_baseclass = MySQLCursorRaw
105+
else:
106+
if buffered:
107+
cursor_baseclass = MySQLCursorBuffered
108+
else:
109+
cursor_baseclass = MySQLCursor
110+
111+
class TracedMySQLCursor(cursor_baseclass):
112+
_datadog_tracer = ddtracer
113+
_datadog_service = service
114+
_datadog_conn_meta = meta
115+
116+
@classmethod
117+
def set_datadog_meta(cls, meta):
118+
cls._datadog_conn_meta = meta
119+
120+
def __init__(self, db=None):
121+
if db is None:
122+
raise NotSupportedError(
123+
"db is None, "
124+
"it should be defined before cursor "
125+
"creation when using ddtrace, "
126+
"please check your connection param")
127+
if not hasattr(db, "_datadog_tags"):
128+
raise ProgrammingError(
129+
"TracedMySQLCursor should be initialized"
130+
"with a TracedMySQLConnection")
131+
self._datadog_tags = db._datadog_tags
132+
self._datadog_cursor_creation = time.time()
133+
self._datadog_baseclass_name = cursor_baseclass.__name__
134+
super(TracedMySQLCursor, self).__init__(db)
135+
136+
# using *args, **kwargs instead of "operation, params, multi"
137+
# as multi, typically, might be available or not depending
138+
# on the version of mysql.connector
139+
def _datadog_execute(self, dd_func_name, *args, **kwargs):
140+
super_func = getattr(super(TracedMySQLCursor, self),dd_func_name)
141+
operation = ""
142+
if len(args) >= 1:
143+
operation = args[0]
144+
if "operation" in kwargs:
145+
operation = kwargs["operation"]
146+
# keep it for fetch* methods
147+
self._datadog_operation = operation
148+
if dd_func_name in db._datadog_traced_funcs:
149+
with self._datadog_tracer.trace('mysql.' + dd_func_name) as s:
150+
if s.sampled:
151+
s.service = self._datadog_service
152+
s.span_type = sqlx.TYPE
153+
s.resource = operation
154+
s.set_tag(sqlx.QUERY, operation)
155+
# dababase name available through db.NAME
156+
s.set_tags(self._datadog_tags)
157+
s.set_tags(self._datadog_conn_meta)
158+
result = super_func(*args,**kwargs)
159+
# Note, as stated on
160+
# https://dev.mysql.com/doc/connector-python/en/connector-python-api-mysqlcursor-rowcount.html
161+
# rowcount is not known before rows are fetched,
162+
# unless the cursor is a buffered one.
163+
# Don't be surprised if it's "-1"
164+
s.set_metric(sqlx.ROWS, self.rowcount)
165+
return result
166+
# not sampled
167+
return super_func(*args, **kwargs)
168+
else:
169+
# not using traces on this callback
170+
return super_func(*args, **kwargs)
171+
172+
def callproc(self, *args, **kwargs):
173+
return self._datadog_execute('callproc', *args, **kwargs)
174+
175+
def execute(self, *args, **kwargs):
176+
return self._datadog_execute('execute', *args, **kwargs)
177+
178+
def executemany(self, *args, **kwargs):
179+
return self._datadog_execute('executemany', *args, **kwargs)
180+
181+
def _datadog_fetch(self, dd_func_name, *args, **kwargs):
182+
super_func = getattr(super(TracedMySQLCursor, self),dd_func_name)
183+
if dd_func_name in db._datadog_traced_funcs:
184+
with self._datadog_tracer.trace('mysql.' + dd_func_name) as s:
185+
if s.sampled:
186+
s.service = self._datadog_service
187+
s.span_type = sqlx.TYPE
188+
# _datadog_operation refers to last execute* call
189+
if hasattr(self,"_datadog_operation"):
190+
s.resource = self._datadog_operation
191+
s.set_tag(sqlx.QUERY, self._datadog_operation)
192+
# dababase name available through db.NAME
193+
s.set_tags(self._datadog_tags)
194+
s.set_tags(self._datadog_conn_meta)
195+
result = super_func(*args, **kwargs)
196+
s.set_metric(sqlx.ROWS, self.rowcount)
197+
return result
198+
# not sampled
199+
return super_func(*args, **kwargs)
200+
else:
201+
# not using traces on this callback
202+
return super_func(*args, **kwargs)
203+
204+
def fetchall(self, *args, **kwargs):
205+
return self._datadog_fetch('fetchall', *args, **kwargs)
206+
207+
def fetchmany(self, *args, **kwargs):
208+
return self._datadog_fetch('fetchmany', *args, **kwargs)
209+
210+
def fetchone(self, *args, **kwargs):
211+
return self._datadog_fetch('fetchone', *args, **kwargs)
212+
213+
def fetchwarnings(self, *args, **kwargs):
214+
return self._datadog_fetch('fetchwarnings', *args, **kwargs)
215+
216+
return TracedMySQLCursor(db=db)
217+
218+
return TracedMySQLConnection

ddtrace/contrib/psycopg/connection.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import functools
77
import logging
88

9+
from ...ext import db
910
from ...ext import net
1011
from ...ext import sql as sqlx
1112
from ...ext import AppTypes
@@ -19,7 +20,7 @@
1920

2021
def connection_factory(tracer, service="postgres"):
2122
""" Return a connection factory class that will can be used to trace
22-
sqlite queries.
23+
postgres queries.
2324
2425
>>> factory = connection_factor(my_tracer, service="my_db_service")
2526
>>> conn = pyscopg2.connect(..., connection_factory=factory)
@@ -84,8 +85,8 @@ def __init__(self, *args, **kwargs):
8485
self._datadog_tags = {
8586
net.TARGET_HOST: dsn.get("host"),
8687
net.TARGET_PORT: dsn.get("port"),
87-
"db.name": dsn.get("dbname"),
88-
"db.user": dsn.get("user"),
88+
db.NAME: dsn.get("dbname"),
89+
db.USER: dsn.get("user"),
8990
"db.application" : dsn.get("application_name"),
9091
}
9192

ddtrace/ext/db.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
# tags
3+
NAME = "db.name" # the database name (eg: dbname for pgsql)
4+
USER = "db.user" # the user connecting to the db

docker-compose.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ postgres:
1111
image: postgres:9.5
1212
environment:
1313
- POSTGRES_PASSWORD=$TEST_POSTGRES_PASSWORD
14-
- POSTGRES_USER=$TEST_POSTGRES_PASSWORD
14+
- POSTGRES_USER=$TEST_POSTGRES_USER
1515
- POSTGRES_DB=$TEST_POSTGRES_DB
1616
ports:
1717
- "127.0.0.1:${TEST_POSTGRES_PORT}:5432"
18+
mysql:
19+
image: mysql:5.7
20+
environment:
21+
- MYSQL_ROOT_PASSWORD=$TEST_MYSQL_ROOT_PASSWORD
22+
- MYSQL_PASSWORD=$TEST_MYSQL_PASSWORD
23+
- MYSQL_USER=$TEST_MYSQL_USER
24+
- MYSQL_DATABASE=$TEST_MYSQL_DATABASE
25+
ports:
26+
- "127.0.0.1:${TEST_MYSQL_PORT}:3306"
1827
redis:
1928
image: redis:3.2
2029
ports:

docs/index.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,11 @@ MongoDB
136136

137137
.. automodule:: ddtrace.contrib.pymongo
138138

139+
MySQL
140+
~~~~~
141+
142+
.. automodule:: ddtrace.contrib.mysql
143+
139144
Postgres
140145
~~~~~~~~
141146

tests/contrib/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
'dbname' : os.getenv("TEST_POSTGRES_DB", "postgres"),
2626
}
2727

28+
MYSQL_CONFIG = {
29+
'host' : '127.0.0.1',
30+
'port' : int(os.getenv("TEST_MYSQL_PORT", 53306)),
31+
'user' : os.getenv("TEST_MYSQL_USER", 'test'),
32+
'password' : os.getenv("TEST_MYSQL_PASSWORD", 'test'),
33+
'database' : os.getenv("TEST_MYSQL_DATABASE", 'test'),
34+
}
35+
2836
REDIS_CONFIG = {
2937
'port': int(os.getenv("TEST_REDIS_PORT", 56379)),
3038
}

tests/contrib/mysql/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)