Skip to content

Commit 3ce2cf0

Browse files
stasspumer
authored andcommitted
feat(sentry): added explicit integration
1 parent c3176e6 commit 3ce2cf0

File tree

12 files changed

+406
-104
lines changed

12 files changed

+406
-104
lines changed

fastapi_jsonrpc/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from starlette.routing import Match, request_response, compile_path
3333
import fastapi.params
3434
import aiojobs
35+
import warnings
3536

3637
logger = logging.getLogger(__name__)
3738

@@ -53,6 +54,11 @@ def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
5354
if hasattr(sentry_sdk, 'new_scope'):
5455
# sentry_sdk 2.x
5556
sentry_new_scope = sentry_sdk.new_scope
57+
58+
def get_sentry_integration():
59+
return sentry_sdk.get_client().get_integration(
60+
"FastApiJsonRPCIntegration"
61+
)
5662
else:
5763
# sentry_sdk 1.x
5864
@contextmanager
@@ -62,6 +68,8 @@ def sentry_new_scope():
6268
with hub.configure_scope() as scope:
6369
yield scope
6470

71+
get_sentry_integration = lambda : None
72+
6573

6674
class Params(fastapi.params.Body):
6775
def __init__(
@@ -642,6 +650,14 @@ async def _handle_exception(self, reraise=True):
642650

643651
@contextmanager
644652
def _enter_sentry_scope(self):
653+
if get_sentry_integration() is not None:
654+
yield
655+
return
656+
657+
warnings.warn(
658+
"You are using implicit sentry integration. This feature might be removed in a future major release."
659+
"Use explicit `FastApiJsonRPCIntegration` with sentry-sdk 2.* instead.",
660+
)
645661
with sentry_new_scope() as scope:
646662
# Actually we can use set_transaction_name
647663
# scope.set_transaction_name(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .sentry import FastApiJsonRPCIntegration, TransactionNameGenerator, jrpc_transaction_middleware
2+
3+
__all__ = [
4+
"FastApiJsonRPCIntegration",
5+
"TransactionNameGenerator",
6+
"jrpc_transaction_middleware",
7+
]
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from .integration import FastApiJsonRPCIntegration
2+
from .jrpc import TransactionNameGenerator, jrpc_transaction_middleware
3+
4+
__all__ = [
5+
"FastApiJsonRPCIntegration",
6+
"TransactionNameGenerator",
7+
"jrpc_transaction_middleware",
8+
]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import uuid
2+
from functools import wraps
3+
from contextvars import ContextVar
4+
from starlette.requests import Request
5+
from sentry_sdk.integrations.asgi import _get_headers
6+
7+
sentry_asgi_context: ContextVar[dict] = ContextVar("_sentry_asgi_context")
8+
9+
10+
def set_shared_sentry_context(cls):
11+
original_handle_body = cls.handle_body
12+
13+
@wraps(original_handle_body)
14+
async def _patched_handle_body(self, http_request: Request, *args, **kwargs):
15+
headers = _get_headers(http_request.scope)
16+
sentry_asgi_context.set({"sampled_sentry_trace_id": uuid.uuid4(), "asgi_headers": headers})
17+
return await original_handle_body(self, http_request, *args, **kwargs)
18+
19+
cls.handle_body = _patched_handle_body
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from sentry_sdk.integrations import Integration
2+
from fastapi_jsonrpc import MethodRoute, EntrypointRoute
3+
4+
from .http import set_shared_sentry_context
5+
from .jrpc import default_transaction_name_generator, TransactionNameGenerator, prepend_jrpc_transaction_middleware
6+
7+
8+
class FastApiJsonRPCIntegration(Integration):
9+
identifier = "fastapi_jsonrpc"
10+
_already_enabled: bool = False
11+
12+
def __init__(self, transaction_name_generator: TransactionNameGenerator | None = None):
13+
self.transaction_name_generator = transaction_name_generator or default_transaction_name_generator
14+
15+
@staticmethod
16+
def setup_once():
17+
if FastApiJsonRPCIntegration._already_enabled:
18+
return
19+
20+
prepend_jrpc_transaction_middleware()
21+
set_shared_sentry_context(MethodRoute)
22+
set_shared_sentry_context(EntrypointRoute)
23+
24+
FastApiJsonRPCIntegration._already_enabled = True
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from contextlib import asynccontextmanager
2+
from random import Random
3+
from typing import Callable, TYPE_CHECKING
4+
5+
import sentry_sdk
6+
from sentry_sdk.consts import OP
7+
from sentry_sdk.tracing import SENTRY_TRACE_HEADER_NAME, Transaction
8+
from sentry_sdk.tracing_utils import normalize_incoming_data
9+
from sentry_sdk.utils import event_from_exception, is_valid_sample_rate
10+
11+
from fastapi_jsonrpc import JsonRpcContext, BaseError, Entrypoint
12+
from .http import sentry_asgi_context
13+
14+
if TYPE_CHECKING:
15+
from .integration import FastApiJsonRPCIntegration
16+
17+
_DEFAULT_TRANSACTION_NAME = "generic JRPC request"
18+
TransactionNameGenerator = Callable[[JsonRpcContext], str]
19+
20+
21+
@asynccontextmanager
22+
async def jrpc_transaction_middleware(ctx: JsonRpcContext):
23+
"""
24+
Start new transaction for each JRPC request. Applies same sampling decision for every transaction in the batch.
25+
"""
26+
27+
current_asgi_context = sentry_asgi_context.get()
28+
headers = current_asgi_context["asgi_headers"]
29+
transaction_params = dict(
30+
# this name is replaced by event processor
31+
name=_DEFAULT_TRANSACTION_NAME,
32+
op=OP.HTTP_SERVER,
33+
source=sentry_sdk.tracing.TRANSACTION_SOURCE_CUSTOM,
34+
origin="manual",
35+
)
36+
with sentry_sdk.isolation_scope() as jrpc_request_scope:
37+
jrpc_request_scope.clear()
38+
39+
if SENTRY_TRACE_HEADER_NAME in headers:
40+
# continue existing trace
41+
# https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/scope.py#L471
42+
jrpc_request_scope.generate_propagation_context(headers)
43+
transaction = JrpcTransaction.continue_from_headers(
44+
normalize_incoming_data(headers),
45+
**transaction_params,
46+
)
47+
else:
48+
# no parent transaction, start a new trace
49+
transaction = JrpcTransaction(
50+
trace_id=current_asgi_context["sampled_sentry_trace_id"].hex,
51+
**transaction_params, # type: ignore
52+
)
53+
54+
integration: FastApiJsonRPCIntegration | None = sentry_sdk.get_client().get_integration(
55+
"FastApiJsonRPCIntegration"
56+
)
57+
name_generator = integration.transaction_name_generator if integration else default_transaction_name_generator
58+
59+
with jrpc_request_scope.start_transaction(
60+
transaction,
61+
scope=jrpc_request_scope,
62+
):
63+
jrpc_request_scope.add_event_processor(
64+
make_transaction_info_event_processor(ctx, name_generator)
65+
)
66+
try:
67+
yield
68+
except Exception as exc:
69+
if isinstance(exc, BaseError):
70+
raise
71+
72+
# attaching event to current transaction
73+
event, hint = event_from_exception(
74+
exc,
75+
client_options=sentry_sdk.get_client().options,
76+
mechanism={"type": "asgi", "handled": False},
77+
)
78+
sentry_sdk.capture_event(event, hint=hint)
79+
80+
81+
class JrpcTransaction(Transaction):
82+
"""
83+
Overrides `_set_initial_sampling_decision` to apply same sampling decision for transactions with same `trace_id`.
84+
"""
85+
86+
def _set_initial_sampling_decision(self, sampling_context):
87+
super()._set_initial_sampling_decision(sampling_context)
88+
# https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/tracing.py#L1125
89+
if self.sampled or not is_valid_sample_rate(self.sample_rate, source="Tracing"):
90+
return
91+
92+
if not self.sample_rate:
93+
return
94+
95+
# https://github.com/getsentry/sentry-python/blob/2.19.2/sentry_sdk/tracing.py#L1158
96+
self.sampled = Random(self.trace_id).random() < self.sample_rate # noqa: S311
97+
98+
99+
def make_transaction_info_event_processor(ctx: JsonRpcContext, name_generator: TransactionNameGenerator) -> Callable:
100+
def _event_processor(event, _):
101+
event["transaction_info"]["source"] = sentry_sdk.tracing.TRANSACTION_SOURCE_CUSTOM
102+
if ctx.method_route is not None:
103+
event["transaction"] = name_generator(ctx)
104+
105+
return event
106+
107+
return _event_processor
108+
109+
110+
def default_transaction_name_generator(ctx: JsonRpcContext) -> str:
111+
return f"JRPC:{ctx.method_route.name}"
112+
113+
114+
def prepend_jrpc_transaction_middleware(): # noqa: C901
115+
# prepend the jrpc_sentry_transaction_middleware to the middlewares list.
116+
# we cannot patch Entrypoint _init_ directly, since objects can be created before invoking this integration
117+
118+
def _prepend_transaction_middleware(self: Entrypoint):
119+
if not hasattr(self, "__patched_middlewares__"):
120+
original_middlewares = self.__dict__.get("middlewares", [])
121+
self.__patched_middlewares__ = original_middlewares
122+
123+
# middleware was passed manually
124+
if any(middleware is jrpc_transaction_middleware for middleware in self.__patched_middlewares__):
125+
return self.__patched_middlewares__
126+
127+
self.__patched_middlewares__ = [jrpc_transaction_middleware, *self.__patched_middlewares__]
128+
return self.__patched_middlewares__
129+
130+
def _middleware_setter(self: Entrypoint, value):
131+
self.__patched_middlewares__ = value
132+
_prepend_transaction_middleware(self)
133+
134+
Entrypoint.middlewares = property(_prepend_transaction_middleware, _middleware_setter)

tests/conftest.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,11 @@
99
from _pytest.python_api import RaisesContext
1010
from starlette.testclient import TestClient
1111
import fastapi_jsonrpc as jsonrpc
12-
13-
1412
# Workaround for osx systems
1513
# https://stackoverflow.com/questions/58597334/unittest-performance-issue-when-using-requests-mock-on-osx
1614
if platform.system() == 'Darwin':
1715
import socket
1816
socket.gethostbyname = lambda x: '127.0.0.1'
19-
20-
2117
pytest_plugins = 'pytester'
2218

2319

@@ -90,21 +86,24 @@ def app_client(app):
9086

9187
@pytest.fixture
9288
def raw_request(app_client, ep_path):
93-
def requester(body, path_postfix='', auth=None):
89+
def requester(body, path_postfix='', auth=None, headers=None):
9490
resp = app_client.post(
9591
url=ep_path + path_postfix,
9692
content=body,
93+
headers=headers,
9794
auth=auth,
9895
)
9996
return resp
97+
10098
return requester
10199

102100

103101
@pytest.fixture
104102
def json_request(raw_request):
105-
def requester(data, path_postfix=''):
106-
resp = raw_request(json_dumps(data), path_postfix=path_postfix)
103+
def requester(data, path_postfix='', headers=None):
104+
resp = raw_request(json_dumps(data), path_postfix=path_postfix, headers=headers)
107105
return resp.json()
106+
108107
return requester
109108

110109

@@ -126,6 +125,7 @@ def requester(method, params, request_id=0):
126125
'method': method,
127126
'params': params,
128127
}, path_postfix=path_postfix)
128+
129129
return requester
130130

131131

tests/sentry/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)