-
Notifications
You must be signed in to change notification settings - Fork 551
feat(integrations): Support Litestar (#2413) #3358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
antonpirker
merged 35 commits into
getsentry:master
from
KellyWalker:feature/kelly.walker/litestar-integration
Aug 6, 2024
Merged
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
76a7f75
feat(integrations): Support Litestar (#2413)
KellyWalker 05a19dc
Remove references to `pydantic` for `LitestarIntegration`
KellyWalker a26eb6e
Update tox.ini
KellyWalker 2d3c067
Update tox.ini
KellyWalker 3dbc4d2
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker e4ebb0c
Replace use of deprecated Dict and List with dict and list
KellyWalker c4a2ff7
Add comment to `setup_once` describing why we ignore the "litestar" l…
KellyWalker 7ae78f1
Replace type hints with type comments
KellyWalker 70fc674
Apply code review comments for cleaner code
KellyWalker efa5596
Use single underscore instead of double
KellyWalker ab5f548
Use ensure_integration_enabled where appropriate
KellyWalker 2ed697e
Converted type hints in local variables to type comments
KellyWalker a9dfc89
Account for older minor versions of litestar that sometimes used the …
KellyWalker 47bca15
Test for inclusion of expected route handler function name rather tha…
KellyWalker 47c7e03
Handle the fact that the earlier version supporting Python 3.12 is li…
KellyWalker 3245b0a
Apply code review comments, particularly wrt correct usage of @ensure…
KellyWalker 5ab20bc
Clean up test_middleware_spans to avoid order dependency and implemen…
KellyWalker a28f329
Use dict instead of Dict
KellyWalker 1e757c1
Merge branch 'feature/kelly.walker/litestar-integration' of https://g…
KellyWalker d2fffbb
Clean up tests to avoid order dependency
KellyWalker 442ab82
Move classes used in particular tests inside the bodies of those tests
KellyWalker 1141ce0
Remove try/catch in tests that do not expect exceptions
KellyWalker db0ef8a
Remove try/catch in tests that do not expect exceptions
KellyWalker 3589869
Use dict in a way that Python 3.8 and litestar can all agree on
KellyWalker dc5c979
Merge branch 'getsentry:master' into feature/kelly.walker/litestar-in…
KellyWalker b691b02
Added test_litestar_scope_user_on_exception_event
KellyWalker 6c5393a
Merge branch 'master' into feature/kelly.walker/litestar-integration
sentrivana 2dc2250
Remove commented out code not ported from StarliteIntegration, agreed…
KellyWalker 4f25fe1
Update sentry_sdk/integrations/litestar.py
KellyWalker 1f95040
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker cb51892
Make it so the new LitestarIntegration is not auto-enabled
KellyWalker 04c4d5e
Apply code review comments
KellyWalker 1705c51
Fix typo
KellyWalker 163bd72
Trigger CI
antonpirker dad4129
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -115,6 +115,7 @@ | |
"asgi", | ||
"bottle", | ||
"falcon", | ||
"litestar", | ||
"pyramid", | ||
"quart", | ||
"sanic", | ||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,284 @@ | ||
import sentry_sdk | ||
from sentry_sdk._types import TYPE_CHECKING | ||
from sentry_sdk.consts import OP | ||
from sentry_sdk.integrations import DidNotEnable, Integration | ||
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware | ||
from sentry_sdk.integrations.logging import ignore_logger | ||
from sentry_sdk.scope import should_send_default_pii | ||
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE | ||
from sentry_sdk.utils import ( | ||
ensure_integration_enabled, | ||
event_from_exception, | ||
transaction_from_function, | ||
) | ||
|
||
try: | ||
from litestar import Request, Litestar # type: ignore | ||
from litestar.handlers.base import BaseRouteHandler # type: ignore | ||
from litestar.middleware import DefineMiddleware # type: ignore | ||
from litestar.routes.http import HTTPRoute # type: ignore | ||
from litestar.data_extractors import ConnectionDataExtractor # type: ignore | ||
except ImportError: | ||
raise DidNotEnable("Litestar is not installed") | ||
if TYPE_CHECKING: | ||
from typing import Any, Optional, Union | ||
from litestar.types.asgi_types import ASGIApp # type: ignore | ||
from litestar.types import ( # type: ignore | ||
HTTPReceiveMessage, | ||
HTTPScope, | ||
Message, | ||
Middleware, | ||
Receive, | ||
Scope as LitestarScope, | ||
Send, | ||
WebSocketReceiveMessage, | ||
) | ||
from litestar.middleware import MiddlewareProtocol | ||
from sentry_sdk._types import Event, Hint | ||
|
||
_DEFAULT_TRANSACTION_NAME = "generic Litestar request" | ||
|
||
|
||
class LitestarIntegration(Integration): | ||
identifier = "litestar" | ||
origin = f"auto.http.{identifier}" | ||
|
||
@staticmethod | ||
def setup_once(): | ||
# type: () -> None | ||
patch_app_init() | ||
patch_middlewares() | ||
patch_http_route_handle() | ||
|
||
# The following line follows the pattern found in other integrations such as `DjangoIntegration.setup_once`. | ||
# The Litestar `ExceptionHandlerMiddleware.__call__` catches exceptions and does the following | ||
# (among other things): | ||
# 1. Logs them, some at least (such as 500s) as errors | ||
# 2. Calls after_exception hooks | ||
# The `LitestarIntegration`` provides an after_exception hook (see `patch_app_init` below) to create a Sentry event | ||
# from an exception, which ends up being called during step 2 above. However, the Sentry `LoggingIntegration` will | ||
# by default create a Sentry event from error logs made in step 1 if we do not prevent it from doing so. | ||
ignore_logger("litestar") | ||
|
||
|
||
class SentryLitestarASGIMiddleware(SentryAsgiMiddleware): | ||
def __init__(self, app, span_origin=LitestarIntegration.origin): | ||
# type: (ASGIApp, str) -> None | ||
|
||
super().__init__( | ||
app=app, | ||
unsafe_context_data=False, | ||
transaction_style="endpoint", | ||
mechanism_type="asgi", | ||
span_origin=span_origin, | ||
) | ||
|
||
|
||
def patch_app_init(): | ||
# type: () -> None | ||
""" | ||
Replaces the Litestar class's `__init__` function in order to inject `after_exception` handlers and set the | ||
`SentryLitestarASGIMiddleware` as the outmost middleware in the stack. | ||
See: | ||
- https://docs.litestar.dev/2/usage/applications.html#after-exception | ||
- https://docs.litestar.dev/2/usage/middleware/using-middleware.html | ||
""" | ||
old__init__ = Litestar.__init__ | ||
|
||
@ensure_integration_enabled(LitestarIntegration, old__init__) | ||
def injection_wrapper(self, *args, **kwargs): | ||
# type: (Litestar, *Any, **Any) -> None | ||
kwargs["after_exception"] = [ | ||
exception_handler, | ||
*(kwargs.get("after_exception") or []), | ||
] | ||
|
||
SentryLitestarASGIMiddleware.__call__ = SentryLitestarASGIMiddleware._run_asgi3 # type: ignore | ||
middleware = kwargs.get("middleware") or [] | ||
kwargs["middleware"] = [SentryLitestarASGIMiddleware, *middleware] | ||
old__init__(self, *args, **kwargs) | ||
|
||
Litestar.__init__ = injection_wrapper | ||
|
||
|
||
def patch_middlewares(): | ||
# type: () -> None | ||
old_resolve_middleware_stack = BaseRouteHandler.resolve_middleware | ||
|
||
@ensure_integration_enabled(LitestarIntegration, old_resolve_middleware_stack) | ||
def resolve_middleware_wrapper(self): | ||
# type: (BaseRouteHandler) -> list[Middleware] | ||
return [ | ||
enable_span_for_middleware(middleware) | ||
for middleware in old_resolve_middleware_stack(self) | ||
] | ||
|
||
BaseRouteHandler.resolve_middleware = resolve_middleware_wrapper | ||
|
||
|
||
def enable_span_for_middleware(middleware): | ||
# type: (Middleware) -> Middleware | ||
if ( | ||
not hasattr(middleware, "__call__") # noqa: B004 | ||
or middleware is SentryLitestarASGIMiddleware | ||
): | ||
return middleware | ||
|
||
if isinstance(middleware, DefineMiddleware): | ||
old_call = middleware.middleware.__call__ # type: ASGIApp | ||
else: | ||
old_call = middleware.__call__ | ||
|
||
async def _create_span_call(self, scope, receive, send): | ||
# type: (MiddlewareProtocol, LitestarScope, Receive, Send) -> None | ||
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: | ||
return await old_call(self, scope, receive, send) | ||
|
||
middleware_name = self.__class__.__name__ | ||
with sentry_sdk.start_span( | ||
op=OP.MIDDLEWARE_LITESTAR, | ||
description=middleware_name, | ||
origin=LitestarIntegration.origin, | ||
) as middleware_span: | ||
middleware_span.set_tag("litestar.middleware_name", middleware_name) | ||
|
||
# Creating spans for the "receive" callback | ||
async def _sentry_receive(*args, **kwargs): | ||
# type: (*Any, **Any) -> Union[HTTPReceiveMessage, WebSocketReceiveMessage] | ||
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: | ||
return await receive(*args, **kwargs) | ||
with sentry_sdk.start_span( | ||
op=OP.MIDDLEWARE_LITESTAR_RECEIVE, | ||
description=getattr(receive, "__qualname__", str(receive)), | ||
origin=LitestarIntegration.origin, | ||
) as span: | ||
span.set_tag("litestar.middleware_name", middleware_name) | ||
return await receive(*args, **kwargs) | ||
|
||
receive_name = getattr(receive, "__name__", str(receive)) | ||
receive_patched = receive_name == "_sentry_receive" | ||
KellyWalker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
new_receive = _sentry_receive if not receive_patched else receive | ||
|
||
# Creating spans for the "send" callback | ||
async def _sentry_send(message): | ||
# type: (Message) -> None | ||
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: | ||
return await send(message) | ||
with sentry_sdk.start_span( | ||
op=OP.MIDDLEWARE_LITESTAR_SEND, | ||
description=getattr(send, "__qualname__", str(send)), | ||
origin=LitestarIntegration.origin, | ||
) as span: | ||
span.set_tag("litestar.middleware_name", middleware_name) | ||
return await send(message) | ||
|
||
send_name = getattr(send, "__name__", str(send)) | ||
send_patched = send_name == "_sentry_send" | ||
KellyWalker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
new_send = _sentry_send if not send_patched else send | ||
|
||
return await old_call(self, scope, new_receive, new_send) | ||
|
||
not_yet_patched = old_call.__name__ not in ["_create_span_call"] | ||
KellyWalker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if not_yet_patched: | ||
if isinstance(middleware, DefineMiddleware): | ||
middleware.middleware.__call__ = _create_span_call | ||
else: | ||
middleware.__call__ = _create_span_call | ||
|
||
return middleware | ||
|
||
|
||
def patch_http_route_handle(): | ||
KellyWalker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# type: () -> None | ||
old_handle = HTTPRoute.handle | ||
|
||
async def handle_wrapper(self, scope, receive, send): | ||
# type: (HTTPRoute, HTTPScope, Receive, Send) -> None | ||
if sentry_sdk.get_client().get_integration(LitestarIntegration) is None: | ||
return await old_handle(self, scope, receive, send) | ||
|
||
sentry_scope = sentry_sdk.get_isolation_scope() | ||
request = scope["app"].request_class( | ||
scope=scope, receive=receive, send=send | ||
) # type: Request[Any, Any] | ||
extracted_request_data = ConnectionDataExtractor( | ||
parse_body=True, parse_query=True | ||
)(request) | ||
body = extracted_request_data.pop("body") | ||
|
||
request_data = await body | ||
|
||
def event_processor(event, _): | ||
# type: (Event, Hint) -> Event | ||
route_handler = scope.get("route_handler") | ||
|
||
request_info = event.get("request", {}) | ||
request_info["content_length"] = len(scope.get("_body", b"")) | ||
if should_send_default_pii(): | ||
request_info["cookies"] = extracted_request_data["cookies"] | ||
if request_data is not None: | ||
request_info["data"] = request_data | ||
|
||
func = None | ||
if route_handler.name is not None: | ||
tx_name = route_handler.name | ||
# Accounts for use of type `Ref` in earlier versions of litestar without the need to reference it as a type | ||
elif hasattr(route_handler.fn, "value"): | ||
func = route_handler.fn.value | ||
else: | ||
func = route_handler.fn | ||
if func is not None: | ||
tx_name = transaction_from_function(func) | ||
|
||
tx_info = {"source": SOURCE_FOR_STYLE["endpoint"]} | ||
|
||
if not tx_name: | ||
tx_name = _DEFAULT_TRANSACTION_NAME | ||
tx_info = {"source": TRANSACTION_SOURCE_ROUTE} | ||
|
||
event.update( | ||
{ | ||
"request": request_info, | ||
"transaction": tx_name, | ||
"transaction_info": tx_info, | ||
} | ||
) | ||
return event | ||
|
||
sentry_scope._name = LitestarIntegration.identifier | ||
sentry_scope.add_event_processor(event_processor) | ||
|
||
return await old_handle(self, scope, receive, send) | ||
|
||
HTTPRoute.handle = handle_wrapper | ||
|
||
|
||
def retrieve_user_from_scope(scope): | ||
# type: (LitestarScope) -> Optional[dict[str, Any]] | ||
scope_user = scope.get("user") | ||
if isinstance(scope_user, dict): | ||
return scope_user | ||
if hasattr(scope_user, "asdict"): # dataclasses | ||
return scope_user.asdict() | ||
sentrivana marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return None | ||
|
||
|
||
@ensure_integration_enabled(LitestarIntegration) | ||
def exception_handler(exc, scope): | ||
# type: (Exception, LitestarScope) -> None | ||
user_info = None # type: Optional[dict[str, Any]] | ||
if should_send_default_pii(): | ||
user_info = retrieve_user_from_scope(scope) | ||
if user_info and isinstance(user_info, dict): | ||
sentry_scope = sentry_sdk.get_isolation_scope() | ||
sentry_scope.set_user(user_info) | ||
|
||
event, hint = event_from_exception( | ||
exc, | ||
client_options=sentry_sdk.get_client().options, | ||
mechanism={"type": LitestarIntegration.identifier, "handled": False}, | ||
) | ||
|
||
sentry_sdk.capture_event(event, hint=hint) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import pytest | ||
|
||
pytest.importorskip("litestar") |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.