import sys
import weakref
from inspect import iscoroutinefunction

from sentry_sdk.hub import Hub, _should_send_default_pii
from sentry_sdk.utils import (
    event_from_exception,
    capture_internal_exceptions,
    transaction_from_function,
)
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations._wsgi_common import (
    RequestExtractor,
    _filter_headers,
    _is_json_content_type,
)
from sentry_sdk.integrations.logging import ignore_logger

from tornado.web import RequestHandler, HTTPError  # type: ignore
from tornado.gen import coroutine  # type: ignore

if False:
    from typing import Any
    from typing import List
    from typing import Optional
    from typing import Dict
    from typing import Callable


class TornadoIntegration(Integration):
    identifier = "tornado"

    @staticmethod
    def setup_once():
        # type: () -> None
        import tornado  # type: ignore

        tornado_version = getattr(tornado, "version_info", None)
        if tornado_version is None or tornado_version < (5, 0):
            raise RuntimeError("Tornado 5+ required")

        if sys.version_info < (3, 7):
            # Tornado is async. We better have contextvars or we're going to leak
            # state between requests.
            raise RuntimeError(
                "The tornado integration for Sentry requires Python 3.7+"
            )

        ignore_logger("tornado.application")
        ignore_logger("tornado.access")

        old_execute = RequestHandler._execute

        awaitable = iscoroutinefunction(old_execute)

        if awaitable:
            # Starting Tornado 6 RequestHandler._execute method is a standard Python coroutine (async/await)
            # In that case our method should be a coroutine function too
            async def sentry_execute_request_handler(self, *args, **kwargs):
                # type: (Any, *List, **Any) -> Any
                hub = Hub.current
                integration = hub.get_integration(TornadoIntegration)
                if integration is None:
                    return await old_execute(self, *args, **kwargs)

                weak_handler = weakref.ref(self)

                with Hub(hub) as hub:
                    with hub.configure_scope() as scope:
                        scope.add_event_processor(_make_event_processor(weak_handler))
                    return await old_execute(self, *args, **kwargs)

        else:

            @coroutine  # type: ignore
            def sentry_execute_request_handler(self, *args, **kwargs):
                hub = Hub.current
                integration = hub.get_integration(TornadoIntegration)
                if integration is None:
                    return old_execute(self, *args, **kwargs)

                weak_handler = weakref.ref(self)

                with Hub(hub) as hub:
                    with hub.configure_scope() as scope:
                        scope.add_event_processor(_make_event_processor(weak_handler))
                    result = yield from old_execute(self, *args, **kwargs)
                    return result

        RequestHandler._execute = sentry_execute_request_handler

        old_log_exception = RequestHandler.log_exception

        def sentry_log_exception(self, ty, value, tb, *args, **kwargs):
            # type: (Any, type, BaseException, Any, *Any, **Any) -> Optional[Any]
            _capture_exception(ty, value, tb)
            return old_log_exception(self, ty, value, tb, *args, **kwargs)

        RequestHandler.log_exception = sentry_log_exception


def _capture_exception(ty, value, tb):
    # type: (type, BaseException, Any) -> None
    hub = Hub.current
    if hub.get_integration(TornadoIntegration) is None:
        return
    if isinstance(value, HTTPError):
        return

    event, hint = event_from_exception(
        (ty, value, tb),
        client_options=hub.client.options,
        mechanism={"type": "tornado", "handled": False},
    )

    hub.capture_event(event, hint=hint)


def _make_event_processor(weak_handler):
    # type: (Callable[[], RequestHandler]) -> Callable
    def tornado_processor(event, hint):
        # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
        handler = weak_handler()
        if handler is None:
            return event

        request = handler.request

        with capture_internal_exceptions():
            method = getattr(handler, handler.request.method.lower())
            event["transaction"] = transaction_from_function(method)

        with capture_internal_exceptions():
            extractor = TornadoRequestExtractor(request)
            extractor.extract_into_event(event)

            request_info = event["request"]

            request_info["url"] = "%s://%s%s" % (
                request.protocol,
                request.host,
                request.path,
            )

            request_info["query_string"] = request.query
            request_info["method"] = request.method
            request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
            request_info["headers"] = _filter_headers(dict(request.headers))

        with capture_internal_exceptions():
            if handler.current_user and _should_send_default_pii():
                event.setdefault("user", {})["is_authenticated"] = True

        return event

    return tornado_processor


class TornadoRequestExtractor(RequestExtractor):
    def content_length(self):
        # type: () -> int
        if self.request.body is None:
            return 0
        return len(self.request.body)

    def cookies(self):
        # type: () -> Dict
        return {k: v.value for k, v in self.request.cookies.items()}

    def raw_data(self):
        # type: () -> bytes
        return self.request.body

    def form(self):
        # type: () -> Optional[Any]
        # TODO: Where to get formdata and nothing else?
        return None

    def is_json(self):
        # type: () -> bool
        return _is_json_content_type(self.request.headers.get("content-type"))

    def files(self):
        # type: () -> Dict
        return {k: v[0] for k, v in self.request.files.items() if v}

    def size_of_file(self, file):
        return len(file.body or ())

sentry_sdk/integrations/tornado.py
import sys
import weakref
from inspect import iscoroutinefunction

from sentry_sdk.hub import Hub, _should_send_default_pii
from sentry_sdk.utils import (
    event_from_exception,
    capture_internal_exceptions,
    transaction_from_function,
)
from sentry_sdk.integrations import Integration
from sentry_sdk.integrations._wsgi_common import (
    RequestExtractor,
    _filter_headers,
    _is_json_content_type,
)
from sentry_sdk.integrations.logging import ignore_logger

from tornado.web import RequestHandler, HTTPError  # type: ignore
from tornado.gen import coroutine  # type: ignore

if False:
    from typing import Any
    from typing import List
    from typing import Optional
    from typing import Dict
    from typing import Callable


class TornadoIntegration(Integration):
    identifier = "tornado"

    @staticmethod
    def setup_once():
        # type: () -> None
        import tornado  # type: ignore

        tornado_version = getattr(tornado, "version_info", None)
        if tornado_version is None or tornado_version < (5, 0):
            raise RuntimeError("Tornado 5+ required")

        if sys.version_info < (3, 7):
            # Tornado is async. We better have contextvars or we're going to leak
            # state between requests.
            raise RuntimeError(
                "The tornado integration for Sentry requires Python 3.7+"
            )

        ignore_logger("tornado.application")
        ignore_logger("tornado.access")

        old_execute = RequestHandler._execute

        awaitable = iscoroutinefunction(old_execute)

        if awaitable:
            # Starting Tornado 6 RequestHandler._execute method is a standard Python coroutine (async/await)
            # In that case our method should be a coroutine function too
            async def sentry_execute_request_handler(self, *args, **kwargs):
                # type: (Any, *List, **Any) -> Any
                hub = Hub.current
                integration = hub.get_integration(TornadoIntegration)
                if integration is None:
                    return await old_execute(self, *args, **kwargs)

                weak_handler = weakref.ref(self)

                with Hub(hub) as hub:
                    with hub.configure_scope() as scope:
                        scope.add_event_processor(_make_event_processor(weak_handler))
                    return await old_execute(self, *args, **kwargs)

        else:

            @coroutine  # type: ignore
            def sentry_execute_request_handler(self, *args, **kwargs):
                hub = Hub.current
                integration = hub.get_integration(TornadoIntegration)
                if integration is None:
                    return old_execute(self, *args, **kwargs)

                weak_handler = weakref.ref(self)

                with Hub(hub) as hub:
                    with hub.configure_scope() as scope:
                        scope.add_event_processor(_make_event_processor(weak_handler))
                    result = yield from old_execute(self, *args, **kwargs)
                    return result

        RequestHandler._execute = sentry_execute_request_handler

        old_log_exception = RequestHandler.log_exception

        def sentry_log_exception(self, ty, value, tb, *args, **kwargs):
            # type: (Any, type, BaseException, Any, *Any, **Any) -> Optional[Any]
            _capture_exception(ty, value, tb)
            return old_log_exception(self, ty, value, tb, *args, **kwargs)

        RequestHandler.log_exception = sentry_log_exception


def _capture_exception(ty, value, tb):
    # type: (type, BaseException, Any) -> None
    hub = Hub.current
    if hub.get_integration(TornadoIntegration) is None:
        return
    if isinstance(value, HTTPError):
        return

    event, hint = event_from_exception(
        (ty, value, tb),
        client_options=hub.client.options,
        mechanism={"type": "tornado", "handled": False},
    )

    hub.capture_event(event, hint=hint)


def _make_event_processor(weak_handler):
    # type: (Callable[[], RequestHandler]) -> Callable
    def tornado_processor(event, hint):
        # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
        handler = weak_handler()
        if handler is None:
            return event

        request = handler.request

        with capture_internal_exceptions():
            method = getattr(handler, handler.request.method.lower())
            event["transaction"] = transaction_from_function(method)

        with capture_internal_exceptions():
            extractor = TornadoRequestExtractor(request)
            extractor.extract_into_event(event)

            request_info = event["request"]

            request_info["url"] = "%s://%s%s" % (
                request.protocol,
                request.host,
                request.path,
            )

            request_info["query_string"] = request.query
            request_info["method"] = request.method
            request_info["env"] = {"REMOTE_ADDR": request.remote_ip}
            request_info["headers"] = _filter_headers(dict(request.headers))

        with capture_internal_exceptions():
            if handler.current_user and _should_send_default_pii():
                event.setdefault("user", {})["is_authenticated"] = True

        return event

    return tornado_processor


class TornadoRequestExtractor(RequestExtractor):
    def content_length(self):
        # type: () -> int
        if self.request.body is None:
            return 0
        return len(self.request.body)

    def cookies(self):
        # type: () -> Dict
        return {k: v.value for k, v in self.request.cookies.items()}

    def raw_data(self):
        # type: () -> bytes
        return self.request.body

    def form(self):
        # type: () -> Optional[Any]
        return {
            k: [v.decode("latin1", "replace") for v in vs]
            for k, vs in self.request.body_arguments.items()
        }

    def is_json(self):
        # type: () -> bool
        return _is_json_content_type(self.request.headers.get("content-type"))

    def files(self):
        # type: () -> Dict
        return {k: v[0] for k, v in self.request.files.items() if v}

    def size_of_file(self, file):
        return len(file.body or ())

sentry_sdk/integrations/tornado.py
diff --git a/scripts/runtox.sh b/scripts/runtox.sh
index 54b1c96161..009e52a71b 100755
--- a/scripts/runtox.sh
+++ b/scripts/runtox.sh
@@ -1,8 +1,12 @@
 #!/bin/bash
 set -e
 
-if [ -z "$TOXPATH" ]; then
+if [ -n "$TOXPATH" ]; then
+    true
+elif which tox &> /dev/null; then
     TOXPATH=tox
+else
+    TOXPATH=./.venv/bin/tox
 fi
 
 # Usage: sh scripts/runtox.sh py3.7 <pytest-args>
diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py
index 32fec5ab26..4cad65b03b 100644
--- a/sentry_sdk/integrations/tornado.py
+++ b/sentry_sdk/integrations/tornado.py
@@ -173,8 +173,10 @@ def raw_data(self):
 
     def form(self):
         # type: () -> Optional[Any]
-        # TODO: Where to get formdata and nothing else?
-        return None
+        return {
+            k: [v.decode("latin1", "replace") for v in vs]
+            for k, vs in self.request.body_arguments.items()
+        }
 
     def is_json(self):
         # type: () -> bool
diff --git a/tests/conftest.py b/tests/conftest.py
index e38de25b3b..2456aa4318 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,16 +16,20 @@
 
 @pytest.fixture(autouse=True)
 def reraise_internal_exceptions(request, monkeypatch):
+    errors = []
     if "tests_internal_exceptions" in request.keywords:
         return
 
-    def _capture_internal_exception(exc_info):
-        reraise(*exc_info)
+    def _capture_internal_exception(self, exc_info):
+        errors.append(exc_info)
+
+    @request.addfinalizer
+    def _():
+        for e in errors:
+            reraise(*e)
 
     monkeypatch.setattr(
-        sentry_sdk.Hub.current,
-        "_capture_internal_exception",
-        _capture_internal_exception,
+        sentry_sdk.Hub, "_capture_internal_exception", _capture_internal_exception
     )
 
 
diff --git a/tests/integrations/tornado/test_tornado.py b/tests/integrations/tornado/test_tornado.py
index 469cb2596c..8070947e78 100644
--- a/tests/integrations/tornado/test_tornado.py
+++ b/tests/integrations/tornado/test_tornado.py
@@ -1,7 +1,8 @@
+import json
+
 import pytest
 
 from sentry_sdk import configure_scope
-
 from sentry_sdk.integrations.tornado import TornadoIntegration
 
 from tornado.web import RequestHandler, Application, HTTPError
@@ -136,3 +137,56 @@ def get(self):
     assert exception["type"] == "ZeroDivisionError"
 
     assert "user" not in event
+
+
+def test_formdata(tornado_testcase, sentry_init, capture_events):
+    sentry_init(integrations=[TornadoIntegration()], send_default_pii=True)
+    events = capture_events()
+
+    class FormdataHandler(RequestHandler):
+        def post(self):
+            raise ValueError(json.dumps(sorted(self.request.body_arguments)))
+
+    client = tornado_testcase(Application([(r"/form", FormdataHandler)]))
+
+    response = client.fetch(
+        "/form?queryarg=1",
+        method="POST",
+        headers={"Content-Type": "application/x-www-form-urlencoded"},
+        body=b"field1=value1&field2=value2",
+    )
+
+    assert response.code == 500
+
+    event, = events
+    exception, = event["exception"]["values"]
+    assert exception["value"] == '["field1", "field2"]'
+    assert event["request"]["data"] == {"field1": ["value1"], "field2": ["value2"]}
+
+
+def test_json(tornado_testcase, sentry_init, capture_events):
+    sentry_init(integrations=[TornadoIntegration()], send_default_pii=True)
+    events = capture_events()
+
+    class FormdataHandler(RequestHandler):
+        def post(self):
+            raise ValueError(json.dumps(sorted(self.request.body_arguments)))
+
+    client = tornado_testcase(Application([(r"/form", FormdataHandler)]))
+
+    response = client.fetch(
+        "/form?queryarg=1",
+        method="POST",
+        headers={"Content-Type": "application/json"},
+        body=b"""
+        {"foo": {"bar": 42}}
+        """,
+    )
+
+    assert response.code == 500
+
+    event, = events
+    exception, = event["exception"]["values"]
+    assert exception["value"] == "[]"
+    assert event
+    assert event["request"]["data"] == {"foo": {"bar": 42}}

Tornado integration does not capture formdata
There is no obvious way to get just formdata from Tornado's request object (not mixed with other request params), but I haven't done a lot of research on that.
