Skip to content

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
merged 35 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
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 Jul 26, 2024
05a19dc
Remove references to `pydantic` for `LitestarIntegration`
KellyWalker Jul 26, 2024
a26eb6e
Update tox.ini
KellyWalker Jul 29, 2024
2d3c067
Update tox.ini
KellyWalker Jul 29, 2024
3dbc4d2
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker Jul 29, 2024
e4ebb0c
Replace use of deprecated Dict and List with dict and list
KellyWalker Jul 29, 2024
c4a2ff7
Add comment to `setup_once` describing why we ignore the "litestar" l…
KellyWalker Jul 29, 2024
7ae78f1
Replace type hints with type comments
KellyWalker Jul 29, 2024
70fc674
Apply code review comments for cleaner code
KellyWalker Jul 29, 2024
efa5596
Use single underscore instead of double
KellyWalker Jul 29, 2024
ab5f548
Use ensure_integration_enabled where appropriate
KellyWalker Jul 29, 2024
2ed697e
Converted type hints in local variables to type comments
KellyWalker Jul 29, 2024
a9dfc89
Account for older minor versions of litestar that sometimes used the …
KellyWalker Jul 29, 2024
47bca15
Test for inclusion of expected route handler function name rather tha…
KellyWalker Jul 29, 2024
47c7e03
Handle the fact that the earlier version supporting Python 3.12 is li…
KellyWalker Jul 29, 2024
3245b0a
Apply code review comments, particularly wrt correct usage of @ensure…
KellyWalker Jul 30, 2024
5ab20bc
Clean up test_middleware_spans to avoid order dependency and implemen…
KellyWalker Jul 30, 2024
a28f329
Use dict instead of Dict
KellyWalker Jul 30, 2024
1e757c1
Merge branch 'feature/kelly.walker/litestar-integration' of https://g…
KellyWalker Jul 30, 2024
d2fffbb
Clean up tests to avoid order dependency
KellyWalker Jul 30, 2024
442ab82
Move classes used in particular tests inside the bodies of those tests
KellyWalker Jul 30, 2024
1141ce0
Remove try/catch in tests that do not expect exceptions
KellyWalker Jul 30, 2024
db0ef8a
Remove try/catch in tests that do not expect exceptions
KellyWalker Jul 30, 2024
3589869
Use dict in a way that Python 3.8 and litestar can all agree on
KellyWalker Jul 30, 2024
dc5c979
Merge branch 'getsentry:master' into feature/kelly.walker/litestar-in…
KellyWalker Jul 30, 2024
b691b02
Added test_litestar_scope_user_on_exception_event
KellyWalker Jul 30, 2024
6c5393a
Merge branch 'master' into feature/kelly.walker/litestar-integration
sentrivana Jul 31, 2024
2dc2250
Remove commented out code not ported from StarliteIntegration, agreed…
KellyWalker Jul 31, 2024
4f25fe1
Update sentry_sdk/integrations/litestar.py
KellyWalker Jul 31, 2024
1f95040
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker Jul 31, 2024
cb51892
Make it so the new LitestarIntegration is not auto-enabled
KellyWalker Jul 31, 2024
04c4d5e
Apply code review comments
KellyWalker Jul 31, 2024
1705c51
Fix typo
KellyWalker Jul 31, 2024
163bd72
Trigger CI
antonpirker Aug 1, 2024
dad4129
Merge branch 'master' into feature/kelly.walker/litestar-integration
KellyWalker Aug 2, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/test-integrations-web-frameworks-2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-falcon-latest"
- name: Test litestar latest
run: |
set -x # print commands that are executed
./scripts/runtox.sh "py${{ matrix.python-version }}-litestar-latest"
- name: Test pyramid latest
run: |
set -x # print commands that are executed
Expand Down Expand Up @@ -137,6 +141,10 @@ jobs:
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-falcon"
- name: Test litestar pinned
run: |
set -x # print commands that are executed
./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-litestar"
- name: Test pyramid pinned
run: |
set -x # print commands that are executed
Expand Down
1 change: 1 addition & 0 deletions scripts/split-tox-gh-actions/split-tox-gh-actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"asgi",
"bottle",
"falcon",
"litestar",
"pyramid",
"quart",
"sanic",
Expand Down
3 changes: 3 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ class OP:
HTTP_CLIENT_STREAM = "http.client.stream"
HTTP_SERVER = "http.server"
MIDDLEWARE_DJANGO = "middleware.django"
MIDDLEWARE_LITESTAR = "middleware.litestar"
MIDDLEWARE_LITESTAR_RECEIVE = "middleware.litestar.receive"
MIDDLEWARE_LITESTAR_SEND = "middleware.litestar.send"
MIDDLEWARE_STARLETTE = "middleware.starlette"
MIDDLEWARE_STARLETTE_RECEIVE = "middleware.starlette.receive"
MIDDLEWARE_STARLETTE_SEND = "middleware.starlette.send"
Expand Down
284 changes: 284 additions & 0 deletions sentry_sdk/integrations/litestar.py
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"
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"
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"]

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():
# 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()

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)
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def get_file_text(file_name):
"huey": ["huey>=2"],
"huggingface_hub": ["huggingface_hub>=0.22"],
"langchain": ["langchain>=0.0.210"],
"litestar": ["litestar>=2.0.0"],
"loguru": ["loguru>=0.5"],
"openai": ["openai>=1.0.0", "tiktoken>=0.3.0"],
"opentelemetry": ["opentelemetry-distro>=0.35b0"],
Expand Down
3 changes: 3 additions & 0 deletions tests/integrations/litestar/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("litestar")
Loading
Loading