Skip to content
This repository was archived by the owner on Jun 13, 2023. It is now read-only.

Commit 388053a

Browse files
authored
feat(tornado_client.py): support tornado http client calls (#318)
1 parent b1ca110 commit 388053a

6 files changed

Lines changed: 265 additions & 4 deletions

File tree

epsagon/events/tornado_client.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""
2+
Tornado AsyncHTTPClient events module.
3+
"""
4+
5+
from __future__ import absolute_import
6+
7+
import functools
8+
try:
9+
from urllib.parse import urlparse, urlunparse
10+
except ImportError:
11+
from urlparse import urlparse, urlunparse
12+
import traceback
13+
from uuid import uuid4
14+
15+
from epsagon.utils import add_data_if_needed
16+
from ..trace import trace_factory
17+
from ..event import BaseEvent
18+
from ..http_filters import (
19+
is_blacklisted_url,
20+
is_payload_collection_blacklisted
21+
)
22+
from ..utils import update_http_headers, normalize_http_url
23+
from ..constants import HTTP_ERR_CODE, EPSAGON_HEADER_TITLE
24+
25+
26+
class TornadoAsyncHTTPClientEvent(BaseEvent):
27+
"""
28+
Represents base request event.
29+
"""
30+
31+
ORIGIN = 'tornado_client'
32+
RESOURCE_TYPE = 'http'
33+
34+
# pylint: disable=W0613
35+
def __init__(self, wrapped, instance, args, kwargs, start_time, response,
36+
exception):
37+
"""
38+
Initialize.
39+
:param wrapped: wrapt's wrapped
40+
:param instance: wrapt's instance
41+
:param args: wrapt's args
42+
:param kwargs: wrapt's kwargs
43+
:param start_time: Start timestamp (epoch)
44+
:param response: response data
45+
:param exception: Exception (if happened)
46+
"""
47+
super(TornadoAsyncHTTPClientEvent, self).__init__(start_time)
48+
self.event_id = 'tornado-client-{}'.format(str(uuid4()))
49+
50+
request = args[0]
51+
headers = dict(request.headers)
52+
if headers:
53+
# Make sure trace ID is present in case headers will be removed.
54+
epsagon_trace_id = headers.get(EPSAGON_HEADER_TITLE)
55+
if epsagon_trace_id:
56+
self.resource['metadata']['http_trace_id'] = epsagon_trace_id
57+
58+
parsed_url = urlparse(request.url)
59+
60+
host_url = parsed_url.netloc.split(':')[0]
61+
full_url = urlunparse((
62+
parsed_url.scheme,
63+
host_url,
64+
parsed_url.path,
65+
parsed_url.params,
66+
parsed_url.query,
67+
parsed_url.fragment,
68+
))
69+
70+
self.resource['name'] = normalize_http_url(request.url)
71+
self.resource['operation'] = request.method
72+
self.resource['metadata']['url'] = request.url
73+
74+
if not is_payload_collection_blacklisted(full_url):
75+
add_data_if_needed(
76+
self.resource['metadata'],
77+
'request_headers',
78+
headers
79+
)
80+
body = request.body
81+
if isinstance(body, bytes):
82+
body = body.decode('utf-8')
83+
if body:
84+
add_data_if_needed(
85+
self.resource['metadata'],
86+
'request_body',
87+
body
88+
)
89+
90+
if response is not None:
91+
callback = functools.partial(self.update_response)
92+
response.add_done_callback(callback)
93+
94+
if exception is not None:
95+
self.set_exception(exception, traceback.format_exc())
96+
97+
def update_response(self, future):
98+
"""
99+
Adds response data to event.
100+
:param future: Future response object
101+
:return: None
102+
"""
103+
response = future.result()
104+
105+
self.resource['metadata']['status_code'] = response.code
106+
self.resource = update_http_headers(
107+
self.resource,
108+
dict(response.headers)
109+
)
110+
111+
full_url = self.resource['metadata']['url']
112+
113+
if not is_payload_collection_blacklisted(full_url):
114+
add_data_if_needed(
115+
self.resource['metadata'],
116+
'response_headers',
117+
dict(response.headers)
118+
)
119+
body = response.body
120+
if isinstance(body, bytes):
121+
try:
122+
body = body.decode('utf-8')
123+
except UnicodeDecodeError:
124+
body = str(body)
125+
if body:
126+
add_data_if_needed(
127+
self.resource['metadata'],
128+
'response_body',
129+
body
130+
)
131+
132+
# Detect errors based on status code
133+
if response.code >= HTTP_ERR_CODE:
134+
self.set_error()
135+
136+
137+
class TornadoClientEventFactory(object):
138+
"""
139+
Factory class, generates AsyncHTTPClient event.
140+
"""
141+
142+
@staticmethod
143+
def create_event(wrapped, instance, args, kwargs, start_time, response,
144+
exception):
145+
"""
146+
Create an AsyncHTTPClient event.
147+
"""
148+
# Detect if URL is blacklisted, and ignore.
149+
if is_blacklisted_url(args[0].url):
150+
return
151+
152+
event = TornadoAsyncHTTPClientEvent(
153+
wrapped,
154+
instance,
155+
args,
156+
kwargs,
157+
start_time,
158+
response,
159+
exception
160+
)
161+
162+
trace_factory.add_event(event)

epsagon/modules/tornado.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,20 @@
88
import uuid
99
from functools import partial
1010
import wrapt
11+
from tornado.httpclient import HTTPRequest
12+
from tornado.httputil import HTTPHeaders
1113
import epsagon.trace
14+
from epsagon.modules.general_wrapper import wrapper
1215
from epsagon.runners.tornado import TornadoRunner
1316
from epsagon.http_filters import ignore_request, is_ignored_endpoint
14-
from epsagon.utils import collect_container_metadata, print_debug
17+
from epsagon.utils import (
18+
collect_container_metadata,
19+
print_debug,
20+
get_epsagon_http_trace_id
21+
)
22+
from ..constants import EPSAGON_HEADER
23+
from ..events.tornado_client import TornadoClientEventFactory
24+
1525

1626
TORNADO_TRACE_ID = 'epsagon_tornado_trace_key'
1727

@@ -229,6 +239,50 @@ def thread_pool_execution(cls, unique_id, fn, args, kwargs):
229239
return res
230240

231241

242+
def _prepare_http_request(request, raise_error=True, **kwargs):
243+
"""
244+
Copying parameters from original `AsyncHTTPClient.fetch` function
245+
:return: HTTPRequest, raise_error bool
246+
"""
247+
# request can be a URL string or a HTTPRequest object
248+
if not isinstance(request, HTTPRequest):
249+
request = HTTPRequest(request, **kwargs)
250+
251+
return request, raise_error
252+
253+
254+
def _wrapper(wrapped, instance, args, kwargs):
255+
"""
256+
General wrapper for AsyncHTTPClient instrumentation.
257+
:param wrapped: wrapt's wrapped
258+
:param instance: wrapt's instance
259+
:param args: wrapt's args
260+
:param kwargs: wrapt's kwargs
261+
:return: None
262+
"""
263+
try:
264+
request, raise_error = _prepare_http_request(*args, **kwargs)
265+
except Exception: # pylint: disable=W0703
266+
return wrapped(*args, **kwargs)
267+
268+
trace_header = get_epsagon_http_trace_id()
269+
270+
if isinstance(request.headers, HTTPHeaders):
271+
if not request.headers.get(EPSAGON_HEADER):
272+
request.headers.add(EPSAGON_HEADER, trace_header)
273+
elif isinstance(request.headers, dict):
274+
if EPSAGON_HEADER not in request.headers:
275+
request.headers[EPSAGON_HEADER] = trace_header
276+
277+
return wrapper(
278+
TornadoClientEventFactory,
279+
wrapped,
280+
instance,
281+
(request, ), # new args
282+
{'raise_error': raise_error} # new kwargs
283+
)
284+
285+
232286
def patch():
233287
"""
234288
Patch module.
@@ -267,3 +321,9 @@ def patch():
267321
except Exception: # pylint: disable=broad-except
268322
# Can happen in different Tornado versions.
269323
pass
324+
325+
wrapt.wrap_function_wrapper(
326+
'tornado.httpclient',
327+
'AsyncHTTPClient.fetch',
328+
_wrapper
329+
)

epsagon/runners/aiohttp.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from aiohttp.payload import StringPayload
88
from ..event import BaseEvent
99
from ..utils import add_data_if_needed, normalize_http_url
10-
from ..constants import EPSAGON_HEADER
10+
from ..constants import EPSAGON_HEADER_TITLE
1111

1212

1313
class AiohttpRunner(BaseEvent):
@@ -53,9 +53,9 @@ def __init__(self, start_time, request, body, handler):
5353
)
5454

5555
request_headers = dict(request.headers)
56-
if request_headers.get(EPSAGON_HEADER):
56+
if request_headers.get(EPSAGON_HEADER_TITLE):
5757
self.resource['metadata']['http_trace_id'] = request_headers.get(
58-
EPSAGON_HEADER
58+
EPSAGON_HEADER_TITLE
5959
)
6060
if request_headers:
6161
add_data_if_needed(

epsagon/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from __future__ import absolute_import, print_function
66
import os
77
import collections
8+
import uuid
89
import socket
910
import sys
1011
import traceback
@@ -85,6 +86,18 @@ def update_http_headers(resource_data, response_headers):
8586
return resource_data
8687

8788

89+
def get_epsagon_http_trace_id():
90+
"""Returns an Epsagon trace ID to inject over HTTP."""
91+
trace_id = uuid.uuid4().hex
92+
span_id = uuid.uuid4().hex[16:]
93+
parent_span_id = uuid.uuid4().hex[16:]
94+
return '{trace_id}:{span_id}:{parent_span_id}:1'.format(
95+
trace_id=trace_id,
96+
span_id=span_id,
97+
parent_span_id=parent_span_id
98+
)
99+
100+
88101
def get_tc_url(use_ssl):
89102
"""
90103
Get the TraceCollector URL.

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ pytest-aiohttp; python_version >= '3.5'
1919
httpx; python_version >= '3.5'
2020
asynctest; python_version >= '3.5'
2121
moto
22+
tornado
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import epsagon.wrappers.python_function
2+
import epsagon.runners.python_function
3+
import epsagon.constants
4+
import mock
5+
from tornado.httpclient import AsyncHTTPClient
6+
7+
TEST_URL = 'https://example.test/'
8+
9+
10+
@mock.patch('epsagon.trace.TraceFactory.add_event')
11+
def test_sanity(add_event_mock):
12+
retval = 'success'
13+
14+
@epsagon.wrappers.python_function.python_wrapper
15+
def wrapped_function():
16+
http_client = AsyncHTTPClient()
17+
http_client.fetch(TEST_URL)
18+
return retval
19+
assert wrapped_function() == retval
20+
add_event_mock.assert_called()
21+
event = add_event_mock.call_args_list[0].args[0]
22+
assert event.resource['name'] == 'example.test'
23+
assert event.resource['operation'] == 'GET'
24+
assert event.resource['type'] == 'http'
25+
assert 'http_trace_id' in event.resource['metadata']

0 commit comments

Comments
 (0)