Repository: getsentry/sentry-python

Issue:
Pyramid support
[Pyramid](https://trypyramid.com/) was previously supported by raven. It would be great to see support for it in this client. Is this perhaps already on the roadmap?

Also if there is any documentation on how to write an integration I might be tempted to contribute.


Discussion:
Hi, we generally want to move out integrations into separate packages.

Integrations don't have a large interface they need to implement: There's the [`Integration`](https://getsentry.github.io/sentry-python/integrations/index.html#sentry_sdk.integrations.Integration) class with a single `install` function that can do anything. In most integrations this means registering/hooking/patching into global signal handlers to receive exceptions.

I would encourage to try integrating Pyramid with this SDK in a separate package, but with a few caveats:

* You probably want to use `sentry_sdk.integrations._wsgi` like Flask and Django do, but that is currently an unstable API. Stabilizing this is on the roadmap.
* We don't have much documentation about how to write integrations because the only prerequisite for getting started is subclassing `Integration`. But that also means that you'll have to look into existing code to see what is idiomatic in the context of this SDK.
Thanks for you reply and the pointers. It doesn't seem like Pyramid integrates as nicely as Flask or Django and since the `_wsgi` module is subject to change I think I'm going to withhold from implementing the integration for now. If I change my mind, I'll make sure to create a separate package for it. 
Note though that this is not fully set in stone yet, we are definetly open to changes of plan, should the demand for a native Pyramid integration be there :)

Resolving patch:
diff --git a/sentry_sdk/integrations/_wsgi.py b/sentry_sdk/integrations/_wsgi.py
--- a/sentry_sdk/integrations/_wsgi.py
+++ b/sentry_sdk/integrations/_wsgi.py
@@ -7,7 +7,7 @@
     capture_internal_exceptions,
     event_from_exception,
 )
-from sentry_sdk._compat import reraise, implements_iterator
+from sentry_sdk._compat import reraise, implements_iterator, text_type
 
 
 def get_environ(environ):
@@ -123,7 +123,10 @@ def is_json(self):
     def json(self):
         try:
             if self.is_json():
-                return json.loads(self.raw_data().decode("utf-8"))
+                raw_data = self.raw_data()
+                if not isinstance(raw_data, text_type):
+                    raw_data = raw_data.decode("utf-8")
+                return json.loads(raw_data)
         except ValueError:
             pass
 
diff --git a/sentry_sdk/integrations/pyramid.py b/sentry_sdk/integrations/pyramid.py
new file mode 100644
--- /dev/null
+++ b/sentry_sdk/integrations/pyramid.py
@@ -0,0 +1,148 @@
+from __future__ import absolute_import
+
+import os
+import sys
+import weakref
+
+from pyramid.httpexceptions import HTTPException
+
+from sentry_sdk.hub import Hub, _should_send_default_pii
+from sentry_sdk.utils import capture_internal_exceptions, event_from_exception
+from sentry_sdk._compat import reraise
+
+from sentry_sdk.integrations import Integration
+from sentry_sdk.integrations._wsgi import RequestExtractor, run_wsgi_app
+
+
+class PyramidIntegration(Integration):
+    identifier = "pyramid"
+
+    transaction_style = None
+
+    def __init__(self, transaction_style="route_name"):
+        TRANSACTION_STYLE_VALUES = ("route_name", "route_pattern")
+        if transaction_style not in TRANSACTION_STYLE_VALUES:
+            raise ValueError(
+                "Invalid value for transaction_style: %s (must be in %s)"
+                % (transaction_style, TRANSACTION_STYLE_VALUES)
+            )
+        self.transaction_style = transaction_style
+
+    @staticmethod
+    def setup_once():
+        from pyramid.router import Router
+
+        old_handle_request = Router.handle_request
+
+        def sentry_patched_handle_request(self, request, *args, **kwargs):
+            hub = Hub.current
+            integration = hub.get_integration(PyramidIntegration)
+            if integration is None:
+                return old_handle_request(self, request, *args, **kwargs)
+
+            with hub.configure_scope() as scope:
+                scope.add_event_processor(
+                    _make_event_processor(weakref.ref(request), integration)
+                )
+
+            try:
+                return old_handle_request(self, request, *args, **kwargs)
+            except Exception:
+                exc_info = sys.exc_info()
+                _capture_exception(exc_info)
+                reraise(*exc_info)
+
+        Router.handle_request = sentry_patched_handle_request
+
+        old_wsgi_call = Router.__call__
+
+        def sentry_patched_wsgi_call(self, environ, start_response):
+            hub = Hub.current
+            integration = hub.get_integration(PyramidIntegration)
+            if integration is None:
+                return old_wsgi_call(self, environ, start_response)
+
+            return run_wsgi_app(
+                lambda *a, **kw: old_wsgi_call(self, *a, **kw), environ, start_response
+            )
+
+        Router.__call__ = sentry_patched_wsgi_call
+
+
+def _capture_exception(exc_info, **kwargs):
+    if issubclass(exc_info[0], HTTPException):
+        return
+    hub = Hub.current
+    if hub.get_integration(PyramidIntegration) is None:
+        return
+    event, hint = event_from_exception(
+        exc_info,
+        client_options=hub.client.options,
+        mechanism={"type": "pyramid", "handled": False},
+    )
+
+    hub.capture_event(event, hint=hint)
+
+
+class PyramidRequestExtractor(RequestExtractor):
+    def url(self):
+        return self.request.path_url
+
+    def env(self):
+        return self.request.environ
+
+    def cookies(self):
+        return self.request.cookies
+
+    def raw_data(self):
+        return self.request.text
+
+    def form(self):
+        return {
+            key: value
+            for key, value in self.request.POST.items()
+            if not getattr(value, "filename", None)
+        }
+
+    def files(self):
+        return {
+            key: value
+            for key, value in self.request.POST.items()
+            if getattr(value, "filename", None)
+        }
+
+    def size_of_file(self, postdata):
+        file = postdata.file
+        try:
+            return os.fstat(file.fileno()).st_size
+        except Exception:
+            return 0
+
+
+def _make_event_processor(weak_request, integration):
+    def event_processor(event, hint):
+        request = weak_request()
+        if request is None:
+            return event
+
+        if "transaction" not in event:
+            try:
+                if integration.transaction_style == "route_name":
+                    event["transaction"] = request.matched_route.name
+                elif integration.transaction_style == "route_pattern":
+                    event["transaction"] = request.matched_route.pattern
+            except Exception:
+                pass
+
+        with capture_internal_exceptions():
+            PyramidRequestExtractor(request).extract_into_event(event)
+
+        if _should_send_default_pii():
+            with capture_internal_exceptions():
+                user_info = event.setdefault("user", {})
+                if "id" not in user_info:
+                    user_info["id"] = request.authenticated_userid
+
+        return event
+
+    return event_processor


Test patch:
diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py
--- a/tests/integrations/flask/test_flask.py
+++ b/tests/integrations/flask/test_flask.py
@@ -290,6 +290,7 @@ def index():
     }
     assert len(event["request"]["data"]["foo"]) == 512
 
+    # TODO: wrong filesize reported due to usage of BytesIO. Use more realistic input
     assert event["_meta"]["request"]["data"]["file"] == {
         "": {"len": 0, "rem": [["!raw", "x", 0, 0]]}
     }
diff --git a/tests/integrations/pyramid/__init__.py b/tests/integrations/pyramid/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/pyramid/__init__.py
@@ -0,0 +1,3 @@
+import pytest
+
+pyramid = pytest.importorskip("pyramid")
diff --git a/tests/integrations/pyramid/test_pyramid.py b/tests/integrations/pyramid/test_pyramid.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/pyramid/test_pyramid.py
@@ -0,0 +1,242 @@
+import json
+
+from io import BytesIO
+
+import pytest
+
+import pyramid.testing
+
+from pyramid.response import Response
+
+from werkzeug.test import Client
+
+from sentry_sdk import capture_message
+from sentry_sdk.integrations.pyramid import PyramidIntegration
+
+
+def hi(request):
+    capture_message("hi")
+    return Response("hi")
+
+
+@pytest.fixture
+def pyramid_config():
+    config = pyramid.testing.setUp()
+    try:
+        config.add_route("hi", "/message")
+        config.add_view(hi, route_name="hi")
+        yield config
+    finally:
+        pyramid.testing.tearDown()
+
+
+@pytest.fixture
+def route(pyramid_config):
+    def inner(url):
+        def wrapper(f):
+            pyramid_config.add_route(f.__name__, url)
+            pyramid_config.add_view(f, route_name=f.__name__)
+            return f
+
+        return wrapper
+
+    return inner
+
+
+@pytest.fixture
+def get_client(pyramid_config):
+    def inner():
+        return Client(pyramid_config.make_wsgi_app())
+
+    return inner
+
+
+def test_view_exceptions(
+    get_client, route, sentry_init, capture_events, capture_exceptions
+):
+    sentry_init(integrations=[PyramidIntegration()])
+    events = capture_events()
+    exceptions = capture_exceptions()
+
+    @route("/errors")
+    def errors(request):
+        1 / 0
+
+    client = get_client()
+    with pytest.raises(ZeroDivisionError):
+        client.get("/errors")
+
+    error, = exceptions
+    assert isinstance(error, ZeroDivisionError)
+
+    event, = events
+    assert event["exception"]["values"][0]["mechanism"]["type"] == "pyramid"
+
+
+def test_has_context(route, get_client, sentry_init, capture_events):
+    sentry_init(integrations=[PyramidIntegration()])
+    events = capture_events()
+
+    @route("/message/{msg}")
+    def hi2(request):
+        capture_message(request.matchdict["msg"])
+        return Response("hi")
+
+    client = get_client()
+    client.get("/message/yoo")
+
+    event, = events
+    assert event["message"] == "yoo"
+    assert event["request"] == {
+        "env": {"SERVER_NAME": "localhost", "SERVER_PORT": "80"},
+        "headers": {"Content-Length": "0", "Content-Type": "", "Host": "localhost"},
+        "method": "GET",
+        "query_string": "",
+        "url": "http://localhost/message/yoo",
+    }
+    assert event["transaction"] == "hi2"
+
+
+@pytest.mark.parametrize(
+    "transaction_style,expected_transaction",
+    [("route_name", "hi"), ("route_pattern", "/message")],
+)
+def test_transaction_style(
+    sentry_init, get_client, capture_events, transaction_style, expected_transaction
+):
+    sentry_init(integrations=[PyramidIntegration(transaction_style=transaction_style)])
+
+    events = capture_events()
+    client = get_client()
+    client.get("/message")
+
+    event, = events
+    assert event["transaction"] == expected_transaction
+
+
+def test_large_json_request(sentry_init, capture_events, route, get_client):
+    sentry_init(integrations=[PyramidIntegration()])
+
+    data = {"foo": {"bar": "a" * 2000}}
+
+    @route("/")
+    def index(request):
+        assert request.json == data
+        assert request.text == json.dumps(data)
+        assert not request.POST
+        capture_message("hi")
+        return Response("ok")
+
+    events = capture_events()
+
+    client = get_client()
+    client.post("/", content_type="application/json", data=json.dumps(data))
+
+    event, = events
+    assert event["_meta"]["request"]["data"]["foo"]["bar"] == {
+        "": {"len": 2000, "rem": [["!limit", "x", 509, 512]]}
+    }
+    assert len(event["request"]["data"]["foo"]["bar"]) == 512
+
+
+def test_files_and_form(sentry_init, capture_events, route, get_client):
+    sentry_init(integrations=[PyramidIntegration()], request_bodies="always")
+
+    data = {"foo": "a" * 2000, "file": (BytesIO(b"hello"), "hello.txt")}
+
+    @route("/")
+    def index(request):
+        capture_message("hi")
+        return Response("ok")
+
+    events = capture_events()
+
+    client = get_client()
+    client.post("/", data=data)
+
+    event, = events
+    assert event["_meta"]["request"]["data"]["foo"] == {
+        "": {"len": 2000, "rem": [["!limit", "x", 509, 512]]}
+    }
+    assert len(event["request"]["data"]["foo"]) == 512
+
+    # TODO: wrong filesize reported due to usage of BytesIO. Use more realistic input
+    assert event["_meta"]["request"]["data"]["file"] == {
+        "": {"len": 0, "rem": [["!raw", "x", 0, 0]]}
+    }
+    assert not event["request"]["data"]["file"]
+
+
+def test_bad_request_not_captured(
+    sentry_init, pyramid_config, capture_events, route, get_client
+):
+    import pyramid.httpexceptions as exc
+
+    sentry_init(integrations=[PyramidIntegration()])
+    events = capture_events()
+
+    @route("/")
+    def index(request):
+        raise exc.HTTPBadRequest()
+
+    def errorhandler(exc, request):
+        return Response("bad request")
+
+    pyramid_config.add_view(errorhandler, context=exc.HTTPBadRequest)
+
+    client = get_client()
+    client.get("/")
+
+    assert not events
+
+
+def test_errorhandler(
+    sentry_init, pyramid_config, capture_exceptions, route, get_client
+):
+    sentry_init(integrations=[PyramidIntegration()])
+    errors = capture_exceptions()
+
+    @route("/")
+    def index(request):
+        raise Exception()
+
+    def errorhandler(exc, request):
+        return Response("bad request")
+
+    pyramid_config.add_view(errorhandler, context=Exception)
+
+    client = get_client()
+    client.get("/")
+
+    error, = errors
+    assert type(error) is Exception
+
+
+def test_error_in_errorhandler(
+    sentry_init, pyramid_config, capture_events, route, get_client
+):
+    sentry_init(integrations=[PyramidIntegration()])
+
+    @route("/")
+    def index(request):
+        raise ValueError()
+
+    def error_handler(err, request):
+        1 / 0
+
+    pyramid_config.add_view(error_handler, context=ValueError)
+
+    events = capture_events()
+
+    client = get_client()
+
+    with pytest.raises(ZeroDivisionError):
+        client.get("/")
+
+    event1, event2 = events
+
+    exception, = event1["exception"]["values"]
+    assert exception["type"] == "ValueError"
+
+    exception, = event2["exception"]["values"]
+    assert exception["type"] == "ZeroDivisionError"