Repository: getsentry/sentry-python

Issue:
Performance instrumentation for Sanic Integration
### Problem Statement

Currently the `SanicIntegration` does not start transactions for a request-response-cycle.

### Solution Brainstorm

If Sanic is a WSGI based framework we maybe can use `SentryWsgiMiddleware` like bottle does: 
https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/bottle.py

If not we can have a look at how the low level WSGI integration creates transactions (and continues traces from incoming requests): https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/wsgi.py#L97-L115


Discussion:

Resolving patch:
diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py
--- a/sentry_sdk/integrations/sanic.py
+++ b/sentry_sdk/integrations/sanic.py
@@ -2,9 +2,11 @@
 import weakref
 from inspect import isawaitable
 
+from sentry_sdk import continue_trace
 from sentry_sdk._compat import urlparse, reraise
+from sentry_sdk.consts import OP
 from sentry_sdk.hub import Hub
-from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
+from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
 from sentry_sdk.utils import (
     capture_internal_exceptions,
     event_from_exception,
@@ -19,6 +21,7 @@
 from sentry_sdk._types import TYPE_CHECKING
 
 if TYPE_CHECKING:
+    from collections.abc import Container
     from typing import Any
     from typing import Callable
     from typing import Optional
@@ -27,6 +30,7 @@
     from typing import Dict
 
     from sanic.request import Request, RequestParameters
+    from sanic.response import BaseHTTPResponse
 
     from sentry_sdk._types import Event, EventProcessor, Hint
     from sanic.router import Route
@@ -54,6 +58,16 @@ class SanicIntegration(Integration):
     identifier = "sanic"
     version = None
 
+    def __init__(self, unsampled_statuses=frozenset({404})):
+        # type: (Optional[Container[int]]) -> None
+        """
+        The unsampled_statuses parameter can be used to specify for which HTTP statuses the
+        transactions should not be sent to Sentry. By default, transactions are sent for all
+        HTTP statuses, except 404. Set unsampled_statuses to None to send transactions for all
+        HTTP statuses, including 404.
+        """
+        self._unsampled_statuses = unsampled_statuses or set()
+
     @staticmethod
     def setup_once():
         # type: () -> None
@@ -180,16 +194,45 @@ async def _hub_enter(request):
         scope.clear_breadcrumbs()
         scope.add_event_processor(_make_request_processor(weak_request))
 
+    transaction = continue_trace(
+        dict(request.headers),
+        op=OP.HTTP_SERVER,
+        # Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
+        name=request.path,
+        source=TRANSACTION_SOURCE_URL,
+    )
+    request.ctx._sentry_transaction = request.ctx._sentry_hub.start_transaction(
+        transaction
+    ).__enter__()
+
+
+async def _hub_exit(request, response=None):
+    # type: (Request, Optional[BaseHTTPResponse]) -> None
+    with capture_internal_exceptions():
+        if not request.ctx._sentry_do_integration:
+            return
+
+        integration = Hub.current.get_integration(SanicIntegration)  # type: Integration
+
+        response_status = None if response is None else response.status
+
+        # This capture_internal_exceptions block has been intentionally nested here, so that in case an exception
+        # happens while trying to end the transaction, we still attempt to exit the hub.
+        with capture_internal_exceptions():
+            request.ctx._sentry_transaction.set_http_status(response_status)
+            request.ctx._sentry_transaction.sampled &= (
+                isinstance(integration, SanicIntegration)
+                and response_status not in integration._unsampled_statuses
+            )
+            request.ctx._sentry_transaction.__exit__(None, None, None)
 
-async def _hub_exit(request, **_):
-    # type: (Request, **Any) -> None
-    request.ctx._sentry_hub.__exit__(None, None, None)
+        request.ctx._sentry_hub.__exit__(None, None, None)
 
 
-async def _set_transaction(request, route, **kwargs):
+async def _set_transaction(request, route, **_):
     # type: (Request, Route, **Any) -> None
     hub = Hub.current
-    if hub.get_integration(SanicIntegration) is not None:
+    if request.ctx._sentry_do_integration:
         with capture_internal_exceptions():
             with hub.configure_scope() as scope:
                 route_name = route.name.replace(request.app.name, "").strip(".")


Test patch:
diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py
--- a/tests/integrations/sanic/test_sanic.py
+++ b/tests/integrations/sanic/test_sanic.py
@@ -8,12 +8,20 @@
 
 from sentry_sdk import capture_message, configure_scope
 from sentry_sdk.integrations.sanic import SanicIntegration
+from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
 
 from sanic import Sanic, request, response, __version__ as SANIC_VERSION_RAW
 from sanic.response import HTTPResponse
 from sanic.exceptions import SanicException
 
+from sentry_sdk._types import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from collections.abc import Iterable, Container
+    from typing import Any, Optional
+
 SANIC_VERSION = tuple(map(int, SANIC_VERSION_RAW.split(".")))
+PERFORMANCE_SUPPORTED = SANIC_VERSION >= (21, 9)
 
 
 @pytest.fixture
@@ -49,6 +57,10 @@ def hi_with_id(request, message_id):
         capture_message("hi with id")
         return response.text("ok with id")
 
+    @app.route("/500")
+    def fivehundred(_):
+        1 / 0
+
     return app
 
 
@@ -88,7 +100,7 @@ def test_request_data(sentry_init, app, capture_events):
         ("/message/123456", "hi_with_id", "component"),
     ],
 )
-def test_transaction(
+def test_transaction_name(
     sentry_init, app, capture_events, url, expected_transaction, expected_source
 ):
     sentry_init(integrations=[SanicIntegration()])
@@ -284,3 +296,114 @@ async def runner():
 
     with configure_scope() as scope:
         assert not scope._tags
+
+
+class TransactionTestConfig:
+    """
+    Data class to store configurations for each performance transaction test run, including
+    both the inputs and relevant expected results.
+    """
+
+    def __init__(
+        self,
+        integration_args,
+        url,
+        expected_status,
+        expected_transaction_name,
+        expected_source=None,
+    ):
+        # type: (Iterable[Optional[Container[int]]], str, int, Optional[str], Optional[str]) -> None
+        """
+        expected_transaction_name of None indicates we expect to not receive a transaction
+        """
+        self.integration_args = integration_args
+        self.url = url
+        self.expected_status = expected_status
+        self.expected_transaction_name = expected_transaction_name
+        self.expected_source = expected_source
+
+
+@pytest.mark.skipif(
+    not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
+)
+@pytest.mark.parametrize(
+    "test_config",
+    [
+        TransactionTestConfig(
+            # Transaction for successful page load
+            integration_args=(),
+            url="/message",
+            expected_status=200,
+            expected_transaction_name="hi",
+            expected_source=TRANSACTION_SOURCE_COMPONENT,
+        ),
+        TransactionTestConfig(
+            # Transaction still recorded when we have an internal server error
+            integration_args=(),
+            url="/500",
+            expected_status=500,
+            expected_transaction_name="fivehundred",
+            expected_source=TRANSACTION_SOURCE_COMPONENT,
+        ),
+        TransactionTestConfig(
+            # By default, no transaction when we have a 404 error
+            integration_args=(),
+            url="/404",
+            expected_status=404,
+            expected_transaction_name=None,
+        ),
+        TransactionTestConfig(
+            # With no ignored HTTP statuses, we should get transactions for 404 errors
+            integration_args=(None,),
+            url="/404",
+            expected_status=404,
+            expected_transaction_name="/404",
+            expected_source=TRANSACTION_SOURCE_URL,
+        ),
+        TransactionTestConfig(
+            # Transaction can be suppressed for other HTTP statuses, too, by passing config to the integration
+            integration_args=({200},),
+            url="/message",
+            expected_status=200,
+            expected_transaction_name=None,
+        ),
+    ],
+)
+def test_transactions(test_config, sentry_init, app, capture_events):
+    # type: (TransactionTestConfig, Any, Any, Any) -> None
+
+    # Init the SanicIntegration with the desired arguments
+    sentry_init(
+        integrations=[SanicIntegration(*test_config.integration_args)],
+        traces_sample_rate=1.0,
+    )
+    events = capture_events()
+
+    # Make request to the desired URL
+    _, response = app.test_client.get(test_config.url)
+    assert response.status == test_config.expected_status
+
+    # Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
+    transaction_events = [
+        e for e in events if "type" in e and e["type"] == "transaction"
+    ]
+    assert len(transaction_events) <= 1
+
+    # Get the only transaction event, or set to None if there are no transaction events.
+    (transaction_event, *_) = [*transaction_events, None]
+
+    # We should have no transaction event if and only if we expect no transactions
+    assert (transaction_event is None) == (
+        test_config.expected_transaction_name is None
+    )
+
+    # If a transaction was expected, ensure it is correct
+    assert (
+        transaction_event is None
+        or transaction_event["transaction"] == test_config.expected_transaction_name
+    )
+    assert (
+        transaction_event is None
+        or transaction_event["transaction_info"]["source"]
+        == test_config.expected_source
+    )