Skip to content

Commit 3500546

Browse files
authored
Merge pull request #73 from DataDog/matt/reqs
requests: first pass at instrumenting requests
2 parents 71e2737 + 6155045 commit 3500546

File tree

10 files changed

+332
-5
lines changed

10 files changed

+332
-5
lines changed

ddtrace/compat.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
import http.client as httplib
2020
from io import StringIO
2121

22+
try:
23+
import urlparse
24+
except ImportError:
25+
from urllib import parse as urlparse
2226

2327
try:
2428
import simplejson as json
@@ -45,12 +49,13 @@ def to_unicode(s):
4549

4650

4751
__all__ = [
48-
'PY2',
49-
'urlencode',
5052
'httplib',
51-
'stringify',
53+
'iteritems',
54+
'json',
55+
'PY2',
5256
'Queue',
57+
'stringify',
5358
'StringIO',
54-
'json',
55-
'iteritems'
59+
'urlencode',
60+
'urlparse',
5661
]

ddtrace/contrib/autopatch.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""
2+
the autopatch module will attempt to automatically monkeypatch
3+
all available contrib modules.
4+
5+
It is currently experimental and incomplete.
6+
"""
7+
8+
9+
import logging
10+
import importlib
11+
12+
13+
log = logging.getLogger()
14+
15+
16+
# modules which are monkeypatch'able
17+
autopatch_modules = [
18+
'requests',
19+
]
20+
21+
22+
def autopatch():
23+
""" autopatch will attempt to patch all available contrib modules. """
24+
for module in autopatch_modules:
25+
path = 'ddtrace.contrib.%s' % module
26+
patch_module(path)
27+
28+
def patch_module(path):
29+
""" patch_module will attempt to autopatch the module with the given
30+
import path.
31+
"""
32+
log.debug("attempting to patch %s", path)
33+
imp = importlib.import_module(path)
34+
35+
func = getattr(imp, 'patch', None)
36+
if func is None:
37+
log.debug('no patch function in %s. skipping', path)
38+
return False
39+
40+
log.debug("calling patch func %s in %s", func, path)
41+
func()
42+
log.debug("patched")
43+
return True
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
To trace all HTTP calls from the requests library, patch the library like so::
3+
4+
# Patch the requests library.
5+
from ddtrace.contrib.requests import patch
6+
patch()
7+
8+
import requests
9+
requests.get("http://www.datadog.com")
10+
11+
If you would prefer finer grained control without monkeypatching the requests'
12+
code, use a TracedSession object as you would a requests.Session::
13+
14+
from ddtrace.contrib.requests import TracedSession
15+
16+
session = TracedSession()
17+
session.get("http://www.datadog.com")
18+
"""
19+
20+
21+
from ..util import require_modules
22+
23+
required_modules = ['requests']
24+
25+
with require_modules(required_modules) as missing_modules:
26+
if not missing_modules:
27+
from .patch import TracedSession, patch
28+
__all__ = ['TracedSession', 'patch']

ddtrace/contrib/requests/patch.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Tracing for the requests library.
3+
4+
https://github.com/kennethreitz/requests
5+
"""
6+
7+
# stdlib
8+
import logging
9+
10+
# 3p
11+
import requests
12+
import wrapt
13+
14+
# project
15+
import ddtrace
16+
from ddtrace.compat import urlparse
17+
from ddtrace.ext import http
18+
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
def patch():
24+
""" Monkeypatch the requests library to trace http calls. """
25+
wrapt.wrap_function_wrapper('requests', 'Session.request', _traced_request_func)
26+
27+
28+
def _traced_request_func(func, instance, args, kwargs):
29+
""" traced_request is a tracing wrapper for requests' Session.request
30+
instance method.
31+
"""
32+
33+
# perhaps a global tracer isn't what we want, so permit individual requests
34+
# sessions to have their own (with the standard global fallback)
35+
tracer = getattr(instance, 'datadog_tracer', ddtrace.tracer)
36+
37+
# bail on the tracing if not enabled.
38+
if not tracer.enabled:
39+
return func(*args, **kwargs)
40+
41+
method = kwargs.get('method') or args[0]
42+
url = kwargs.get('url') or args[1]
43+
44+
with tracer.trace("requests.request", span_type=http.TYPE) as span:
45+
resp = None
46+
try:
47+
resp = func(*args, **kwargs)
48+
return resp
49+
finally:
50+
try:
51+
_apply_tags(span, method, url, resp)
52+
except Exception:
53+
log.warn("error patching tags", exc_info=True)
54+
55+
56+
def _apply_tags(span, method, url, response):
57+
""" apply_tags will patch the given span with tags about the given request. """
58+
try:
59+
parsed = urlparse.urlparse(url)
60+
span.service = parsed.netloc
61+
# FIXME[matt] how do we decide how do we normalize arbitrary urls???
62+
path = parsed.path or "/"
63+
span.resource = "%s %s" % (method.upper(), path)
64+
except Exception:
65+
pass
66+
67+
span.set_tag(http.METHOD, method)
68+
span.set_tag(http.URL, url)
69+
if response is not None:
70+
span.set_tag(http.STATUS_CODE, response.status_code)
71+
span.error = 500 <= response.status_code
72+
73+
74+
class TracedSession(requests.Session):
75+
""" TracedSession is a requests' Session that is already patched.
76+
"""
77+
pass
78+
79+
# Always patch our traced session with the traced method (cheesy way of sharing
80+
# code)
81+
wrapt.wrap_function_wrapper(TracedSession, 'request', _traced_request_func)

ddtrace/ext/errors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
ERROR_TYPE = "error.type" # a string representing the type of the error
1010
ERROR_STACK = "error.stack" # a human readable version of the stack. beta.
1111

12+
# shorthand for -----^
13+
MSG = ERROR_MSG
14+
TYPE = ERROR_TYPE
15+
STACK = ERROR_STACK
16+
1217
def get_traceback(tb=None, error=None):
1318
t = None
1419
if error:

tests/autopatch.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
# manual test for autopatching
3+
import logging
4+
logging.basicConfig(level=logging.DEBUG)
5+
6+
from ddtrace.contrib.autopatch import autopatch
7+
8+
autopatch()

tests/contrib/requests/__init__.py

Whitespace-only changes.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
2+
# 3p
3+
from nose.tools import eq_, assert_raises
4+
from requests import Session
5+
6+
# project
7+
from ddtrace.contrib.requests import TracedSession
8+
from ddtrace.ext import http, errors
9+
from tests.test_tracer import get_test_tracer
10+
11+
12+
class TestRequests(object):
13+
14+
@staticmethod
15+
def test_resource_path():
16+
tracer, session = get_traced_session()
17+
out = session.get('http://httpstat.us/200')
18+
eq_(out.status_code, 200)
19+
spans = tracer.writer.pop()
20+
eq_(len(spans), 1)
21+
s = spans[0]
22+
eq_(s.resource, 'GET /200')
23+
24+
@staticmethod
25+
def test_resource_empty_path():
26+
tracer, session = get_traced_session()
27+
out = session.get('http://httpstat.us')
28+
eq_(out.status_code, 200)
29+
spans = tracer.writer.pop()
30+
eq_(len(spans), 1)
31+
s = spans[0]
32+
eq_(s.resource, 'GET /')
33+
34+
out = session.get('http://httpstat.us/')
35+
eq_(out.status_code, 200)
36+
spans = tracer.writer.pop()
37+
eq_(len(spans), 1)
38+
s = spans[0]
39+
eq_(s.resource, 'GET /')
40+
41+
@staticmethod
42+
def test_tracer_disabled():
43+
# ensure all valid combinations of args / kwargs work
44+
tracer, session = get_traced_session()
45+
tracer.enabled = False
46+
out = session.get('http://httpstat.us/200')
47+
eq_(out.status_code, 200)
48+
spans = tracer.writer.pop()
49+
eq_(len(spans), 0)
50+
51+
@staticmethod
52+
def test_args_kwargs():
53+
# ensure all valid combinations of args / kwargs work
54+
tracer, session = get_traced_session()
55+
url = 'http://httpstat.us/200'
56+
method = 'GET'
57+
inputs = [
58+
([], {'method': method, 'url': url}),
59+
([method], {'url': url}),
60+
([method, url], {}),
61+
]
62+
untraced = Session()
63+
for args, kwargs in inputs:
64+
# ensure an untraced request works with these args
65+
out = untraced.request(*args, **kwargs)
66+
eq_(out.status_code, 200)
67+
out = session.request(*args, **kwargs)
68+
eq_(out.status_code, 200)
69+
# validation
70+
spans = tracer.writer.pop()
71+
eq_(len(spans), 1)
72+
s = spans[0]
73+
eq_(s.get_tag(http.METHOD), 'GET')
74+
eq_(s.get_tag(http.STATUS_CODE), '200')
75+
76+
77+
@staticmethod
78+
def test_200():
79+
tracer, session = get_traced_session()
80+
out = session.get('http://httpstat.us/200')
81+
eq_(out.status_code, 200)
82+
# validation
83+
spans = tracer.writer.pop()
84+
eq_(len(spans), 1)
85+
s = spans[0]
86+
eq_(s.get_tag(http.METHOD), 'GET')
87+
eq_(s.get_tag(http.STATUS_CODE), '200')
88+
eq_(s.error, 0)
89+
eq_(s.service, 'httpstat.us')
90+
eq_(s.span_type, http.TYPE)
91+
92+
@staticmethod
93+
def test_post_500():
94+
tracer, session = get_traced_session()
95+
out = session.post('http://httpstat.us/500')
96+
# validation
97+
eq_(out.status_code, 500)
98+
spans = tracer.writer.pop()
99+
eq_(len(spans), 1)
100+
s = spans[0]
101+
eq_(s.get_tag(http.METHOD), 'POST')
102+
eq_(s.get_tag(http.STATUS_CODE), '500')
103+
eq_(s.error, 1)
104+
105+
@staticmethod
106+
def test_non_existant_url():
107+
tracer, session = get_traced_session()
108+
109+
try:
110+
session.get('http://doesnotexist.google.com')
111+
except Exception:
112+
pass
113+
else:
114+
assert 0, "expected error"
115+
116+
spans = tracer.writer.pop()
117+
eq_(len(spans), 1)
118+
s = spans[0]
119+
eq_(s.get_tag(http.METHOD), 'GET')
120+
eq_(s.error, 1)
121+
assert "Name or service not known" in s.get_tag(errors.MSG)
122+
assert "Name or service not known" in s.get_tag(errors.STACK)
123+
assert "Traceback (most recent call last)" in s.get_tag(errors.STACK)
124+
assert "requests.exception" in s.get_tag(errors.TYPE)
125+
126+
127+
@staticmethod
128+
def test_500():
129+
tracer, session = get_traced_session()
130+
out = session.get('http://httpstat.us/500')
131+
eq_(out.status_code, 500)
132+
133+
spans = tracer.writer.pop()
134+
eq_(len(spans), 1)
135+
s = spans[0]
136+
eq_(s.get_tag(http.METHOD), 'GET')
137+
eq_(s.get_tag(http.STATUS_CODE), '500')
138+
eq_(s.error, 1)
139+
140+
141+
def get_traced_session():
142+
tracer = get_test_tracer()
143+
session = TracedSession()
144+
setattr(session, 'datadog_tracer', tracer)
145+
return tracer, session

tests/test_tracer.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,3 +295,7 @@ def pop_services(self):
295295
self.services = {}
296296
return s
297297

298+
def get_test_tracer():
299+
tracer = Tracer()
300+
tracer.writer = DummyWriter()
301+
return tracer

tox.ini

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ envlist =
1818
{py27,py34}-mysqlconnector{21}
1919
{py27,py34}-pylibmc{140,150}
2020
{py27,py34}-pymongo{30,31,32,33}-mongoengine
21+
{py27,py34}-requests{208,209,210,211}
2122
{py27,py34}-sqlalchemy{10,11}-psycopg2
2223
{py27,py34}-all
2324

@@ -44,6 +45,7 @@ deps =
4445
all: pymongo
4546
all: python-memcached
4647
all: redis
48+
all: requests
4749
all: sqlalchemy
4850
blinker: blinker
4951
elasticsearch23: elasticsearch>=2.3,<2.4
@@ -69,6 +71,11 @@ deps =
6971
pymongo33: pymongo>=3.3
7072
psycopg2: psycopg2
7173
redis: redis
74+
requests200: requests>=2.0,<2.1
75+
requests208: requests>=2.8,<2.9
76+
requests209: requests>=2.9,<2.10
77+
requests210: requests>=2.10,<2.11
78+
requests211: requests>=2.11,<2.12
7279
sqlalchemy10: sqlalchemy>=1.0,<1.1
7380
sqlalchemy11: sqlalchemy==1.1.0b3
7481

@@ -93,6 +100,7 @@ commands =
93100
{py27,py34}-pymongo{30,31,32,33}: nosetests {posargs} tests/contrib/pymongo/
94101
{py27,py34}-mongoengine: nosetests {posargs} tests/contrib/mongoengine
95102
{py27,py34}-psycopg2: nosetests {posargs} tests/contrib/psycopg
103+
{py27,py34}-requests{200,208,209,210,211}: nosetests {posargs} tests/contrib/requests
96104
{py27,py34}-sqlalchemy{10,11}: nosetests {posargs} tests/contrib/sqlalchemy
97105

98106
[testenv:wait]

0 commit comments

Comments
 (0)