Skip to content

Commit edefb3d

Browse files
hasierhaotianw465
authored andcommitted
Recursively patch any given module functions with capture (#113)
* Add recursive function and method patching with capture * Patch with only import hooks * Remove setup patching, add envvar to patch modules on Django app ready * Prevent double patching and add startup patching segment * Add module pattern ignore * Add function attribute and check to detect whether already decorated * Add tests * Fix PY3 patching + importing and amend tests * Add ignore tests * Fix class methods, classmethods and staticmethods, and add tests * Update docs * Fix referenced inherited methods * Avoid automatically patching classmethods * Add settings variable to opt-in for import-time parent segment * Patch pg8000 tests only once per module
1 parent 3bc68cf commit edefb3d

File tree

16 files changed

+469
-19
lines changed

16 files changed

+469
-19
lines changed

CHANGELOG.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
CHANGELOG
33
=========
44

5+
unreleased
6+
==========
7+
* feature: Recursively patch any given module functions with capture
8+
59
2.2.0
610
=====
711
* feature: Added context managers on segment/subsegment capture. `PR97 <https://github.com/aws/aws-xray-sdk-python/pull/97>`_.
@@ -32,11 +36,11 @@ CHANGELOG
3236
* **Breaking**: The original sampling modules for local defined rules are moved from `models.sampling` to `models.sampling.local`.
3337
* **Breaking**: The default behavior of `patch_all` changed to selectively patches libraries to avoid double patching. You can use `patch_all(double_patch=True)` to force it to patch ALL supported libraries. See more details on `ISSUE63 <https://github.com/aws/aws-xray-sdk-python/issues/63>`_
3438
* **Breaking**: The latest `botocore` that has new X-Ray service API `GetSamplingRules` and `GetSamplingTargets` are required.
35-
* **Breaking**: Version 2.x doesn't support pynamodb and aiobotocore as it requires botocore >= 1.11.3 which isn’t currently supported by the pynamodb and aiobotocore libraries. Please continue to use version 1.x if you’re using pynamodb or aiobotocore until those haven been updated to use botocore > = 1.11.3.
39+
* **Breaking**: Version 2.x doesn't support pynamodb and aiobotocore as it requires botocore >= 1.11.3 which isn’t currently supported by the pynamodb and aiobotocore libraries. Please continue to use version 1.x if you’re using pynamodb or aiobotocore until those haven been updated to use botocore > = 1.11.3.
3640
* feature: Environment variable `AWS_XRAY_DAEMON_ADDRESS` now takes an additional notation in `tcp:127.0.0.1:2000 udp:127.0.0.2:2001` to set TCP and UDP destination separately. By default it assumes a X-Ray daemon listening to both UDP and TCP traffic on `127.0.0.1:2000`.
3741
* feature: Added MongoDB python client support. `PR65 <https://github.com/aws/aws-xray-sdk-python/pull/65>`_.
38-
* bugfix: Support binding connection in sqlalchemy as well as engine. `PR78 <https://github.com/aws/aws-xray-sdk-python/pull/78>`_.
39-
* bugfix: Flask middleware safe request teardown. `ISSUE75 <https://github.com/aws/aws-xray-sdk-python/issues/75>`_.
42+
* bugfix: Support binding connection in sqlalchemy as well as engine. `PR78 <https://github.com/aws/aws-xray-sdk-python/pull/78>`_.
43+
* bugfix: Flask middleware safe request teardown. `ISSUE75 <https://github.com/aws/aws-xray-sdk-python/issues/75>`_.
4044

4145

4246
1.1.2
@@ -68,7 +72,7 @@ CHANGELOG
6872
* bugfix: Fixed an issue where arbitrary fields in trace header being dropped when calling downstream.
6973
* bugfix: Fixed a compatibility issue between botocore and httplib patcher. `ISSUE48 <https://github.com/aws/aws-xray-sdk-python/issues/48>`_.
7074
* bugfix: Fixed a typo in sqlalchemy decorators. `PR50 <https://github.com/aws/aws-xray-sdk-python/pull/50>`_.
71-
* Updated `README` with more usage examples.
75+
* Updated `README` with more usage examples.
7276

7377
0.97
7478
====

README.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,29 @@ libs_to_patch = ('boto3', 'mysql', 'requests')
260260
patch(libs_to_patch)
261261
```
262262

263-
### Add Django middleware
263+
#### Automatic module patching
264+
265+
Full modules in the local codebase can be recursively patched by providing the module references
266+
to the patch function.
267+
```python
268+
from aws_xray_sdk.core import patch
269+
270+
libs_to_patch = ('boto3', 'requests', 'local.module.ref', 'other_module')
271+
patch(libs_to_patch)
272+
```
273+
An `xray_recorder.capture()` decorator will be applied to all functions and class methods in the
274+
given module and all the modules inside them recursively. Some files/modules can be excluded by
275+
providing to the `patch` function a regex that matches them.
276+
```python
277+
from aws_xray_sdk.core import patch
278+
279+
libs_to_patch = ('boto3', 'requests', 'local.module.ref', 'other_module')
280+
ignore = ('local.module.ref.some_file', 'other_module.some_module\.*')
281+
patch(libs_to_patch, ignore_module_patterns=ignore)
282+
```
283+
284+
### Django
285+
#### Add Django middleware
264286

265287
In django settings.py, use the following.
266288

@@ -276,6 +298,27 @@ MIDDLEWARE = [
276298
]
277299
```
278300

301+
#### Automatic patching
302+
The automatic module patching can also be configured through Django settings.
303+
```python
304+
XRAY_RECORDER = {
305+
'PATCH_MODULES': [
306+
'boto3',
307+
'requests',
308+
'local.module.ref',
309+
'other_module',
310+
],
311+
'IGNORE_MODULE_PATTERNS': [
312+
'local.module.ref.some_file',
313+
'other_module.some_module\.*',
314+
],
315+
...
316+
}
317+
```
318+
If `AUTO_PATCH_PARENT_SEGMENT_NAME` is also specified, then a segment parent will be created
319+
with the supplied name, wrapping the automatic patching so that it captures any dangling
320+
subsegments created on the import patching.
321+
279322
### Add Flask middleware
280323

281324
```python

aws_xray_sdk/core/async_recorder.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import time
22

3-
import wrapt
4-
53
from aws_xray_sdk.core.recorder import AWSXRayRecorder
64
from aws_xray_sdk.core.utils import stacktrace
7-
from aws_xray_sdk.core.models.subsegment import SubsegmentContextManager
5+
from aws_xray_sdk.core.models.subsegment import SubsegmentContextManager, is_already_recording, subsegment_decorator
86
from aws_xray_sdk.core.models.segment import SegmentContextManager
97

108

@@ -17,8 +15,13 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
1715

1816
class AsyncSubsegmentContextManager(SubsegmentContextManager):
1917

20-
@wrapt.decorator
18+
@subsegment_decorator
2119
async def __call__(self, wrapped, instance, args, kwargs):
20+
if is_already_recording(wrapped):
21+
# The wrapped function is already decorated, the subsegment will be created later,
22+
# just return the result
23+
return await wrapped(*args, **kwargs)
24+
2225
func_name = self.name
2326
if not func_name:
2427
func_name = wrapped.__name__

aws_xray_sdk/core/models/subsegment.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@
77
from ..exceptions.exceptions import SegmentNotFoundException
88

99

10+
# Attribute starts with _self_ to prevent wrapt proxying to underlying function
11+
SUBSEGMENT_RECORDING_ATTRIBUTE = '_self___SUBSEGMENT_RECORDING_ATTRIBUTE__'
12+
13+
14+
def set_as_recording(decorated_func, wrapped):
15+
# If the wrapped function has the attribute, then it has already been patched
16+
setattr(decorated_func, SUBSEGMENT_RECORDING_ATTRIBUTE, hasattr(wrapped, SUBSEGMENT_RECORDING_ATTRIBUTE))
17+
18+
19+
def is_already_recording(func):
20+
# The function might have the attribute, but its value might still be false
21+
# as it might be the first decorator
22+
return getattr(func, SUBSEGMENT_RECORDING_ATTRIBUTE, False)
23+
24+
25+
@wrapt.decorator
26+
def subsegment_decorator(wrapped, instance, args, kwargs):
27+
decorated_func = wrapt.decorator(wrapped)(*args, **kwargs)
28+
set_as_recording(decorated_func, wrapped)
29+
return decorated_func
30+
31+
1032
class SubsegmentContextManager:
1133
"""
1234
Wrapper for segment and recorder to provide segment context manager.
@@ -18,8 +40,13 @@ def __init__(self, recorder, name=None, **subsegment_kwargs):
1840
self.recorder = recorder
1941
self.subsegment = None
2042

21-
@wrapt.decorator
43+
@subsegment_decorator
2244
def __call__(self, wrapped, instance, args, kwargs):
45+
if is_already_recording(wrapped):
46+
# The wrapped function is already decorated, the subsegment will be created later,
47+
# just return the result
48+
return wrapped(*args, **kwargs)
49+
2350
func_name = self.name
2451
if not func_name:
2552
func_name = wrapped.__name__

aws_xray_sdk/core/patcher.py

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
import logging
21
import importlib
2+
import inspect
3+
import logging
4+
import os
5+
import pkgutil
6+
import re
7+
import sys
8+
import wrapt
9+
10+
from .utils.compat import PY2, is_classmethod, is_instance_method
311

412
log = logging.getLogger(__name__)
513

@@ -36,7 +44,22 @@ def patch_all(double_patch=False):
3644
patch(NO_DOUBLE_PATCH, raise_errors=False)
3745

3846

39-
def patch(modules_to_patch, raise_errors=True):
47+
def _is_valid_import(module):
48+
module = module.replace('.', '/')
49+
if PY2:
50+
return bool(pkgutil.get_loader(module))
51+
else:
52+
realpath = os.path.realpath(module)
53+
is_module = os.path.isdir(realpath) and (
54+
os.path.isfile('{}/__init__.py'.format(module)) or os.path.isfile('{}/__init__.pyc'.format(module))
55+
)
56+
is_file = not is_module and (
57+
os.path.isfile('{}.py'.format(module)) or os.path.isfile('{}.pyc'.format(module))
58+
)
59+
return is_module or is_file
60+
61+
62+
def patch(modules_to_patch, raise_errors=True, ignore_module_patterns=None):
4063
modules = set()
4164
for module_to_patch in modules_to_patch:
4265
# boto3 depends on botocore and patching botocore is sufficient
@@ -51,14 +74,24 @@ def patch(modules_to_patch, raise_errors=True):
5174
modules.add(module_to_patch)
5275
else:
5376
modules.add(module_to_patch)
54-
unsupported_modules = modules - set(SUPPORTED_MODULES)
77+
78+
unsupported_modules = set(module for module in modules if module not in SUPPORTED_MODULES)
79+
native_modules = modules - unsupported_modules
80+
81+
external_modules = set(module for module in unsupported_modules if _is_valid_import(module))
82+
unsupported_modules = unsupported_modules - external_modules
83+
5584
if unsupported_modules:
5685
raise Exception('modules %s are currently not supported for patching'
5786
% ', '.join(unsupported_modules))
5887

59-
for m in modules:
88+
for m in native_modules:
6089
_patch_module(m, raise_errors)
6190

91+
ignore_module_patterns = [re.compile(pattern) for pattern in ignore_module_patterns or []]
92+
for m in external_modules:
93+
_external_module_patch(m, ignore_module_patterns)
94+
6295

6396
def _patch_module(module_to_patch, raise_errors=True):
6497
try:
@@ -82,3 +115,93 @@ def _patch(module_to_patch):
82115

83116
_PATCHED_MODULES.add(module_to_patch)
84117
log.info('successfully patched module %s', module_to_patch)
118+
119+
120+
def _patch_func(parent, func_name, func, modifier=lambda x: x):
121+
if func_name not in parent.__dict__:
122+
# Ignore functions not directly defined in parent, i.e. exclude inherited ones
123+
return
124+
125+
from aws_xray_sdk.core import xray_recorder
126+
127+
capture_name = func_name
128+
if func_name.startswith('__') and func_name.endswith('__'):
129+
capture_name = '{}.{}'.format(parent.__name__, capture_name)
130+
setattr(parent, func_name, modifier(xray_recorder.capture(name=capture_name)(func)))
131+
132+
133+
def _patch_class(module, cls):
134+
for member_name, member in inspect.getmembers(cls, inspect.isclass):
135+
if member.__module__ == module.__name__:
136+
# Only patch classes of the module, ignore imports
137+
_patch_class(module, member)
138+
139+
for member_name, member in inspect.getmembers(cls, inspect.ismethod):
140+
if member.__module__ == module.__name__:
141+
# Only patch methods of the class defined in the module, ignore other modules
142+
if is_classmethod(member):
143+
# classmethods are internally generated through descriptors. The classmethod
144+
# decorator must be the last applied, so we cannot apply another one on top
145+
log.warning('Cannot automatically patch classmethod %s.%s, '
146+
'please apply decorator manually', cls.__name__, member_name)
147+
else:
148+
_patch_func(cls, member_name, member)
149+
150+
for member_name, member in inspect.getmembers(cls, inspect.isfunction):
151+
if member.__module__ == module.__name__:
152+
# Only patch static methods of the class defined in the module, ignore other modules
153+
if is_instance_method(cls, member_name, member):
154+
_patch_func(cls, member_name, member)
155+
else:
156+
_patch_func(cls, member_name, member, modifier=staticmethod)
157+
158+
159+
def _on_import(module):
160+
for member_name, member in inspect.getmembers(module, inspect.isfunction):
161+
if member.__module__ == module.__name__:
162+
# Only patch functions of the module, ignore imports
163+
_patch_func(module, member_name, member)
164+
165+
for member_name, member in inspect.getmembers(module, inspect.isclass):
166+
if member.__module__ == module.__name__:
167+
# Only patch classes of the module, ignore imports
168+
_patch_class(module, member)
169+
170+
171+
def _external_module_patch(module, ignore_module_patterns):
172+
if module.startswith('.'):
173+
raise Exception('relative packages not supported for patching: {}'.format(module))
174+
175+
if module in _PATCHED_MODULES:
176+
log.debug('%s already patched', module)
177+
elif any(pattern.match(module) for pattern in ignore_module_patterns):
178+
log.debug('%s ignored due to rules: %s', module, ignore_module_patterns)
179+
else:
180+
if module in sys.modules:
181+
_on_import(sys.modules[module])
182+
else:
183+
wrapt.importer.when_imported(module)(_on_import)
184+
185+
for loader, submodule_name, is_module in pkgutil.iter_modules([module.replace('.', '/')]):
186+
submodule = '.'.join([module, submodule_name])
187+
if is_module:
188+
_external_module_patch(submodule, ignore_module_patterns)
189+
else:
190+
if submodule in _PATCHED_MODULES:
191+
log.debug('%s already patched', submodule)
192+
continue
193+
elif any(pattern.match(submodule) for pattern in ignore_module_patterns):
194+
log.debug('%s ignored due to rules: %s', submodule, ignore_module_patterns)
195+
continue
196+
197+
if submodule in sys.modules:
198+
_on_import(sys.modules[submodule])
199+
else:
200+
wrapt.importer.when_imported(submodule)(_on_import)
201+
202+
_PATCHED_MODULES.add(submodule)
203+
log.info('successfully patched module %s', submodule)
204+
205+
if module not in _PATCHED_MODULES:
206+
_PATCHED_MODULES.add(module)
207+
log.info('successfully patched module %s', module)

aws_xray_sdk/core/recorder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def in_subsegment(self, name=None, **subsegment_kwargs):
190190
Return a subsegment context manger.
191191
192192
:param str name: the name of the subsegment
193-
:param dict segment_kwargs: remaining arguments passed directly to `begin_subsegment`
193+
:param dict subsegment_kwargs: remaining arguments passed directly to `begin_subsegment`
194194
"""
195195
return SubsegmentContextManager(self, name=name, **subsegment_kwargs)
196196

aws_xray_sdk/core/utils/compat.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import inspect
12
import sys
23

34

@@ -10,3 +11,21 @@
1011
else:
1112
annotation_value_types = (int, float, bool, str)
1213
string_types = str
14+
15+
16+
def is_classmethod(func):
17+
return getattr(func, '__self__', None) is not None
18+
19+
20+
def is_instance_method(parent_class, func_name, func):
21+
try:
22+
func_from_dict = parent_class.__dict__[func_name]
23+
except KeyError:
24+
for base in inspect.getmro(parent_class):
25+
if func_name in base.__dict__:
26+
func_from_dict = base.__dict__[func_name]
27+
break
28+
else:
29+
return True
30+
31+
return not is_classmethod(func) and not isinstance(func_from_dict, staticmethod)

aws_xray_sdk/ext/django/apps.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .conf import settings
66
from .db import patch_db
77
from .templates import patch_template
8-
from aws_xray_sdk.core import xray_recorder
8+
from aws_xray_sdk.core import patch, xray_recorder
99
from aws_xray_sdk.core.exceptions.exceptions import SegmentNameMissingException
1010

1111

@@ -38,6 +38,13 @@ def ready(self):
3838
max_trace_back=settings.MAX_TRACE_BACK,
3939
)
4040

41+
if settings.PATCH_MODULES:
42+
if settings.AUTO_PATCH_PARENT_SEGMENT_NAME is not None:
43+
with xray_recorder.in_segment(settings.AUTO_PATCH_PARENT_SEGMENT_NAME):
44+
patch(settings.PATCH_MODULES, ignore_module_patterns=settings.IGNORE_MODULE_PATTERNS)
45+
else:
46+
patch(settings.PATCH_MODULES, ignore_module_patterns=settings.IGNORE_MODULE_PATTERNS)
47+
4148
# if turned on subsegment will be generated on
4249
# built-in database and template rendering
4350
if settings.AUTO_INSTRUMENT:

aws_xray_sdk/ext/django/conf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
'DYNAMIC_NAMING': None,
1515
'STREAMING_THRESHOLD': None,
1616
'MAX_TRACE_BACK': None,
17+
'PATCH_MODULES': [],
18+
'AUTO_PATCH_PARENT_SEGMENT_NAME': None,
19+
'IGNORE_MODULE_PATTERNS': [],
1720
}
1821

1922
XRAY_NAMESPACE = 'XRAY_RECORDER'

0 commit comments

Comments
 (0)