Skip to content

Commit 0452535

Browse files
Sanic integration initial version (#2419)
* Sanic integration initial version * Errors in trace now * Address review feedback * By default, no transactions for 404 status * Removed commented-out code * Make default statuses frozen * Change back to original transaction naming * Test latest Sanic version * Sanic integration unit tests * Assert at most one transaction * Tox.ini updates * Allow no response to _hub_exit
1 parent 243023a commit 0452535

File tree

3 files changed

+182
-7
lines changed

3 files changed

+182
-7
lines changed

sentry_sdk/integrations/sanic.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import weakref
33
from inspect import isawaitable
44

5+
from sentry_sdk import continue_trace
56
from sentry_sdk._compat import urlparse, reraise
7+
from sentry_sdk.consts import OP
68
from sentry_sdk.hub import Hub
7-
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT
9+
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
810
from sentry_sdk.utils import (
911
capture_internal_exceptions,
1012
event_from_exception,
@@ -19,6 +21,7 @@
1921
from sentry_sdk._types import TYPE_CHECKING
2022

2123
if TYPE_CHECKING:
24+
from collections.abc import Container
2225
from typing import Any
2326
from typing import Callable
2427
from typing import Optional
@@ -27,6 +30,7 @@
2730
from typing import Dict
2831

2932
from sanic.request import Request, RequestParameters
33+
from sanic.response import BaseHTTPResponse
3034

3135
from sentry_sdk._types import Event, EventProcessor, Hint
3236
from sanic.router import Route
@@ -54,6 +58,16 @@ class SanicIntegration(Integration):
5458
identifier = "sanic"
5559
version = None
5660

61+
def __init__(self, unsampled_statuses=frozenset({404})):
62+
# type: (Optional[Container[int]]) -> None
63+
"""
64+
The unsampled_statuses parameter can be used to specify for which HTTP statuses the
65+
transactions should not be sent to Sentry. By default, transactions are sent for all
66+
HTTP statuses, except 404. Set unsampled_statuses to None to send transactions for all
67+
HTTP statuses, including 404.
68+
"""
69+
self._unsampled_statuses = unsampled_statuses or set()
70+
5771
@staticmethod
5872
def setup_once():
5973
# type: () -> None
@@ -180,16 +194,45 @@ async def _hub_enter(request):
180194
scope.clear_breadcrumbs()
181195
scope.add_event_processor(_make_request_processor(weak_request))
182196

197+
transaction = continue_trace(
198+
dict(request.headers),
199+
op=OP.HTTP_SERVER,
200+
# Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
201+
name=request.path,
202+
source=TRANSACTION_SOURCE_URL,
203+
)
204+
request.ctx._sentry_transaction = request.ctx._sentry_hub.start_transaction(
205+
transaction
206+
).__enter__()
207+
208+
209+
async def _hub_exit(request, response=None):
210+
# type: (Request, Optional[BaseHTTPResponse]) -> None
211+
with capture_internal_exceptions():
212+
if not request.ctx._sentry_do_integration:
213+
return
214+
215+
integration = Hub.current.get_integration(SanicIntegration) # type: Integration
216+
217+
response_status = None if response is None else response.status
218+
219+
# This capture_internal_exceptions block has been intentionally nested here, so that in case an exception
220+
# happens while trying to end the transaction, we still attempt to exit the hub.
221+
with capture_internal_exceptions():
222+
request.ctx._sentry_transaction.set_http_status(response_status)
223+
request.ctx._sentry_transaction.sampled &= (
224+
isinstance(integration, SanicIntegration)
225+
and response_status not in integration._unsampled_statuses
226+
)
227+
request.ctx._sentry_transaction.__exit__(None, None, None)
183228

184-
async def _hub_exit(request, **_):
185-
# type: (Request, **Any) -> None
186-
request.ctx._sentry_hub.__exit__(None, None, None)
229+
request.ctx._sentry_hub.__exit__(None, None, None)
187230

188231

189-
async def _set_transaction(request, route, **kwargs):
232+
async def _set_transaction(request, route, **_):
190233
# type: (Request, Route, **Any) -> None
191234
hub = Hub.current
192-
if hub.get_integration(SanicIntegration) is not None:
235+
if request.ctx._sentry_do_integration:
193236
with capture_internal_exceptions():
194237
with hub.configure_scope() as scope:
195238
route_name = route.name.replace(request.app.name, "").strip(".")

tests/integrations/sanic/test_sanic.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88

99
from sentry_sdk import capture_message, configure_scope
1010
from sentry_sdk.integrations.sanic import SanicIntegration
11+
from sentry_sdk.tracing import TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_URL
1112

1213
from sanic import Sanic, request, response, __version__ as SANIC_VERSION_RAW
1314
from sanic.response import HTTPResponse
1415
from sanic.exceptions import SanicException
1516

17+
from sentry_sdk._types import TYPE_CHECKING
18+
19+
if TYPE_CHECKING:
20+
from collections.abc import Iterable, Container
21+
from typing import Any, Optional
22+
1623
SANIC_VERSION = tuple(map(int, SANIC_VERSION_RAW.split(".")))
24+
PERFORMANCE_SUPPORTED = SANIC_VERSION >= (21, 9)
1725

1826

1927
@pytest.fixture
@@ -49,6 +57,10 @@ def hi_with_id(request, message_id):
4957
capture_message("hi with id")
5058
return response.text("ok with id")
5159

60+
@app.route("/500")
61+
def fivehundred(_):
62+
1 / 0
63+
5264
return app
5365

5466

@@ -88,7 +100,7 @@ def test_request_data(sentry_init, app, capture_events):
88100
("/message/123456", "hi_with_id", "component"),
89101
],
90102
)
91-
def test_transaction(
103+
def test_transaction_name(
92104
sentry_init, app, capture_events, url, expected_transaction, expected_source
93105
):
94106
sentry_init(integrations=[SanicIntegration()])
@@ -284,3 +296,114 @@ async def runner():
284296

285297
with configure_scope() as scope:
286298
assert not scope._tags
299+
300+
301+
class TransactionTestConfig:
302+
"""
303+
Data class to store configurations for each performance transaction test run, including
304+
both the inputs and relevant expected results.
305+
"""
306+
307+
def __init__(
308+
self,
309+
integration_args,
310+
url,
311+
expected_status,
312+
expected_transaction_name,
313+
expected_source=None,
314+
):
315+
# type: (Iterable[Optional[Container[int]]], str, int, Optional[str], Optional[str]) -> None
316+
"""
317+
expected_transaction_name of None indicates we expect to not receive a transaction
318+
"""
319+
self.integration_args = integration_args
320+
self.url = url
321+
self.expected_status = expected_status
322+
self.expected_transaction_name = expected_transaction_name
323+
self.expected_source = expected_source
324+
325+
326+
@pytest.mark.skipif(
327+
not PERFORMANCE_SUPPORTED, reason="Performance not supported on this Sanic version"
328+
)
329+
@pytest.mark.parametrize(
330+
"test_config",
331+
[
332+
TransactionTestConfig(
333+
# Transaction for successful page load
334+
integration_args=(),
335+
url="/message",
336+
expected_status=200,
337+
expected_transaction_name="hi",
338+
expected_source=TRANSACTION_SOURCE_COMPONENT,
339+
),
340+
TransactionTestConfig(
341+
# Transaction still recorded when we have an internal server error
342+
integration_args=(),
343+
url="/500",
344+
expected_status=500,
345+
expected_transaction_name="fivehundred",
346+
expected_source=TRANSACTION_SOURCE_COMPONENT,
347+
),
348+
TransactionTestConfig(
349+
# By default, no transaction when we have a 404 error
350+
integration_args=(),
351+
url="/404",
352+
expected_status=404,
353+
expected_transaction_name=None,
354+
),
355+
TransactionTestConfig(
356+
# With no ignored HTTP statuses, we should get transactions for 404 errors
357+
integration_args=(None,),
358+
url="/404",
359+
expected_status=404,
360+
expected_transaction_name="/404",
361+
expected_source=TRANSACTION_SOURCE_URL,
362+
),
363+
TransactionTestConfig(
364+
# Transaction can be suppressed for other HTTP statuses, too, by passing config to the integration
365+
integration_args=({200},),
366+
url="/message",
367+
expected_status=200,
368+
expected_transaction_name=None,
369+
),
370+
],
371+
)
372+
def test_transactions(test_config, sentry_init, app, capture_events):
373+
# type: (TransactionTestConfig, Any, Any, Any) -> None
374+
375+
# Init the SanicIntegration with the desired arguments
376+
sentry_init(
377+
integrations=[SanicIntegration(*test_config.integration_args)],
378+
traces_sample_rate=1.0,
379+
)
380+
events = capture_events()
381+
382+
# Make request to the desired URL
383+
_, response = app.test_client.get(test_config.url)
384+
assert response.status == test_config.expected_status
385+
386+
# Extract the transaction events by inspecting the event types. We should at most have 1 transaction event.
387+
transaction_events = [
388+
e for e in events if "type" in e and e["type"] == "transaction"
389+
]
390+
assert len(transaction_events) <= 1
391+
392+
# Get the only transaction event, or set to None if there are no transaction events.
393+
(transaction_event, *_) = [*transaction_events, None]
394+
395+
# We should have no transaction event if and only if we expect no transactions
396+
assert (transaction_event is None) == (
397+
test_config.expected_transaction_name is None
398+
)
399+
400+
# If a transaction was expected, ensure it is correct
401+
assert (
402+
transaction_event is None
403+
or transaction_event["transaction"] == test_config.expected_transaction_name
404+
)
405+
assert (
406+
transaction_event is None
407+
or transaction_event["transaction_info"]["source"]
408+
== test_config.expected_source
409+
)

tox.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ envlist =
155155
{py3.6,py3.7,py3.8}-sanic-v{20}
156156
{py3.7,py3.8,py3.9,py3.10,py3.11}-sanic-v{21}
157157
{py3.7,py3.8,py3.9,py3.10,py3.11}-sanic-v{22}
158+
{py3.8,py3.9,py3.10,py3.11}-sanic-latest
158159

159160
# Starlette
160161
{py3.7,py3.8,py3.9,py3.10,py3.11}-starlette-v{0.20,0.22,0.24,0.26,0.28}
@@ -452,10 +453,18 @@ deps =
452453
sanic-v21: sanic>=21.0,<22.0
453454
sanic-v22: sanic>=22.0,<22.9.0
454455

456+
# Sanic is not using semver, so here we check the current latest version of Sanic. When this test breaks, we should
457+
# determine whether it is because we need to fix something in our integration, or whether Sanic has simply dropped
458+
# support for an older Python version. If Sanic has dropped support for an older python version, we should add a new
459+
# line above to test for the newest Sanic version still supporting the old Python version, and we should update the
460+
# line below so we test the latest Sanic version only using the Python versions that are supported.
461+
sanic-latest: sanic>=23.6
462+
455463
sanic: websockets<11.0
456464
sanic: aiohttp
457465
sanic-v21: sanic_testing<22
458466
sanic-v22: sanic_testing<22.9.0
467+
sanic-latest: sanic_testing>=23.6
459468
{py3.5,py3.6}-sanic: aiocontextvars==0.2.1
460469
{py3.5}-sanic: ujson<4
461470

0 commit comments

Comments
 (0)