Skip to content

Commit b0ff649

Browse files
RD-3763 - sqlalchemy (#159)
* support sqlalchemy
1 parent 98e7170 commit b0ff649

File tree

6 files changed

+186
-0
lines changed

6 files changed

+186
-0
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ attrs==19.1.0
88
requests==2.24.0
99
pymongo==3.11.0
1010
redispy==3.0.0
11+
sqlalchemy==1.3.20

src/lumigo_tracer/wrappers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .http.sync_http_wrappers import wrap_http_calls
22
from .pymongo.pymongo_wrapper import wrap_pymongo
33
from .redis.redis_wrapper import wrap_redis
4+
from .sql.sqlalchemy_wrapper import wrap_sqlalchemy
45

56

67
already_wrapped = False
@@ -14,4 +15,5 @@ def wrap(force: bool = False):
1415
if force or not already_wrapped:
1516
wrap_pymongo()
1617
wrap_redis()
18+
wrap_sqlalchemy()
1719
already_wrapped = True

src/lumigo_tracer/wrappers/sql/__init__.py

Whitespace-only changes.
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import importlib
2+
import uuid
3+
4+
from lumigo_tracer.libs.wrapt import wrap_function_wrapper
5+
from lumigo_tracer.lumigo_utils import (
6+
lumigo_safe_execute,
7+
get_logger,
8+
lumigo_dumps,
9+
get_current_ms_time,
10+
)
11+
from lumigo_tracer.spans_container import SpansContainer
12+
13+
try:
14+
from sqlalchemy.event import listen
15+
except Exception:
16+
listen = None
17+
18+
19+
SQL_SPAN = "mySql"
20+
21+
22+
def _before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
23+
with lumigo_safe_execute("handle sqlalchemy before execute"):
24+
SpansContainer.get_span().add_span(
25+
{
26+
"id": str(uuid.uuid4()),
27+
"type": SQL_SPAN,
28+
"started": get_current_ms_time(),
29+
"connectionParameters": {
30+
"host": conn.engine.url.host or conn.engine.url.database,
31+
"port": conn.engine.url.port,
32+
"database": conn.engine.url.database,
33+
"user": conn.engine.url.username,
34+
},
35+
"query": lumigo_dumps(statement),
36+
"values": lumigo_dumps(parameters),
37+
}
38+
)
39+
40+
41+
def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
42+
with lumigo_safe_execute("handle sqlalchemy after execute"):
43+
span = SpansContainer.get_span().get_last_span()
44+
if not span:
45+
get_logger().warning("Redis span ended without a record on its start")
46+
return
47+
span.update({"ended": get_current_ms_time(), "response": ""})
48+
49+
50+
def _handle_error(context):
51+
with lumigo_safe_execute("handle sqlalchemy error"):
52+
span = SpansContainer.get_span().get_last_span()
53+
if not span:
54+
get_logger().warning("Redis span ended without a record on its start")
55+
return
56+
span.update(
57+
{
58+
"ended": get_current_ms_time(),
59+
"error": lumigo_dumps(
60+
{
61+
"type": context.original_exception.__class__.__name__,
62+
"args": context.original_exception.args,
63+
}
64+
),
65+
}
66+
)
67+
68+
69+
def execute_wrapper(func, instance, args, kwargs):
70+
result = func(*args, **kwargs)
71+
with lumigo_safe_execute("sqlalchemy: listen to engine"):
72+
listen(result, "before_cursor_execute", _before_cursor_execute)
73+
listen(result, "after_cursor_execute", _after_cursor_execute)
74+
listen(result, "handle_error", _handle_error)
75+
return result
76+
77+
78+
def wrap_sqlalchemy():
79+
with lumigo_safe_execute("wrap sqlalchemy"):
80+
if importlib.util.find_spec("sqlalchemy") and listen:
81+
get_logger().debug("wrapping sqlalchemy")
82+
wrap_function_wrapper(
83+
"sqlalchemy.engine.strategies", "DefaultEngineStrategy.create", execute_wrapper
84+
)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import uuid
2+
3+
import pytest
4+
5+
from sqlalchemy.exc import OperationalError
6+
from sqlalchemy import create_engine, Table, Column, Integer, String, MetaData
7+
from sqlalchemy.sql import select
8+
9+
from lumigo_tracer.lumigo_utils import DEFAULT_MAX_ENTRY_SIZE
10+
from lumigo_tracer.spans_container import SpansContainer
11+
from lumigo_tracer.tracer import lumigo_tracer
12+
13+
14+
md = MetaData()
15+
Users = Table("users", md, Column("id", Integer, primary_key=True), Column("name", String))
16+
17+
18+
@pytest.fixture
19+
def db(tmp_path):
20+
path = tmp_path / "file.db"
21+
engine = create_engine(fr"sqlite:///{path}")
22+
md.create_all(engine)
23+
yield f"sqlite:///{path}"
24+
25+
26+
def test_happy_flow(context, db):
27+
@lumigo_tracer(token="123")
28+
def lambda_test_function(event, context):
29+
engine = create_engine(db)
30+
conn = engine.connect()
31+
conn.execute(Users.insert().values(name="saart"))
32+
result = conn.execute(select([Users]))
33+
return result.fetchone()
34+
35+
assert lambda_test_function({}, context) == (1, "saart")
36+
http_spans = SpansContainer.get_span().spans
37+
38+
assert len(http_spans) == 2
39+
assert http_spans[0]["query"] == '"INSERT INTO users (name) VALUES (?)"'
40+
assert http_spans[0]["values"] == '["saart"]'
41+
assert http_spans[0]["ended"] >= http_spans[0]["started"]
42+
43+
assert http_spans[1]["query"] == '"SELECT users.id, users.name \\nFROM users"'
44+
assert http_spans[1]["values"] == "[]"
45+
assert http_spans[0]["ended"] >= http_spans[0]["started"]
46+
47+
48+
def test_non_existing_table(context, db):
49+
@lumigo_tracer(token="123")
50+
def lambda_test_function(event, context):
51+
others = Table("others", md, Column("id", Integer, primary_key=True))
52+
engine = create_engine(db)
53+
conn = engine.connect()
54+
result = conn.execute(select([others]))
55+
return result.fetchone()
56+
57+
with pytest.raises(OperationalError):
58+
lambda_test_function({}, context)
59+
60+
http_spans = SpansContainer.get_span().spans
61+
62+
assert len(http_spans) == 1
63+
assert http_spans[0]["query"] == '"SELECT others.id \\nFROM others"'
64+
assert (
65+
http_spans[0]["error"] == '{"type": "OperationalError", "args": ["no such table: others"]}'
66+
)
67+
assert http_spans[0]["ended"] >= http_spans[0]["started"]
68+
69+
70+
def test_pruning_long_strings(context, db):
71+
@lumigo_tracer(token="123")
72+
def lambda_test_function(event, context):
73+
engine = create_engine(db)
74+
conn = engine.connect()
75+
conn.execute(Users.insert().values(name="a" * (DEFAULT_MAX_ENTRY_SIZE * 5)))
76+
77+
lambda_test_function({}, context)
78+
http_spans = SpansContainer.get_span().spans
79+
80+
assert len(http_spans) == 1
81+
assert len(http_spans[0]["values"]) <= DEFAULT_MAX_ENTRY_SIZE * 2
82+
83+
84+
def test_exception_in_wrapper(context, db, monkeypatch):
85+
@lumigo_tracer(token="123")
86+
def lambda_test_function(event, context):
87+
engine = create_engine(db)
88+
conn = engine.connect()
89+
conn.execute(Users.insert().values(name="a" * (DEFAULT_MAX_ENTRY_SIZE * 5)))
90+
91+
monkeypatch.setattr(uuid, "uuid4", lambda: 1 / 0)
92+
93+
lambda_test_function({}, context) # No exception
94+
95+
assert not SpansContainer.get_span().spans

src/test/unit/wrappers/test_no_wrapping_library.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ def test_wrapping_without_libraries(monkeypatch):
1313
monkeypatch.setitem(sys.modules, "redis", None)
1414
importlib.reload(lumigo_tracer.wrappers.redis.redis_wrapper)
1515

16+
monkeypatch.setitem(sys.modules, "sqlalchemy", None)
17+
importlib.reload(lumigo_tracer.wrappers.sql.sqlalchemy_wrapper)
18+
1619
lumigo_tracer.wrappers.wrap(force=True) # should succeed
1720

1821
monkeypatch.undo()
1922
importlib.reload(lumigo_tracer.wrappers.pymongo.pymongo_wrapper)
2023
importlib.reload(lumigo_tracer.wrappers.redis.redis_wrapper)
24+
importlib.reload(lumigo_tracer.wrappers.sql.sqlalchemy_wrapper)

0 commit comments

Comments
 (0)