Skip to content

Commit

Permalink
feat(fastapi): add FastAPI Integration (#1495)
Browse files Browse the repository at this point in the history
Add FastAPI integration that sets transaction names and sources (the rest is done in the Starlette integration)
  • Loading branch information
antonpirker committed Jul 20, 2022
1 parent 698c24b commit 8f4db31
Show file tree
Hide file tree
Showing 10 changed files with 359 additions and 98 deletions.
2 changes: 2 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,5 @@ ignore_missing_imports = True
ignore_missing_imports = True
[mypy-starlette.*]
ignore_missing_imports = True
[mypy-fastapi.*]
ignore_missing_imports = True
9 changes: 3 additions & 6 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,13 @@
from sentry_sdk.tracing import (
SOURCE_FOR_STYLE,
TRANSACTION_SOURCE_ROUTE,
TRANSACTION_SOURCE_UNKNOWN,
)
from sentry_sdk.utils import (
ContextVar,
event_from_exception,
transaction_from_function,
HAS_REAL_CONTEXTVARS,
CONTEXTVARS_ERROR_MESSAGE,
transaction_from_function,
)
from sentry_sdk.tracing import Transaction

Expand Down Expand Up @@ -212,7 +211,6 @@ def event_processor(self, event, hint, asgi_scope):

def _set_transaction_name_and_source(self, event, transaction_style, asgi_scope):
# type: (Event, str, Any) -> None

transaction_name_already_set = (
event.get("transaction", _DEFAULT_TRANSACTION_NAME)
!= _DEFAULT_TRANSACTION_NAME
Expand Down Expand Up @@ -240,9 +238,8 @@ def _set_transaction_name_and_source(self, event, transaction_style, asgi_scope)
name = path

if not name:
# If no transaction name can be found set an unknown source.
# This can happen when ASGI frameworks that are not yet supported well are used.
event["transaction_info"] = {"source": TRANSACTION_SOURCE_UNKNOWN}
event["transaction"] = _DEFAULT_TRANSACTION_NAME
event["transaction_info"] = {"source": TRANSACTION_SOURCE_ROUTE}
return

event["transaction"] = name
Expand Down
122 changes: 122 additions & 0 deletions sentry_sdk/integrations/fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from sentry_sdk._types import MYPY
from sentry_sdk.hub import Hub
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.integrations.starlette import (
SentryStarletteMiddleware,
StarletteIntegration,
)
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE
from sentry_sdk.utils import transaction_from_function

if MYPY:
from typing import Any, Callable, Dict

from sentry_sdk._types import Event

try:
from fastapi.applications import FastAPI
from fastapi.requests import Request
except ImportError:
raise DidNotEnable("FastAPI is not installed")

try:
from starlette.types import ASGIApp, Receive, Scope, Send
except ImportError:
raise DidNotEnable("Starlette is not installed")


_DEFAULT_TRANSACTION_NAME = "generic FastApi request"


class FastApiIntegration(StarletteIntegration):
identifier = "fastapi"

@staticmethod
def setup_once():
# type: () -> None
StarletteIntegration.setup_once()
patch_middlewares()


def patch_middlewares():
# type: () -> None

old_build_middleware_stack = FastAPI.build_middleware_stack

def _sentry_build_middleware_stack(self):
# type: (FastAPI) -> Callable[..., Any]
"""
Adds `SentryStarletteMiddleware` and `SentryFastApiMiddleware` to the
middleware stack of the FastAPI application.
"""
app = old_build_middleware_stack(self)
app = SentryStarletteMiddleware(app=app)
app = SentryFastApiMiddleware(app=app)
return app

FastAPI.build_middleware_stack = _sentry_build_middleware_stack


def _set_transaction_name_and_source(event, transaction_style, request):
# type: (Event, str, Any) -> None
name = ""

if transaction_style == "endpoint":
endpoint = request.scope.get("endpoint")
if endpoint:
name = transaction_from_function(endpoint) or ""

elif transaction_style == "url":
route = request.scope.get("route")
if route:
path = getattr(route, "path", None)
if path is not None:
name = path

if not name:
event["transaction"] = _DEFAULT_TRANSACTION_NAME
event["transaction_info"] = {"source": TRANSACTION_SOURCE_ROUTE}
return

event["transaction"] = name
event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}


class SentryFastApiMiddleware:
def __init__(self, app, dispatch=None):
# type: (ASGIApp, Any) -> None
self.app = app

async def __call__(self, scope, receive, send):
# type: (Scope, Receive, Send) -> Any
if scope["type"] != "http":
await self.app(scope, receive, send)
return

hub = Hub.current
integration = hub.get_integration(FastApiIntegration)
if integration is None:
return

with hub.configure_scope() as sentry_scope:
request = Request(scope, receive=receive, send=send)

def _make_request_event_processor(req, integration):
# type: (Any, Any) -> Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]]
def event_processor(event, hint):
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]

_set_transaction_name_and_source(
event, integration.transaction_style, req
)

return event

return event_processor

sentry_scope._name = FastApiIntegration.identifier
sentry_scope.add_event_processor(
_make_request_event_processor(request, integration)
)

await self.app(scope, receive, send)
63 changes: 32 additions & 31 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import absolute_import


from sentry_sdk._compat import iteritems
from sentry_sdk._types import MYPY
from sentry_sdk.hub import Hub, _should_send_default_pii
Expand All @@ -9,19 +10,18 @@
request_body_within_bounds,
)
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
from sentry_sdk.tracing import SOURCE_FOR_STYLE
from sentry_sdk.utils import (
TRANSACTION_SOURCE_COMPONENT,
TRANSACTION_SOURCE_ROUTE,
TRANSACTION_SOURCE_UNKNOWN,
AnnotatedValue,
event_from_exception,
transaction_from_function,
)

if MYPY:
from sentry_sdk._types import Event
from typing import Any, Awaitable, Callable, Dict, Optional, Union

from sentry_sdk._types import Event

try:
from starlette.applications import Starlette
Expand All @@ -30,6 +30,7 @@
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.requests import Request
from starlette.routing import Match
from starlette.types import ASGIApp, Receive, Scope, Send
except ImportError:
raise DidNotEnable("Starlette is not installed")

Expand All @@ -39,6 +40,8 @@
from starlette.exceptions import ExceptionMiddleware # Startlette 0.19.1


_DEFAULT_TRANSACTION_NAME = "generic Starlette request"

TRANSACTION_STYLE_VALUES = ("endpoint", "url")


Expand Down Expand Up @@ -231,7 +234,7 @@ def _sentry_middleware_init(self, cls, **options):
old_build_middleware_stack = Starlette.build_middleware_stack

def _sentry_build_middleware_stack(self):
# type: (Callable[..., Any]) -> Callable[..., Any]
# type: (Starlette) -> Callable[..., Any]
"""
Adds `SentryStarletteMiddleware` to the
middleware stack of the Starlette application.
Expand All @@ -251,7 +254,7 @@ def patch_asgi_app():
old_app = Starlette.__call__

async def _sentry_patched_asgi_app(self, scope, receive, send):
# type: (Dict[str, Any], Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> None
# type: (Starlette, Scope, Receive, Send) -> None
if Hub.current.get_integration(StarletteIntegration) is None:
return await old_app(self, scope, receive, send)

Expand Down Expand Up @@ -374,45 +377,43 @@ async def parsed_body(self):


def _set_transaction_name_and_source(event, transaction_style, request):
# type: (Event, str, Any) -> Event
if "router" not in request.scope:
return event

# type: (Event, str, Any) -> None
name = ""

router = request.scope["router"]
for route in router.routes:
match = route.matches(request.scope)
if transaction_style == "endpoint":
endpoint = request.scope.get("endpoint")
if endpoint:
name = transaction_from_function(endpoint) or ""

if match[0] == Match.FULL:
if transaction_style == "endpoint":
name = transaction_from_function(match[1]["endpoint"]) or ""
elif transaction_style == "url":
name = route.path
elif transaction_style == "url":
router = request.scope["router"]
for route in router.routes:
match = route.matches(request.scope)

if not name:
event["transaction"] = "generic Starlette request"
event["transaction_info"] = {"source": TRANSACTION_SOURCE_UNKNOWN}
return event
if match[0] == Match.FULL:
if transaction_style == "endpoint":
name = transaction_from_function(match[1]["endpoint"]) or ""
break
elif transaction_style == "url":
name = route.path
break

source_for_style = {
"url": TRANSACTION_SOURCE_ROUTE,
"endpoint": TRANSACTION_SOURCE_COMPONENT,
}
if not name:
event["transaction"] = _DEFAULT_TRANSACTION_NAME
event["transaction_info"] = {"source": TRANSACTION_SOURCE_ROUTE}
return

event["transaction"] = name
event["transaction_info"] = {"source": source_for_style[transaction_style]}

return event
event["transaction_info"] = {"source": SOURCE_FOR_STYLE[transaction_style]}


class SentryStarletteMiddleware:
def __init__(self, app, dispatch=None):
# type: (SentryStarletteMiddleware, Any) -> None
# type: (ASGIApp, Any) -> None
self.app = app

async def __call__(self, scope, receive, send):
# type: (SentryStarletteMiddleware, Dict[str, Any], Callable[[], Awaitable[Dict[str, Any]]], Callable[[Dict[str, Any]], Awaitable[None]]) -> Any
# type: (Scope, Receive, Send) -> Any
if scope["type"] != "http":
await self.app(scope, receive, send)
return
Expand Down Expand Up @@ -442,7 +443,7 @@ def event_processor(event, hint):
request_info["data"] = info["data"]
event["request"] = request_info

event = _set_transaction_name_and_source(
_set_transaction_name_and_source(
event, integration.transaction_style, req
)

Expand Down
6 changes: 3 additions & 3 deletions tests/integrations/asgi/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ def kangaroo_handler(request):
"/sync-message",
"url",
"generic ASGI request", # the AsgiMiddleware can not extract routes from the Starlette framework used here for testing.
"unknown",
"route",
),
(
"/sync-message/123456",
Expand All @@ -282,7 +282,7 @@ def kangaroo_handler(request):
"/sync-message/123456",
"url",
"generic ASGI request", # the AsgiMiddleware can not extract routes from the Starlette framework used here for testing.
"unknown",
"route",
),
(
"/async-message",
Expand All @@ -294,7 +294,7 @@ def kangaroo_handler(request):
"/async-message",
"url",
"generic ASGI request", # the AsgiMiddleware can not extract routes from the Starlette framework used here for testing.
"unknown",
"route",
),
],
)
Expand Down
48 changes: 0 additions & 48 deletions tests/integrations/asgi/test_fastapi.py

This file was deleted.

3 changes: 3 additions & 0 deletions tests/integrations/fastapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import pytest

pytest.importorskip("fastapi")

0 comments on commit 8f4db31

Please sign in to comment.