Skip to content

Support patching PynamoDB calls to DynamoDB #13

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 19, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 21 additions & 13 deletions aws_xray_sdk/core/patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
SUPPORTED_MODULES = (
'aiobotocore',
'botocore',
'pynamodb',
'requests',
'sqlite3',
'mysql',
Expand All @@ -19,23 +20,30 @@ def patch_all():


def patch(modules_to_patch, raise_errors=True):
for m in modules_to_patch:
modules = set()
for module_to_patch in modules_to_patch:
# boto3 depends on botocore and patching botocore is sufficient
if module_to_patch == 'boto3':
modules.add('botocore')
# aioboto3 depends on aiobotocore and patching aiobotocore is sufficient
elif module_to_patch == 'aioboto3':
modules.add('aiobotocore')
# pynamodb requires botocore to be patched as well
elif module_to_patch == 'pynamodb':
modules.add('botocore')
modules.add(module_to_patch)
else:
modules.add(module_to_patch)
unsupported_modules = modules - set(SUPPORTED_MODULES)
if unsupported_modules:
raise Exception('modules %s are currently not supported for patching'
% ', '.join(unsupported_modules))

for m in modules:
_patch_module(m, raise_errors)


def _patch_module(module_to_patch, raise_errors=True):
# boto3 depends on botocore and patching botocore is sufficient
if module_to_patch == 'boto3':
module_to_patch = 'botocore'

# aioboto3 depends on aiobotocore and patching aiobotocore is sufficient
if module_to_patch == 'aioboto3':
module_to_patch = 'aiobotocore'

if module_to_patch not in SUPPORTED_MODULES:
raise Exception('module %s is currently not supported for patching'
% module_to_patch)

try:
_patch(module_to_patch)
except Exception:
Expand Down
3 changes: 3 additions & 0 deletions aws_xray_sdk/ext/pynamodb/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .patch import patch

__all__ = ['patch']
63 changes: 63 additions & 0 deletions aws_xray_sdk/ext/pynamodb/patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import botocore.vendored.requests.sessions
import json
import wrapt

from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.core.models import http
from aws_xray_sdk.ext.boto_utils import _extract_whitelisted_params


def patch():
"""Patch PynamoDB so it generates subsegements when calling DynamoDB."""
if hasattr(botocore.vendored.requests.sessions, '_xray_enabled'):
return
setattr(botocore.vendored.requests.sessions, '_xray_enabled', True)

wrapt.wrap_function_wrapper(
'botocore.vendored.requests.sessions',
'Session.send',
_xray_traced_pynamodb,
)


def _xray_traced_pynamodb(wrapped, instance, args, kwargs):

# Check if it's a request to DynamoDB and return otherwise.
try:
service = args[0].headers['X-Amz-Target'].decode('utf-8').split('_')[0]
except KeyError:
return wrapped(*args, **kwargs)
if service.lower() != 'dynamodb':
return wrapped(*args, **kwargs)

return xray_recorder.record_subsegment(
wrapped, instance, args, kwargs,
name='dynamodb',
namespace='aws',
meta_processor=pynamodb_meta_processor,
)


def pynamodb_meta_processor(wrapped, instance, args, kwargs, return_value,
exception, subsegment, stack):
operation_name = args[0].headers['X-Amz-Target'].decode('utf-8').split('.')[1]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to capture request_id here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing out. Turned out I searched for it in the request instead of the response. Added it now.

region = args[0].url.split('.')[1]
request_id = return_value.headers.get('x-amzn-RequestId')

aws_meta = {
'operation': operation_name,
'request_id': request_id,
'region': region
}

if exception:
subsegment.add_error_flag()
subsegment.add_exception(exception, stack, True)

subsegment.put_http_meta(http.STATUS, return_value.status_code)

_extract_whitelisted_params(subsegment.name, operation_name,
aws_meta, [None, json.loads(args[0].body)],
None, return_value.json())

subsegment.set_aws(aws_meta)
22 changes: 22 additions & 0 deletions docs/aws_xray_sdk.ext.pynamodb.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
aws\_xray\_sdk\.ext\.pynamodb package
=====================================

Submodules
----------

aws\_xray\_sdk\.ext\.pynamodb\.patch module
-------------------------------------------

.. automodule:: aws_xray_sdk.ext.pynamodb.patch
:members:
:undoc-members:
:show-inheritance:


Module contents
---------------

.. automodule:: aws_xray_sdk.ext.pynamodb
:members:
:undoc-members:
:show-inheritance:
3 changes: 2 additions & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ Currently supported web frameworks and libraries:
* flask
* boto3
* botocore
* pynamodb
* requests
* sqlite3
* sqlite3
* mysql-connector

You must have the X-Ray daemon running to use the SDK.
Expand Down
13 changes: 9 additions & 4 deletions docs/thirdparty.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ Third Party Library Support
Patching Supported Libraries
----------------------------

The SDK supports aioboto3, aiobotocore, boto3, botocore, requests, sqlite3 and mysql-connector.
The SDK supports aioboto3, aiobotocore, boto3, botocore, pynamodb, requests, sqlite3 and
mysql-connector.

To patch, use code like the following in the main app::

from aws_xray_sdk.core import patch_all

patch_all()
Expand All @@ -30,12 +31,16 @@ The following modules are availble to patch::
'aiobotocore',
'boto3',
'botocore',
'pynamodb',
'requests',
'sqlite3',
'mysql',
)

Patching boto3 and botocore are equivalent since boto3 depends on botocore
Patching boto3 and botocore are equivalent since boto3 depends on botocore.

Patching pynamodb applies the botocore patch as well, as it uses the logic from the botocore
patch to apply the trace header.

Patching mysql
----------------------------
Expand Down Expand Up @@ -67,4 +72,4 @@ up the X-Ray SDK with an Async Context, bear in mind this requires Python 3.5+::
xray_recorder.configure(service='service_name', context=AsyncContext())

See :ref:`Configure Global Recorder <configurations>` for more information about
configuring the ``xray_recorder``.
configuring the ``xray_recorder``.
Empty file.
74 changes: 74 additions & 0 deletions tests/ext/pynamodb/test_pynamodb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import botocore.session
import pytest
from botocore.exceptions import ClientError
from pynamodb.attributes import UnicodeAttribute
from pynamodb.exceptions import VerboseClientError
from pynamodb.models import Model

from aws_xray_sdk.core import patch
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.core.context import Context

patch(('pynamodb',))


@pytest.fixture(autouse=True)
def construct_ctx():
"""
Clean up context storage on each test run and begin a segment
so that later subsegment can be attached. After each test run
it cleans up context storage again.
"""
xray_recorder.configure(service='test', sampling=False, context=Context())
xray_recorder.clear_trace_entities()
xray_recorder.begin_segment('name')
yield
xray_recorder.clear_trace_entities()


def test_exception():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test to guard the behavior where vendored requests only emit subsegments when talking to DynamoDB? In other words after patching pynamodb other AWS services are still captured only through original patched botocore code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it pretty difficult to write proper tests for the whole stuff, thanks to all the patching. I added a test now to check that only a single subsegment gets created when the pynamodb patch is enabled, but another AWS service is called. That single subsegment gets created, because the botocore patch is active in this case too.

class SampleModel(Model):
class Meta:
region = 'us-west-2'
table_name = 'mytable'

sample_attribute = UnicodeAttribute(hash_key=True)

try:
SampleModel.describe_table()
except VerboseClientError:
pass

subsegments = xray_recorder.current_segment().subsegments
assert len(subsegments) == 1
subsegment = subsegments[0]
assert subsegment.name == 'dynamodb'
assert len(subsegment.subsegments) == 0
assert subsegment.error

aws_meta = subsegment.aws
assert aws_meta['region'] == 'us-west-2'
assert aws_meta['operation'] == 'DescribeTable'
assert aws_meta['table_name'] == 'mytable'


def test_only_dynamodb_calls_are_traced():
"""Test only a single subsegment is created for other AWS services.

As the pynamodb patch applies the botocore patch as well, we need
to ensure that only one subsegment is created for all calls not
made by PynamoDB. As PynamoDB calls botocore differently than the
botocore patch expects we also just get a single subsegment per
PynamoDB call.
"""
session = botocore.session.get_session()
s3 = session.create_client('s3', region_name='us-west-2')
try:
s3.get_bucket_location(Bucket='mybucket')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're making external http calls anyway on the dynamo test case so I would suggest to not cause a Client error here. Exception raised from remote endpoint makes sure it reach further down to the botocore code path.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm afraid I don't get your point. I am doing an external call in this test as well and as the remote end returns an error (because I'm not authorized to access that bucket) I get an error back. I have to catch this error to be able to properly do the assertions afterwards. Am I missing something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. I got confused by ClientError. I thought it was raised by library due to invalid parameters. In that case there is not any http related code executed.

except ClientError:
pass

subsegments = xray_recorder.current_segment().subsegments
assert len(subsegments) == 1
assert subsegments[0].name == 's3'
assert len(subsegments[0].subsegments) == 0
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ deps =
flask >= 0.10
# the sdk doesn't support earlier version of django
django >= 1.10, <2.0
pynamodb
# Python3.5+ only deps
py{35,36}: aiohttp >= 2.3.0
py{35,36}: pytest-aiohttp
Expand Down