Skip to content

Commit d110386

Browse files
therealryanbonhamhaotianw465
authored andcommitted
Add Tracing for SQLAlchemy and Flask-SQLAlcemy (#14)
* Initial checkin of Query and BaseQuery overrides * Fix ext name * Fix import * Add support for SQLAlchemy.orm and Flask-SQLAlchemy * Remove print() statement * Attempt to fix handling of Flask not having a request with a xray segment * Fix handling of missing segment * Fix test and add docstrings * Fix bug with End segment * Code Review Cleanup. Files now all pass flake8 tests * Move find_subsegment and _search_entity functions to tests/util.py * Uset set_sql to corectly test the sanitized_query value. Add test to sqlalcemy to test filter() and verify params not present in sanitized_query * Comment out set_sql for sanitized_query for seperate code review * Starting to add in set_sql * Add more SQL info to trace * Correct URL handling for connection strings * Bug fix and remove sanitized_query * Fix unit test and add helper util for finding subsegment by annotation key/value * Minor cleanups
1 parent 0b00e4b commit d110386

File tree

14 files changed

+406
-3
lines changed

14 files changed

+406
-3
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,41 @@ app.router.add_get("/", handler)
176176
web.run_app(app)
177177
```
178178

179+
**Use SQLAlchemy ORM**
180+
The SQLAlchemy integration requires you to override the Session and Query Classes for SQL Alchemy
181+
182+
SQLAlchemy integration uses subsegments so you need to have a segment started before you make a query.
183+
184+
```python
185+
from aws_xray_sdk.core import xray_recorder
186+
from aws_xray_sdk.ext.sqlalchemy.query import XRaySessionMaker
187+
188+
xray_recorder.begin_segment('SQLAlchemyTest')
189+
190+
Session = XRaySessionMaker(bind=engine)
191+
session = Session()
192+
193+
xray_recorder.end_segment()
194+
app = Flask(__name__)
195+
196+
xray_recorder.configure(service='fallback_name', dynamic_naming='*mysite.com*')
197+
XRayMiddleware(app, xray_recorder)
198+
```
199+
200+
**Add Flask-SQLAlchemy**
201+
202+
```python
203+
from aws_xray_sdk.core import xray_recorder
204+
from aws_xray_sdk.ext.flask.middleware import XRayMiddleware
205+
from aws_xray_sdk.ext.flask_sqlalchemy.query import XRayFlaskSqlAlchemy
206+
207+
app = Flask(__name__)
208+
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
209+
210+
XRayMiddleware(app, xray_recorder)
211+
db = XRayFlaskSqlAlchemy(app)
212+
213+
```
179214
## License
180215

181216
The AWS X-Ray SDK for Python is licensed under the Apache 2.0 License. See LICENSE and NOTICE.txt for more information.

aws_xray_sdk/ext/flask_sqlalchemy/__init__.py

Whitespace-only changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from builtins import super
2+
from flask_sqlalchemy.model import Model
3+
from sqlalchemy.orm.session import sessionmaker
4+
from flask_sqlalchemy import SQLAlchemy, BaseQuery, _SessionSignalEvents, get_state
5+
from aws_xray_sdk.ext.sqlalchemy.query import XRaySession, XRayQuery
6+
from aws_xray_sdk.ext.sqlalchemy.util.decerators import xray_on_call, decorate_all_functions
7+
8+
9+
@decorate_all_functions(xray_on_call)
10+
class XRayBaseQuery(BaseQuery):
11+
BaseQuery.__bases__ = (XRayQuery,)
12+
13+
14+
class XRaySignallingSession(XRaySession):
15+
"""The signalling session is the default session that Flask-SQLAlchemy
16+
uses. It extends the default session system with bind selection and
17+
modification tracking.
18+
If you want to use a different session you can override the
19+
:meth:`SQLAlchemy.create_session` function.
20+
.. versionadded:: 2.0
21+
.. versionadded:: 2.1
22+
The `binds` option was added, which allows a session to be joined
23+
to an external transaction.
24+
"""
25+
26+
def __init__(self, db, autocommit=False, autoflush=True, **options):
27+
#: The application that this session belongs to.
28+
self.app = app = db.get_app()
29+
track_modifications = app.config['SQLALCHEMY_TRACK_MODIFICATIONS']
30+
bind = options.pop('bind', None) or db.engine
31+
binds = options.pop('binds', db.get_binds(app))
32+
33+
if track_modifications is None or track_modifications:
34+
_SessionSignalEvents.register(self)
35+
36+
XRaySession.__init__(
37+
self, autocommit=autocommit, autoflush=autoflush,
38+
bind=bind, binds=binds, **options
39+
)
40+
41+
def get_bind(self, mapper=None, clause=None):
42+
# mapper is None if someone tries to just get a connection
43+
if mapper is not None:
44+
info = getattr(mapper.mapped_table, 'info', {})
45+
bind_key = info.get('bind_key')
46+
if bind_key is not None:
47+
state = get_state(self.app)
48+
return state.db.get_engine(self.app, bind=bind_key)
49+
return XRaySession.get_bind(self, mapper, clause)
50+
51+
52+
class XRayFlaskSqlAlchemy(SQLAlchemy):
53+
def __init__(self, app=None, use_native_unicode=True, session_options=None,
54+
metadata=None, query_class=XRayBaseQuery, model_class=Model):
55+
super().__init__(app, use_native_unicode, session_options,
56+
metadata, query_class, model_class)
57+
58+
def create_session(self, options):
59+
return sessionmaker(class_=XRaySignallingSession, db=self, **options)

aws_xray_sdk/ext/sqlalchemy/__init__.py

Whitespace-only changes.

aws_xray_sdk/ext/sqlalchemy/query.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from builtins import super
2+
from sqlalchemy.orm.query import Query
3+
from sqlalchemy.orm.session import Session, sessionmaker
4+
from .util.decerators import xray_on_call, decorate_all_functions
5+
6+
7+
@decorate_all_functions(xray_on_call)
8+
class XRaySession(Session):
9+
pass
10+
11+
12+
@decorate_all_functions(xray_on_call)
13+
class XRayQuery(Query):
14+
pass
15+
16+
17+
@decorate_all_functions(xray_on_call)
18+
class XRaySessionMaker(sessionmaker):
19+
def __init__(self, bind=None, class_=XRaySession, autoflush=True,
20+
autocommit=False,
21+
expire_on_commit=True,
22+
info=None, **kw):
23+
kw['query_cls'] = XRayQuery
24+
super().__init__(bind, class_, autoflush, autocommit, expire_on_commit,
25+
info, **kw)

aws_xray_sdk/ext/sqlalchemy/util/__init__.py

Whitespace-only changes.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import re
2+
from aws_xray_sdk.core import xray_recorder
3+
from future.standard_library import install_aliases
4+
install_aliases()
5+
from urllib.parse import urlparse, uses_netloc
6+
7+
8+
9+
def decorate_all_functions(function_decorator):
10+
def decorator(cls):
11+
for c in cls.__bases__:
12+
for name, obj in vars(c).items():
13+
if name.startswith("_"):
14+
continue
15+
if callable(obj):
16+
try:
17+
obj = obj.__func__ # unwrap Python 2 unbound method
18+
except AttributeError:
19+
pass # not needed in Python 3
20+
setattr(c, name, function_decorator(c, obj))
21+
return cls
22+
return decorator
23+
24+
def xray_on_call(cls, func):
25+
def wrapper(*args, **kw):
26+
from ..query import XRayQuery, XRaySession
27+
from ...flask_sqlalchemy.query import XRaySignallingSession
28+
class_name = str(cls.__module__)
29+
c = xray_recorder._context
30+
sql = None
31+
subsegment = None
32+
if class_name == "sqlalchemy.orm.session":
33+
for arg in args:
34+
if isinstance(arg, XRaySession):
35+
sql = parse_bind(arg.bind)
36+
if isinstance(arg, XRaySignallingSession):
37+
sql = parse_bind(arg.bind)
38+
if class_name == 'sqlalchemy.orm.query':
39+
for arg in args:
40+
if isinstance(arg, XRayQuery):
41+
try:
42+
sql = parse_bind(arg.session.bind)
43+
# Commented our for later PR
44+
# sql['sanitized_query'] = str(arg)
45+
except:
46+
sql = None
47+
if sql is not None:
48+
if getattr(c._local, 'entities', None) is not None:
49+
subsegment = xray_recorder.begin_subsegment(sql['url'], namespace='remote')
50+
else:
51+
subsegment = None
52+
res = func(*args, **kw)
53+
if subsegment is not None:
54+
subsegment.set_sql(sql)
55+
subsegment.put_annotation("sqlalchemy", class_name+'.'+func.__name__ );
56+
xray_recorder.end_subsegment()
57+
return res
58+
return wrapper
59+
# URL Parse output
60+
# scheme 0 URL scheme specifier scheme parameter
61+
# netloc 1 Network location part empty string
62+
# path 2 Hierarchical path empty string
63+
# query 3 Query component empty string
64+
# fragment 4 Fragment identifier empty string
65+
# username User name None
66+
# password Password None
67+
# hostname Host name (lower case) None
68+
# port Port number as integer, if present None
69+
#
70+
# XRAY Trace SQL metaData Sample
71+
# "sql" : {
72+
# "url": "jdbc:postgresql://aawijb5u25wdoy.cpamxznpdoq8.us-west-2.rds.amazonaws.com:5432/ebdb",
73+
# "preparation": "statement",
74+
# "database_type": "PostgreSQL",
75+
# "database_version": "9.5.4",
76+
# "driver_version": "PostgreSQL 9.4.1211.jre7",
77+
# "user" : "dbuser",
78+
# "sanitized_query" : "SELECT * FROM customers WHERE customer_id=?;"
79+
# }
80+
def parse_bind(bind):
81+
"""Parses a connection string and creates SQL trace metadata"""
82+
m = re.match(r"Engine\((.*?)\)", str(bind))
83+
if m is not None:
84+
u = urlparse(m.group(1))
85+
# Add Scheme to uses_netloc or // will be missing from url.
86+
uses_netloc.append(u.scheme)
87+
safe_url = ""
88+
if u.password is None:
89+
safe_url = u.geturl()
90+
else:
91+
# Strip password from URL
92+
host_info = u.netloc.rpartition('@')[-1]
93+
parts = u._replace(netloc='{}@{}'.format(u.username, host_info))
94+
safe_url = u.geturl()
95+
sql = {}
96+
sql['database_type'] = u.scheme
97+
sql['url'] = safe_url
98+
if u.username is not None:
99+
sql['user'] = "{}".format(u.username)
100+
return sql

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
'Programming Language :: Python :: 3.6',
3636
],
3737

38-
install_requires=['jsonpickle', 'wrapt', 'requests'],
38+
install_requires=['jsonpickle', 'wrapt', 'requests', 'future'],
3939

4040
keywords='aws xray sdk',
4141

tests/ext/flask_sqlalchemy/__init__.py

Whitespace-only changes.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import absolute_import
2+
import pytest
3+
from aws_xray_sdk.core import xray_recorder
4+
from aws_xray_sdk.core.context import Context
5+
from aws_xray_sdk.ext.flask_sqlalchemy.query import XRayFlaskSqlAlchemy
6+
from flask import Flask
7+
from ...util import find_subsegment_by_annotation
8+
9+
10+
app = Flask(__name__)
11+
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
12+
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
13+
db = XRayFlaskSqlAlchemy(app)
14+
15+
16+
class User(db.Model):
17+
__tablename__ = "users"
18+
19+
id = db.Column(db.Integer, primary_key=True)
20+
name = db.Column(db.String(255), nullable=False, unique=True)
21+
fullname = db.Column(db.String(255), nullable=False)
22+
password = db.Column(db.String(255), nullable=False)
23+
24+
25+
@pytest.fixture()
26+
def session():
27+
"""Test Fixture to Create DataBase Tables and start a trace segment"""
28+
xray_recorder.configure(service='test', sampling=False, context=Context())
29+
xray_recorder.clear_trace_entities()
30+
xray_recorder.begin_segment('SQLAlchemyTest')
31+
db.create_all()
32+
yield
33+
xray_recorder.end_segment()
34+
xray_recorder.clear_trace_entities()
35+
36+
37+
def test_all(capsys, session):
38+
""" Test calling all() on get all records.
39+
Verify that we capture trace of query and return the SQL as metdata"""
40+
# with capsys.disabled():
41+
User.query.all()
42+
subsegment = find_subsegment_by_annotation(xray_recorder.current_segment(), 'sqlalchemy', 'sqlalchemy.orm.query.all')
43+
assert subsegment['annotations']['sqlalchemy'] == 'sqlalchemy.orm.query.all'
44+
# assert subsegment['sql']['sanitized_query']
45+
assert subsegment['sql']['url']
46+
47+
48+
def test_add(capsys, session):
49+
""" Test calling add() on insert a row.
50+
Verify we that we capture trace for the add"""
51+
# with capsys.disabled():
52+
john = User(name='John', fullname="John Doe", password="password")
53+
db.session.add(john)
54+
subsegment = find_subsegment_by_annotation(xray_recorder.current_segment(), 'sqlalchemy', 'sqlalchemy.orm.session.add')
55+
assert subsegment['annotations']['sqlalchemy'] == 'sqlalchemy.orm.session.add'
56+
assert subsegment['sql']['url']

tests/ext/sqlalchemy/__init__.py

Whitespace-only changes.

tests/ext/sqlalchemy/test_query.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import absolute_import
2+
import pytest
3+
from aws_xray_sdk.core import xray_recorder
4+
from aws_xray_sdk.core.context import Context
5+
from aws_xray_sdk.ext.sqlalchemy.query import XRaySessionMaker
6+
from sqlalchemy.ext.declarative import declarative_base
7+
from sqlalchemy import create_engine, Column, Integer, String
8+
from ...util import find_subsegment_by_annotation
9+
10+
11+
Base = declarative_base()
12+
13+
14+
class User(Base):
15+
__tablename__ = 'users'
16+
17+
id = Column(Integer, primary_key=True)
18+
name = Column(String)
19+
fullname = Column(String)
20+
password = Column(String)
21+
22+
23+
@pytest.fixture()
24+
def session():
25+
"""Test Fixture to Create DataBase Tables and start a trace segment"""
26+
engine = create_engine('sqlite:///:memory:')
27+
xray_recorder.configure(service='test', sampling=False, context=Context())
28+
xray_recorder.clear_trace_entities()
29+
xray_recorder.begin_segment('SQLAlchemyTest')
30+
Session = XRaySessionMaker(bind=engine)
31+
Base.metadata.create_all(engine)
32+
session = Session()
33+
yield session
34+
xray_recorder.end_segment()
35+
xray_recorder.clear_trace_entities()
36+
37+
38+
def test_all(capsys, session):
39+
""" Test calling all() on get all records.
40+
Verify we run the query and return the SQL as metdata"""
41+
# with capsys.disabled():
42+
session.query(User).all()
43+
subsegment = find_subsegment_by_annotation(xray_recorder.current_segment(), 'sqlalchemy', 'sqlalchemy.orm.query.all')
44+
assert subsegment['annotations']['sqlalchemy'] == 'sqlalchemy.orm.query.all'
45+
# assert subsegment['sql']['sanitized_query']
46+
assert subsegment['sql']['url']
47+
48+
49+
def test_add(capsys, session):
50+
""" Test calling add() on insert a row.
51+
Verify we that we capture trace for the add"""
52+
# with capsys.disabled():
53+
john = User(name='John', fullname="John Doe", password="password")
54+
session.add(john)
55+
subsegment = find_subsegment_by_annotation(xray_recorder.current_segment(), 'sqlalchemy', 'sqlalchemy.orm.session.add')
56+
assert subsegment['annotations']['sqlalchemy'] == 'sqlalchemy.orm.session.add'
57+
assert subsegment['sql']['url']
58+
59+
60+
def test_filter(capsys, session):
61+
""" Test calling all() on get all records.
62+
Verify we run the query and return the SQL as metdata"""
63+
# with capsys.disabled():
64+
session.query(User).filter(User.password=="mypassword!")
65+
subsegment = find_subsegment_by_annotation(xray_recorder.current_segment(), 'sqlalchemy', 'sqlalchemy.orm.query.filter')
66+
assert subsegment['annotations']['sqlalchemy'] == 'sqlalchemy.orm.query.filter'
67+
# assert subsegment['sql']['sanitized_query']
68+
# assert "mypassword!" not in subsegment['sql']['sanitized_query']
69+
assert subsegment['sql']['url']

0 commit comments

Comments
 (0)