8000 feat: Tornado integration (#199) · etherscan-io/sentry-python@bba8d4f · GitHub
[go: up one dir, main page]

Skip to content

Commit bba8d4f

Browse files
authored
feat: Tornado integration (getsentry#199)
* feat: Tornado integration * build: Add tornado to tox.ini * fix: Skip tests for tornado when tornado is not installed * fix: Set transaction * fix: Move test
1 parent 1a0783b commit bba8d4f

File tree

10 files changed

+345
-67
lines changed

10 files changed

+345
-67
lines changed

sentry_sdk/integrations/_wsgi_common.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,7 @@ def parsed_body(self):
7575
return self.json()
7676

7777
def is_json(self):
78-
mt = (self.env().get("CONTENT_TYPE") or "").split(";", 1)[0]
79-
return (
80-
mt == "application/json"
81-
or (mt.startswith("application/"))
82-
and mt.endswith("+json")
83-
)
78+
return _is_json_content_type(self.env().get("CONTENT_TYPE"))
8479

8580
def json(self):
8681
try:
@@ -98,6 +93,18 @@ def files(self):
9893
def size_of_file(self, file):
9994
raise NotImplementedError()
10095

96+
def env(self):
97+
raise NotImplementedError()
98+
99+
100+
def _is_json_content_type(ct):
101+
mt = (ct or "").split(";", 1)[0]
102+
return (
103+
mt == "application/json"
104+
or (mt.startswith("application/"))
105+
and mt.endswith("+json")
106+
)
107+
101108

102109
def _filter_headers(headers):
103110
if _should_send_default_pii():

sentry_sdk/integrations/django/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,13 @@ def sql_to_string(sql):
3434
event_from_exception,
3535
safe_repr,
3636
format_and_strip,
37+
transaction_from_function,
3738
)
3839
from sentry_sdk.integrations import Integration
3940
from sentry_sdk.integrations.logging import ignore_logger
4041
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
4142
from sentry_sdk.integrations._wsgi_common import RequestExtractor
42-
from sentry_sdk.integrations.django.transactions import (
43-
LEGACY_RESOLVER,
44-
transaction_from_function,
45-
)
43+
from sentry_sdk.integrations.django.transactions import LEGACY_RESOLVER
4644

4745

4846
if DJANGO_VERSION < (1, 10):

sentry_sdk/integrations/django/transactions.py

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -110,34 +110,3 @@ def resolve(self, path, urlconf=None):
110110

111111

112112
LEGACY_RESOLVER = RavenResolver()
113-
114-
115-
def transaction_from_function(func):
116-
# Methods in Python 2
117-
try:
118-
return "%s.%s.%s" % (
119-
func.im_class.__module__,
120-
func.im_class.__name__,
121-
func.__name__,
122-
)
123-
except Exception:
124-
pass
125-
126-
func_qualname = (
127-
getattr(func, "__qualname__", None) or getattr(func, "__name__", None) or None
128-
)
129-
130-
if not func_qualname:
131-
# No idea what it is
132-
return None
133-
134-
# Methods in Python 3
135-
# Functions
136-
# Classes
137-
try:
138-
return "%s.%s" % (func.__module__, func_qualname)
139-
except Exception:
140-
pass
141-
142-
# Possibly a lambda
143-
return func_qualname

sentry_sdk/integrations/tornado.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import sys
2+
import weakref
3+
4+
from sentry_sdk.hub import Hub, _should_send_default_pii
5+
from sentry_sdk.utils import (
6+
event_from_exception,
7+
capture_internal_exceptions,
8+
transaction_from_function,
9+
)
10+
from sentry_sdk.integrations import Integration
11+
from sentry_sdk.integrations._wsgi_common import (
12+
RequestExtractor,
13+
_filter_headers,
14+
_is_json_content_type,
15+
)
16+
from sentry_sdk.integrations.logging import ignore_logger
17+
18+
from tornado.web import RequestHandler, HTTPError
19+
from tornado.gen import coroutine
20+
21+
22+
class TornadoIntegration(Integration):
23+
identifier = "tornado"
24+
25+
@staticmethod
26+
def setup_once():
27+
import tornado
28+
29+
tornado_version = getattr(tornado, "version_info", None)
30+
if tornado_version is None or tornado_version < (5, 0):
31+
raise RuntimeError("Tornado 5+ required")
32+
33+
if sys.version_info < (3, 7):
34+
# Tornado is async. We better have contextvars or we're going to leak
35+
# state between requests.
36+
raise RuntimeError(
37+
"The tornado integration for Sentry requires Python 3.7+"
38+
)
39+
40+
ignore_logger("tornado.application")
41+
ignore_logger("tornado.access")
42+
43+
old_execute = RequestHandler._execute
44+
45+
@coroutine
46+
def sentry_execute_request_handler(self, *args, **kwargs):
47+
hub = Hub.current
48+
integration = hub.get_integration(TornadoIntegration)
49+
if integration is None:
50+
return old_execute(self, *args, **kwargs)
51+
52+
weak_handler = weakref.ref(self)
53+
54+
with Hub(hub) as hub:
55+
with hub.configure_scope() as scope:
56+
scope.add_event_processor(_make_event_processor(weak_handler))
57+
result = yield from old_execute(self, *args, **kwargs)
58+
return result
59+
60+
RequestHandler._execute = sentry_execute_request_handler
61+
62+
old_log_exception = RequestHandler.log_exception
63+
64+
def sentry_log_exception(self, ty, value, tb, *args, **kwargs):
65+
_capture_exception(ty, value, tb)
66+
return old_log_exception(self, ty, value, tb, *args, **kwargs)
67+
68+
RequestHandler.log_exception = sentry_log_exception
69+
70+
71+
def _capture_exception(ty, value, tb):
72+
hub = Hub.current
73+
if hub.get_integration(TornadoIntegration) is None:
74+
return
75+
if isinstance(value, HTTPError):
76+
return
77+
78+
event, hint = event_from_exception(
79+
(ty, value, tb),
80+
client_options=hub.client.options,
81+
mechanism={"type": "tornado", "handled": False},
82+
)
83+
84+
hub.capture_event(event, hint=hint)
85+
86+
87+
def _make_event_processor(weak_handler):
88+
def tornado_processor(event, hint):
89+
handler = weak_handler()
90+
if handler is None:
91+
return event
92+
93+
request = handler.request
94+
95+
if "transaction" not in event:
96+
with capture_internal_exceptions():
97+
method = getattr(handler, handler.request.method.lower())
98+
event["transaction"] = transaction_from_function(method)
99+
100+
with capture_internal_exceptions():
101+
extractor = TornadoRequestExtractor(request)
102+
extractor.extract_into_event(event)
103+
104+
request_info = event["request"]
105+
106+
if "url" not in request_info:
107+
request_info["url"] = "%s://%s%s" % (
108+
request.protocol,
109+
request.host,
110+
request.path,
111+
)
112+
113+
if "query_string" not in request_info:
114+
request_info["query_string"] = request.query
115+
116+
if "method" not in request_info:
117+
request_info["method"] = request.method
118+
119+
if "env" not in request_info:
120+
request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
121+
122+
if "headers" not in request_info:
123+
request_info["headers"] = _filter_headers(dict(request.headers))
124+
125+
with capture_internal_exceptions():
126+
if handler.current_user and _should_send_default_pii():
127+
event.setdefault("user", {})["is_authenticated"] = True
128+
129+
return event
130+
131+
return tornado_processor
132+
133+
134+
class TornadoRequestExtractor(RequestExtractor):
135+
def content_length(self):
136+
if self.request.body is None:
137+
return 0
138+
return len(self.request.body)
139+
140+
def cookies(self):
141+
return dict(self.request.cookies)
142+
143+
def raw_data(self):
144+
return self.request.body
145+
146+
def form(self):
147+
# TODO: Where to get formdata and nothing else?
148+
return None
149+
150+
def is_json(self):
151+
return _is_json_content_type(self.request.headers.get("content-type"))
152+
153+
def files(self):
154+
return {k: v[0] for k, v in self.request.files.items() if v}
155+
156+
def size_of_file(self, file):
157+
return len(file.body or ())

sentry_sdk/utils.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,3 +712,34 @@ def get(self, default):
712712

713713
def set(self, value):
714714
setattr(self._local, "value", value)
715+
716+
717+
def transaction_from_function(func):
718+
# Methods in Python 2
719+
try:
720+
return "%s.%s.%s" % (
721+
func.im_class.__module__,
722+
func.im_class.__name__,
723+
func.__name__,
724+
)
725+
except Exception:
726+
pass
727+
728+
func_qualname = (
729+
getattr(func, "__qualname__", None) or getattr(func, "__name__", None) or None
730+
)
731+
732+
if not func_qualname:
733+
# No idea what it is
734+
return None
735+
736+
# Methods in Python 3
737+
# Functions
738+
# Classes
739+
try:
740+
return "%s.%s" % (func.__module__, func_qualname)
741+
except Exception:
742+
pass
743+
744+
# Possibly a lambda
745+
return func_qualname

tests/integrations/django/test_transactions.py

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@
99
# for Django version less than 1.4
1010
from django.conf.urls.defaults import url, include # NOQA
1111

12-
from sentry_sdk.integrations.django.transactions import (
13-
RavenResolver,
14-
transaction_from_function,
15-
)
12+
from sentry_sdk.integrations.django.transactions import RavenResolver
1613

1714

1815
if django.VERSION < (1, 9):
@@ -53,25 +50,3 @@ def test_legacy_resolver_newstyle_django20_urlconf():
5350
resolver = RavenResolver()
5451
result = resolver.resolve("/api/v2/1234/store/", url_conf)
5552
assert result == "/api/v2/{project_id}/store/"
56-
57-
58-
class MyClass:
59-
def myfunc():
60-
pass
61-
62-
63-
def myfunc():
64-
pass
65-
66-
67-
def test_transaction_from_function():
68-
x = transaction_from_function
69-
assert x(MyClass) == "tests.integrations.django.test_transactions.MyClass"
70-
assert (
71-
x(MyClass.myfunc)
72-
== "tests.integrations.django.test_transactions.MyClass.myfunc"
73-
)
74-
assert x(myfunc) == "tests.integrations.django.test_transactions.myfunc"
75-
assert x(None) is None
76-
assert x(42) is None
77-
assert x(lambda: None).endswith("<lambda>")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
tornado = pytest.importorskip("tornado")

0 commit comments

Comments
 (0)
0