Skip to content

Commit 2b8d96d

Browse files
authored
Extract additional expression values with pure_eval (#762)
1 parent 5c34ead commit 2b8d96d

File tree

6 files changed

+149
-0
lines changed

6 files changed

+149
-0
lines changed

mypy.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,7 @@ ignore_missing_imports = True
5050
ignore_missing_imports = True
5151
[mypy-executing.*]
5252
ignore_missing_imports = True
53+
[mypy-asttokens.*]
54+
ignore_missing_imports = True
55+
[mypy-pure_eval.*]
56+
ignore_missing_imports = True

sentry_sdk/integrations/pure_eval.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import absolute_import
2+
3+
import ast
4+
5+
from sentry_sdk import Hub
6+
from sentry_sdk._types import MYPY
7+
from sentry_sdk.integrations import Integration, DidNotEnable
8+
from sentry_sdk.scope import add_global_event_processor
9+
from sentry_sdk.utils import walk_exception_chain, iter_stacks
10+
11+
if MYPY:
12+
from typing import Optional, Dict, Any
13+
from types import FrameType
14+
15+
from sentry_sdk._types import Event, Hint
16+
17+
try:
18+
import executing
19+
except ImportError:
20+
raise DidNotEnable("executing is not installed")
21+
22+
try:
23+
import pure_eval
24+
except ImportError:
25+
raise DidNotEnable("pure_eval is not installed")
26+
27+
try:
28+
# Used implicitly, just testing it's available
29+
import asttokens # noqa
30+
except ImportError:
31+
raise DidNotEnable("asttokens is not installed")
32+
33+
34+
class PureEvalIntegration(Integration):
35+
identifier = "pure_eval"
36+
37+
@staticmethod
38+
def setup_once():
39+
# type: () -> None
40+
41+
@add_global_event_processor
42+
def add_executing_info(event, hint):
43+
# type: (Event, Optional[Hint]) -> Optional[Event]
44+
if Hub.current.get_integration(PureEvalIntegration) is None:
45+
return event
46+
47+
if hint is None:
48+
return event
49+
50+
exc_info = hint.get("exc_info", None)
51+
52+
if exc_info is None:
53+
return event
54+
55+
exception = event.get("exception", None)
56+
57+
if exception is None:
58+
return event
59+
60+
values = exception.get("values", None)
61+
62+
if values is None:
63+
return event
64+
65+
for exception, (_exc_type, _exc_value, exc_tb) in zip(
66+
reversed(values), walk_exception_chain(exc_info)
67+
):
68+
sentry_frames = [
69+
frame
70+
for frame in exception.get("stacktrace", {}).get("frames", [])
71+
if frame.get("function")
72+
]
73+
tbs = list(iter_stacks(exc_tb))
74+
if len(sentry_frames) != len(tbs):
75+
continue
76+
77+
for sentry_frame, tb in zip(sentry_frames, tbs):
78+
sentry_frame["vars"].update(pure_eval_frame(tb.tb_frame))
79+
return event
80+
81+
82+
def pure_eval_frame(frame):
83+
# type: (FrameType) -> Dict[str, Any]
84+
source = executing.Source.for_frame(frame)
85+
if not source.tree:
86+
return {}
87+
88+
statements = source.statements_at_line(frame.f_lineno)
89+
if not statements:
90+
return {}
91+
92+
stmt = list(statements)[0]
93+
while True:
94+
# Get the parent first in case the original statement is already
95+
# a function definition, e.g. if we're calling a decorator
96+
# In that case we still want the surrounding scope, not that function
97+
stmt = stmt.parent
98+
if isinstance(stmt, (ast.FunctionDef, ast.ClassDef, ast.Module)):
99+
break
100+
101+
evaluator = pure_eval.Evaluator.from_frame(frame)
102+
expressions = evaluator.interesting_expressions_grouped(stmt)
103+
atok = source.asttokens()
104+
return {atok.get_text(nodes[0]): value for nodes, value in expressions}

test-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ gevent
88
eventlet
99
newrelic
1010
executing
11+
asttokens
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pure_eval = pytest.importorskip("pure_eval")
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import pytest
2+
3+
from sentry_sdk import capture_exception
4+
from sentry_sdk.integrations.pure_eval import PureEvalIntegration
5+
6+
7+
@pytest.mark.parametrize("integrations", [[], [PureEvalIntegration()]])
8+
def test_with_locals_enabled(sentry_init, capture_events, integrations):
9+
sentry_init(with_locals=True, integrations=integrations)
10+
events = capture_events()
11+
12+
def foo():
13+
foo.d = {1: 2}
14+
print(foo.d[1] / 0)
15+
16+
try:
17+
foo()
18+
except Exception:
19+
capture_exception()
20+
21+
(event,) = events
22+
23+
assert all(
24+
frame["vars"]
25+
for frame in event["exception"]["values"][0]["stacktrace"]["frames"]
26+
)
27+
28+
frame_vars = event["exception"]["values"][0]["stacktrace"]["frames"][-1]["vars"]
29+
30+
if integrations:
31+
assert sorted(frame_vars.keys()) == ["foo", "foo.d", "foo.d[1]"]
32+
assert frame_vars["foo.d"] == {"1": "2"}
33+
assert frame_vars["foo.d[1]"] == "2"
34+
else:
35+
assert sorted(frame_vars.keys()) == ["foo"]

tox.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ envlist =
7373
[testenv]
7474
deps =
7575
-r test-requirements.txt
76+
77+
py3.{5,6,7,8}: pure_eval
7678

7779
django-{1.11,2.0,2.1,2.2,3.0,dev}: djangorestframework>=3.0.0,<4.0.0
7880
{py3.7,py3.8}-django-{1.11,2.0,2.1,2.2,3.0,dev}: channels>2

0 commit comments

Comments
 (0)