Skip to content

Commit

Permalink
feat(sentry): no more deprecation warning for sentry_sdk 2.x; now fil…
Browse files Browse the repository at this point in the history
…l a transaction source when modify event data

Return sentry_sdk 1.x testset to quick check it still work, but recommended way is use 2.x branch
  • Loading branch information
spumer committed Oct 31, 2024
1 parent 4a72c62 commit 03cf0ea
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 13 deletions.
44 changes: 32 additions & 12 deletions fastapi_jsonrpc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,30 @@

try:
import sentry_sdk
import sentry_sdk.tracing
from sentry_sdk.utils import transaction_from_function as sentry_transaction_from_function
except ImportError:
sentry_sdk = None
sentry_transaction_from_function = None


try:
from fastapi._compat import _normalize_errors # noqa
except ImportError:
def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
return errors

if hasattr(sentry_sdk, 'new_scope'):
# sentry_sdk 2.x
sentry_new_scope = sentry_sdk.new_scope
else:
# sentry_sdk 1.x
@contextmanager
def sentry_new_scope():
hub = sentry_sdk.Hub.current
with sentry_sdk.Hub(hub) as hub:
with hub.configure_scope() as scope:
yield scope


class Params(fastapi.params.Body):
def __init__(
Expand Down Expand Up @@ -95,6 +107,7 @@ def __init__(

def component_name(name: str, module: str = None):
"""OpenAPI components must be unique by name"""

def decorator(obj):
assert issubclass(obj, BaseModel)
opts = {
Expand Down Expand Up @@ -127,6 +140,7 @@ def decorator(obj):
return components[key]
components[key] = obj
return obj

return decorator


Expand Down Expand Up @@ -598,7 +612,7 @@ async def __aenter__(self):
assert self.exit_stack is None
self.exit_stack = await AsyncExitStack().__aenter__()
if sentry_sdk is not None:
self.exit_stack.enter_context(self._fix_sentry_scope())
self.exit_stack.enter_context(self._enter_sentry_scope())
await self.exit_stack.enter_async_context(self._handle_exception(reraise=False))
self.jsonrpc_context_token = _jsonrpc_context.set(self)
return self
Expand Down Expand Up @@ -626,23 +640,29 @@ async def _handle_exception(self, reraise=True):
if self.exception is not None and self.is_unhandled_exception:
logger.exception(str(self.exception), exc_info=self.exception)

@contextmanager
def _enter_sentry_scope(self):
with sentry_new_scope() as scope:
# Actually we can use set_transaction_name
# scope.set_transaction_name(
# sentry_transaction_from_function(method_route.func),
# source=sentry_sdk.tracing.TRANSACTION_SOURCE_CUSTOM,
# )
# and we need `method_route` instance for that,
# but method_route is optional and is harder to track it than adding event processor
scope.clear_breadcrumbs()
scope.add_event_processor(self._make_sentry_event_processor())
yield scope

def _make_sentry_event_processor(self):
def event_processor(event, _):
if self.method_route is not None:
event['transaction'] = sentry_transaction_from_function(self.method_route.func)
event['transaction_info']['source'] = sentry_sdk.tracing.TRANSACTION_SOURCE_CUSTOM
return event

return event_processor

@contextmanager
def _fix_sentry_scope(self):
hub = sentry_sdk.Hub.current
with sentry_sdk.Hub(hub) as hub:
with hub.configure_scope() as scope:
scope.clear_breadcrumbs()
scope.add_event_processor(self._make_sentry_event_processor())
yield

async def enter_middlewares(self, middlewares: Sequence['JsonRpcMiddleware']):
for mw in middlewares:
cm = mw(self)
Expand Down Expand Up @@ -1388,7 +1408,7 @@ def openapi(self):
self._restore_json_schema_fine_component_names(result)

for route in self.routes:
if isinstance(route, (EntrypointRoute, MethodRoute, )):
if isinstance(route, (EntrypointRoute, MethodRoute,)):
route: Union[EntrypointRoute, MethodRoute]
for media_type in result['paths'][route.path]:
result['paths'][route.path][media_type]['responses'].pop('default', None)
Expand Down
167 changes: 167 additions & 0 deletions tests/test_sentry_sdk_1x.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Test fixtures copied from https://github.com/getsentry/sentry-python/
TODO: move integration to sentry_sdk
"""
import importlib.metadata

import pytest
import sentry_sdk
from sentry_sdk import Transport

from sentry_sdk.utils import capture_internal_exceptions


sentry_sdk_version = importlib.metadata.version('sentry_sdk')
if not sentry_sdk_version.startswith('1.'):
pytest.skip(f"Testset is only for sentry_sdk 1.x, given {sentry_sdk_version=}", allow_module_level=True)


@pytest.fixture
def probe(ep):
@ep.method()
def probe() -> str:
raise ZeroDivisionError

@ep.method()
def probe2() -> str:
raise RuntimeError

return ep


def test_transaction_is_jsonrpc_method(
probe,
json_request,
sentry_init,
capture_exceptions,
capture_events,
assert_log_errors,
):
sentry_init(send_default_pii=True)
exceptions = capture_exceptions()
events = capture_events()

# Test in batch to ensure we correctly handle multiple requests
json_request([
{
'id': 1,
'jsonrpc': '2.0',
'method': 'probe',
'params': {},
},
{
'id': 2,
'jsonrpc': '2.0',
'method': 'probe2',
'params': {},
},
])

assert {type(e) for e in exceptions} == {RuntimeError, ZeroDivisionError}

assert_log_errors(
'', pytest.raises(ZeroDivisionError),
'', pytest.raises(RuntimeError),
)

assert set([
e.get('transaction') for e in events
]) == {'test_sentry_sdk_1x.probe.<locals>.probe', 'test_sentry_sdk_1x.probe.<locals>.probe2'}


class _TestTransport(Transport):
def __init__(self, capture_event_callback, capture_envelope_callback):
Transport.__init__(self)
self.capture_event = capture_event_callback
self.capture_envelope = capture_envelope_callback
self._queue = None


@pytest.fixture
def monkeypatch_test_transport(monkeypatch):
def check_event(event):
def check_string_keys(map):
for key, value in map.items:
assert isinstance(key, str)
if isinstance(value, dict):
check_string_keys(value)

with capture_internal_exceptions():
check_string_keys(event)

def check_envelope(envelope):
with capture_internal_exceptions():
# Assert error events are sent without envelope to server, for compat.
# This does not apply if any item in the envelope is an attachment.
if not any(x.type == "attachment" for x in envelope.items):
assert not any(item.data_category == "error" for item in envelope.items)
assert not any(item.get_event() is not None for item in envelope.items)

def inner(client):
monkeypatch.setattr(
client, "transport", _TestTransport(check_event, check_envelope)
)

return inner


@pytest.fixture
def sentry_init(monkeypatch_test_transport, request):
def inner(*a, **kw):
hub = sentry_sdk.Hub.current
client = sentry_sdk.Client(*a, **kw)
hub.bind_client(client)
if "transport" not in kw:
monkeypatch_test_transport(sentry_sdk.Hub.current.client)

if request.node.get_closest_marker("forked"):
# Do not run isolation if the test is already running in
# ultimate isolation (seems to be required for celery tests that
# fork)
yield inner
else:
with sentry_sdk.Hub(None):
yield inner


@pytest.fixture
def capture_events(monkeypatch):
def inner():
events = []
test_client = sentry_sdk.Hub.current.client
old_capture_event = test_client.transport.capture_event
old_capture_envelope = test_client.transport.capture_envelope

def append_event(event):
events.append(event)
return old_capture_event(event)

def append_envelope(envelope):
for item in envelope:
if item.headers.get("type") in ("event", "transaction"):
test_client.transport.capture_event(item.payload.json)
return old_capture_envelope(envelope)

monkeypatch.setattr(test_client.transport, "capture_event", append_event)
monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope)
return events

return inner


@pytest.fixture
def capture_exceptions(monkeypatch):
def inner():
errors = set()
old_capture_event = sentry_sdk.Hub.capture_event

def capture_event(self, event, hint=None):
if hint:
if "exc_info" in hint:
error = hint["exc_info"][1]
errors.add(error)
return old_capture_event(self, event, hint=hint)

monkeypatch.setattr(sentry_sdk.Hub, "capture_event", capture_event)
return errors

return inner
8 changes: 7 additions & 1 deletion tests/test_sentry.py → tests/test_sentry_sdk_2x.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""Test fixtures copied from https://github.com/getsentry/sentry-python/
TODO: move integration to sentry_sdk
"""
import importlib.metadata

import pytest
import sentry_sdk
from sentry_sdk import Transport
from sentry_sdk.envelope import Envelope


sentry_sdk_version = importlib.metadata.version('sentry_sdk')
if not sentry_sdk_version.startswith('2.'):
pytest.skip(f"Testset is only for sentry_sdk 2.x, given {sentry_sdk_version=}", allow_module_level=True)


@pytest.fixture
def probe(ep):
@ep.method()
Expand Down Expand Up @@ -58,7 +64,7 @@ def test_transaction_is_jsonrpc_method(

assert set([
e.get('transaction') for e in events
]) == {'test_sentry.probe.<locals>.probe', 'test_sentry.probe.<locals>.probe2'}
]) == {'test_sentry_2x.probe.<locals>.probe', 'test_sentry_2x.probe.<locals>.probe2'}


class TestTransport(Transport):
Expand Down

0 comments on commit 03cf0ea

Please sign in to comment.