Skip to content

Commit c963c6f

Browse files
majorgreysKyle-Verhoogjd
committed
feat(dbapi): support Connection context management usage (#1762)
* fix(psycopg2): add test for connection used with contextmanager * dbapi: handle Connection __enter__ * psycopg2 < 2.5 connection doesn't support context management * add dbapi to wordlist * add tests for mysqldb * add dbapi tests * Update ddtrace/contrib/dbapi/__init__.py Co-authored-by: Julien Danjou <[email protected]> * add some doc strings * add test cases for other dbapi libraries * add test for no context manager * update comment Co-authored-by: Kyle Verhoog <[email protected]> Co-authored-by: Julien Danjou <[email protected]> Co-authored-by: Kyle Verhoog <[email protected]>
1 parent f46e443 commit c963c6f

File tree

9 files changed

+695
-2
lines changed

9 files changed

+695
-2
lines changed

ddtrace/contrib/dbapi/__init__.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ...settings import config
1010
from ...utils.formats import asbool, get_env
1111
from ...vendor import wrapt
12-
from ..trace_utils import ext_service
12+
from ..trace_utils import ext_service, iswrapped
1313

1414

1515
log = get_logger(__name__)
@@ -178,6 +178,49 @@ def __init__(self, conn, pin=None, cfg=None, cursor_cls=None):
178178
self._self_cursor_cls = cursor_cls
179179
self._self_config = cfg or config.dbapi2
180180

181+
def __enter__(self):
182+
"""Context management is not defined by the dbapi spec.
183+
184+
This means unfortunately that the database clients each define their own
185+
implementations.
186+
187+
The ones we know about are:
188+
189+
- mysqlclient<2.0 which returns a cursor instance. >=2.0 returns a
190+
connection instance.
191+
- psycopg returns a connection.
192+
- pyodbc returns a connection.
193+
- pymysql doesn't implement it.
194+
- sqlite3 returns the connection.
195+
"""
196+
r = self.__wrapped__.__enter__()
197+
198+
if hasattr(r, "cursor"):
199+
# r is Connection-like.
200+
if r is self.__wrapped__:
201+
# Return the reference to this proxy object. Returning r would
202+
# return the untraced reference.
203+
return self
204+
else:
205+
# r is a different connection object.
206+
# This should not happen in practice but play it safe so that
207+
# the original functionality is maintained.
208+
return r
209+
elif hasattr(r, "execute"):
210+
# r is Cursor-like.
211+
if iswrapped(r):
212+
return r
213+
else:
214+
pin = Pin.get_from(self)
215+
cfg = _get_config(self._self_config)
216+
if not pin:
217+
return r
218+
return self._self_cursor_cls(r, pin, cfg)
219+
else:
220+
# Otherwise r is some other object, so maintain the functionality
221+
# of the original.
222+
return r
223+
181224
def _trace_method(self, method, name, extra_tags, *args, **kwargs):
182225
pin = Pin.get_from(self)
183226
if not pin or not pin.enabled():

docs/spelling_wordlist.txt

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
CPython
2+
INfo
3+
MySQL
4+
OpenTracing
5+
aiobotocore
6+
aiohttp
7+
aiopg
8+
algolia
9+
algoliasearch
10+
analytics
11+
api
12+
app
13+
asgi
14+
autodetected
15+
autopatching
16+
backend
17+
bikeshedding
18+
boto
19+
botocore
20+
config
21+
coroutine
22+
coroutines
23+
datadog
24+
datadoghq
25+
datastore
26+
dbapi
27+
ddtrace
28+
django
29+
dogstatsd
30+
elasticsearch
31+
enqueue
32+
entrypoint
33+
entrypoints
34+
gRPC
35+
gevent
36+
greenlet
37+
greenlets
38+
grpc
39+
hostname
40+
http
41+
httplib
42+
https
43+
iPython
44+
integration
45+
integrations
46+
jinja
47+
kombu
48+
kubernetes
49+
kwarg
50+
lifecycle
51+
mako
52+
memcached
53+
metadata
54+
microservices
55+
middleware
56+
mongoengine
57+
mysql
58+
mysqlclient
59+
mysqldb
60+
namespace
61+
opentracer
62+
opentracing
63+
plugin
64+
posix
65+
postgres
66+
prepended
67+
profiler
68+
psycopg
69+
py
70+
pylibmc
71+
pymemcache
72+
pymongo
73+
pymysql
74+
pynamodb
75+
pyodbc
76+
quickstart
77+
redis
78+
rediscluster
79+
renderers
80+
repo
81+
runnable
82+
runtime
83+
sanic
84+
sqlalchemy
85+
sqlite
86+
starlette
87+
stringable
88+
subdomains
89+
submodules
90+
timestamp
91+
tweens
92+
uWSGI
93+
unix
94+
unregister
95+
url
96+
urls
97+
username
98+
uvicorn
99+
vertica
100+
whitelist
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
dbapi: add support for connection context manager usage

tests/contrib/dbapi/test_dbapi.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import mock
22

3+
import pytest
4+
35
from ddtrace import Pin
46
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
57
from ddtrace.contrib.dbapi import FetchTracedCursor, TracedCursor, TracedConnection
@@ -589,3 +591,179 @@ def test_connection_analytics_with_rate(self):
589591
traced_connection.commit()
590592
span = tracer.writer.pop()[0]
591593
self.assertIsNone(span.get_metric(ANALYTICS_SAMPLE_RATE_KEY))
594+
595+
def test_connection_context_manager(self):
596+
597+
class Cursor(object):
598+
rowcount = 0
599+
600+
def execute(self, *args, **kwargs):
601+
pass
602+
603+
def fetchall(self, *args, **kwargs):
604+
pass
605+
606+
def __enter__(self):
607+
return self
608+
609+
def __exit__(self, *exc):
610+
return False
611+
612+
def commit(self, *args, **kwargs):
613+
pass
614+
615+
# When a connection is returned from a context manager the object proxy
616+
# should be returned so that tracing works.
617+
618+
class ConnectionConnection(object):
619+
def __enter__(self):
620+
return self
621+
622+
def __exit__(self, *exc):
623+
return False
624+
625+
def cursor(self):
626+
return Cursor()
627+
628+
def commit(self):
629+
pass
630+
631+
pin = Pin("pin", tracer=self.tracer)
632+
conn = TracedConnection(ConnectionConnection(), pin)
633+
with conn as conn2:
634+
conn2.commit()
635+
spans = self.tracer.writer.pop()
636+
assert len(spans) == 1
637+
638+
with conn as conn2:
639+
with conn2.cursor() as cursor:
640+
cursor.execute("query")
641+
cursor.fetchall()
642+
643+
spans = self.tracer.writer.pop()
644+
assert len(spans) == 1
645+
646+
# If a cursor is returned from the context manager
647+
# then it should be instrumented.
648+
649+
class ConnectionCursor(object):
650+
def __enter__(self):
651+
return Cursor()
652+
653+
def __exit__(self, *exc):
654+
return False
655+
656+
def commit(self):
657+
pass
658+
659+
with TracedConnection(ConnectionCursor(), pin) as cursor:
660+
cursor.execute("query")
661+
cursor.fetchall()
662+
spans = self.tracer.writer.pop()
663+
assert len(spans) == 1
664+
665+
# If a traced cursor is returned then it should not
666+
# be double instrumented.
667+
668+
class ConnectionTracedCursor(object):
669+
def __enter__(self):
670+
return self.cursor()
671+
672+
def __exit__(self, *exc):
673+
return False
674+
675+
def cursor(self):
676+
return TracedCursor(Cursor(), pin, {})
677+
678+
def commit(self):
679+
pass
680+
681+
with TracedConnection(ConnectionTracedCursor(), pin) as cursor:
682+
cursor.execute("query")
683+
cursor.fetchall()
684+
spans = self.tracer.writer.pop()
685+
assert len(spans) == 1
686+
687+
# Check when a different connection object is returned
688+
# from a connection context manager.
689+
# No traces should be produced.
690+
691+
other_conn = ConnectionConnection()
692+
693+
class ConnectionDifferentConnection(object):
694+
def __enter__(self):
695+
return other_conn
696+
697+
def __exit__(self, *exc):
698+
return False
699+
700+
def cursor(self):
701+
return Cursor()
702+
703+
def commit(self):
704+
pass
705+
706+
conn = TracedConnection(ConnectionDifferentConnection(), pin)
707+
with conn as conn2:
708+
conn2.commit()
709+
spans = self.tracer.writer.pop()
710+
assert len(spans) == 0
711+
712+
with conn as conn2:
713+
with conn2.cursor() as cursor:
714+
cursor.execute("query")
715+
cursor.fetchall()
716+
717+
spans = self.tracer.writer.pop()
718+
assert len(spans) == 0
719+
720+
# When some unexpected value is returned from the context manager
721+
# it should be handled gracefully.
722+
723+
class ConnectionUnknown(object):
724+
def __enter__(self):
725+
return 123456
726+
727+
def __exit__(self, *exc):
728+
return False
729+
730+
def cursor(self):
731+
return Cursor()
732+
733+
def commit(self):
734+
pass
735+
736+
conn = TracedConnection(ConnectionDifferentConnection(), pin)
737+
with conn as conn2:
738+
conn2.commit()
739+
spans = self.tracer.writer.pop()
740+
assert len(spans) == 0
741+
742+
with conn as conn2:
743+
with conn2.cursor() as cursor:
744+
cursor.execute("query")
745+
cursor.fetchall()
746+
747+
spans = self.tracer.writer.pop()
748+
assert len(spans) == 0
749+
750+
# Errors should be the same when no context management is defined.
751+
752+
class ConnectionNoCtx(object):
753+
def cursor(self):
754+
return Cursor()
755+
756+
def commit(self):
757+
pass
758+
759+
conn = TracedConnection(ConnectionNoCtx(), pin)
760+
with pytest.raises(AttributeError):
761+
with conn:
762+
pass
763+
764+
with pytest.raises(AttributeError):
765+
with conn as conn2:
766+
pass
767+
768+
spans = self.tracer.writer.pop()
769+
assert len(spans) == 0

0 commit comments

Comments
 (0)