Skip to content

Commit

Permalink
feat(integrations): Support Litestar (#2413) (#3358)
Browse files Browse the repository at this point in the history
Adds support for Litestar through a new LitestarIntegration based on porting the existing StarliteIntegration.
Starlite was renamed Litestar as part of its move to version 2.0.

Closes #2413

---------

Co-authored-by: Ivana Kellyer <ivana.kellyer@sentry.io>
Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com>
Co-authored-by: Anton Pirker <anton.pirker@sentry.io>
  • Loading branch information
4 people committed Aug 12, 2024
1 parent b2546ea commit 59dd0a5
Show file tree
Hide file tree
Showing 8 changed files with 717 additions and 0 deletions.
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 @@ -432,6 +432,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 @@ -63,6 +63,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

0 comments on commit 59dd0a5

Please sign in to comment.