asgi.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. """
  2. An ASGI middleware.
  3. Based on Tom Christie's `sentry-asgi <https://github.com/encode/sentry-asgi>`.
  4. """
  5. import asyncio
  6. import inspect
  7. from copy import deepcopy
  8. from functools import partial
  9. import sentry_sdk
  10. from sentry_sdk.api import continue_trace
  11. from sentry_sdk.consts import OP
  12. from sentry_sdk.integrations._asgi_common import (
  13. _get_headers,
  14. _get_request_data,
  15. _get_url,
  16. )
  17. from sentry_sdk.integrations._wsgi_common import (
  18. DEFAULT_HTTP_METHODS_TO_CAPTURE,
  19. nullcontext,
  20. )
  21. from sentry_sdk.sessions import track_session
  22. from sentry_sdk.tracing import (
  23. SOURCE_FOR_STYLE,
  24. TransactionSource,
  25. )
  26. from sentry_sdk.utils import (
  27. ContextVar,
  28. event_from_exception,
  29. HAS_REAL_CONTEXTVARS,
  30. CONTEXTVARS_ERROR_MESSAGE,
  31. logger,
  32. transaction_from_function,
  33. _get_installed_modules,
  34. )
  35. from sentry_sdk.tracing import Transaction
  36. from typing import TYPE_CHECKING
  37. if TYPE_CHECKING:
  38. from typing import Any
  39. from typing import Dict
  40. from typing import Optional
  41. from typing import Tuple
  42. from sentry_sdk._types import Event, Hint
  43. _asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
  44. _DEFAULT_TRANSACTION_NAME = "generic ASGI request"
  45. TRANSACTION_STYLE_VALUES = ("endpoint", "url")
  46. def _capture_exception(exc: "Any", mechanism_type: str = "asgi") -> None:
  47. event, hint = event_from_exception(
  48. exc,
  49. client_options=sentry_sdk.get_client().options,
  50. mechanism={"type": mechanism_type, "handled": False},
  51. )
  52. sentry_sdk.capture_event(event, hint=hint)
  53. def _looks_like_asgi3(app: "Any") -> bool:
  54. """
  55. Try to figure out if an application object supports ASGI3.
  56. This is how uvicorn figures out the application version as well.
  57. """
  58. if inspect.isclass(app):
  59. return hasattr(app, "__await__")
  60. elif inspect.isfunction(app):
  61. return asyncio.iscoroutinefunction(app)
  62. else:
  63. call = getattr(app, "__call__", None) # noqa
  64. return asyncio.iscoroutinefunction(call)
  65. class SentryAsgiMiddleware:
  66. __slots__ = (
  67. "app",
  68. "__call__",
  69. "transaction_style",
  70. "mechanism_type",
  71. "span_origin",
  72. "http_methods_to_capture",
  73. )
  74. def __init__(
  75. self,
  76. app: "Any",
  77. unsafe_context_data: bool = False,
  78. transaction_style: str = "endpoint",
  79. mechanism_type: str = "asgi",
  80. span_origin: str = "manual",
  81. http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
  82. asgi_version: "Optional[int]" = None,
  83. ) -> None:
  84. """
  85. Instrument an ASGI application with Sentry. Provides HTTP/websocket
  86. data to sent events and basic handling for exceptions bubbling up
  87. through the middleware.
  88. :param unsafe_context_data: Disable errors when a proper contextvars installation could not be found. We do not recommend changing this from the default.
  89. """
  90. if not unsafe_context_data and not HAS_REAL_CONTEXTVARS:
  91. # We better have contextvars or we're going to leak state between
  92. # requests.
  93. raise RuntimeError(
  94. "The ASGI middleware for Sentry requires Python 3.7+ "
  95. "or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
  96. )
  97. if transaction_style not in TRANSACTION_STYLE_VALUES:
  98. raise ValueError(
  99. "Invalid value for transaction_style: %s (must be in %s)"
  100. % (transaction_style, TRANSACTION_STYLE_VALUES)
  101. )
  102. asgi_middleware_while_using_starlette_or_fastapi = (
  103. mechanism_type == "asgi" and "starlette" in _get_installed_modules()
  104. )
  105. if asgi_middleware_while_using_starlette_or_fastapi:
  106. logger.warning(
  107. "The Sentry Python SDK can now automatically support ASGI frameworks like Starlette and FastAPI. "
  108. "Please remove 'SentryAsgiMiddleware' from your project. "
  109. "See https://docs.sentry.io/platforms/python/guides/asgi/ for more information."
  110. )
  111. self.transaction_style = transaction_style
  112. self.mechanism_type = mechanism_type
  113. self.span_origin = span_origin
  114. self.app = app
  115. self.http_methods_to_capture = http_methods_to_capture
  116. if asgi_version is None:
  117. if _looks_like_asgi3(app):
  118. asgi_version = 3
  119. else:
  120. asgi_version = 2
  121. if asgi_version == 3:
  122. self.__call__ = self._run_asgi3
  123. elif asgi_version == 2:
  124. self.__call__ = self._run_asgi2 # type: ignore
  125. def _capture_lifespan_exception(self, exc: Exception) -> None:
  126. """Capture exceptions raise in application lifespan handlers.
  127. The separate function is needed to support overriding in derived integrations that use different catching mechanisms.
  128. """
  129. return _capture_exception(exc=exc, mechanism_type=self.mechanism_type)
  130. def _capture_request_exception(self, exc: Exception) -> None:
  131. """Capture exceptions raised in incoming request handlers.
  132. The separate function is needed to support overriding in derived integrations that use different catching mechanisms.
  133. """
  134. return _capture_exception(exc=exc, mechanism_type=self.mechanism_type)
  135. def _run_asgi2(self, scope: "Any") -> "Any":
  136. async def inner(receive: "Any", send: "Any") -> "Any":
  137. return await self._run_app(scope, receive, send, asgi_version=2)
  138. return inner
  139. async def _run_asgi3(self, scope: "Any", receive: "Any", send: "Any") -> "Any":
  140. return await self._run_app(scope, receive, send, asgi_version=3)
  141. async def _run_app(
  142. self, scope: "Any", receive: "Any", send: "Any", asgi_version: int
  143. ) -> "Any":
  144. is_recursive_asgi_middleware = _asgi_middleware_applied.get(False)
  145. is_lifespan = scope["type"] == "lifespan"
  146. if is_recursive_asgi_middleware or is_lifespan:
  147. try:
  148. if asgi_version == 2:
  149. return await self.app(scope)(receive, send)
  150. else:
  151. return await self.app(scope, receive, send)
  152. except Exception as exc:
  153. self._capture_lifespan_exception(exc)
  154. raise exc from None
  155. _asgi_middleware_applied.set(True)
  156. try:
  157. with sentry_sdk.isolation_scope() as sentry_scope:
  158. with track_session(sentry_scope, session_mode="request"):
  159. sentry_scope.clear_breadcrumbs()
  160. sentry_scope._name = "asgi"
  161. processor = partial(self.event_processor, asgi_scope=scope)
  162. sentry_scope.add_event_processor(processor)
  163. ty = scope["type"]
  164. (
  165. transaction_name,
  166. transaction_source,
  167. ) = self._get_transaction_name_and_source(
  168. self.transaction_style,
  169. scope,
  170. )
  171. method = scope.get("method", "").upper()
  172. transaction = None
  173. if ty in ("http", "websocket"):
  174. if ty == "websocket" or method in self.http_methods_to_capture:
  175. transaction = continue_trace(
  176. _get_headers(scope),
  177. op="{}.server".format(ty),
  178. name=transaction_name,
  179. source=transaction_source,
  180. origin=self.span_origin,
  181. )
  182. else:
  183. transaction = Transaction(
  184. op=OP.HTTP_SERVER,
  185. name=transaction_name,
  186. source=transaction_source,
  187. origin=self.span_origin,
  188. )
  189. if transaction:
  190. transaction.set_tag("asgi.type", ty)
  191. transaction_context = (
  192. sentry_sdk.start_transaction(
  193. transaction,
  194. custom_sampling_context={"asgi_scope": scope},
  195. )
  196. if transaction is not None
  197. else nullcontext()
  198. )
  199. with transaction_context:
  200. try:
  201. async def _sentry_wrapped_send(
  202. event: "Dict[str, Any]",
  203. ) -> "Any":
  204. if transaction is not None:
  205. is_http_response = (
  206. event.get("type") == "http.response.start"
  207. and "status" in event
  208. )
  209. if is_http_response:
  210. transaction.set_http_status(event["status"])
  211. return await send(event)
  212. if asgi_version == 2:
  213. return await self.app(scope)(
  214. receive, _sentry_wrapped_send
  215. )
  216. else:
  217. return await self.app(
  218. scope, receive, _sentry_wrapped_send
  219. )
  220. except Exception as exc:
  221. self._capture_request_exception(exc)
  222. raise exc from None
  223. finally:
  224. _asgi_middleware_applied.set(False)
  225. def event_processor(
  226. self, event: "Event", hint: "Hint", asgi_scope: "Any"
  227. ) -> "Optional[Event]":
  228. request_data = event.get("request", {})
  229. request_data.update(_get_request_data(asgi_scope))
  230. event["request"] = deepcopy(request_data)
  231. # Only set transaction name if not already set by Starlette or FastAPI (or other frameworks)
  232. transaction = event.get("transaction")
  233. transaction_source = (event.get("transaction_info") or {}).get("source")
  234. already_set = (
  235. transaction is not None
  236. and transaction != _DEFAULT_TRANSACTION_NAME
  237. and transaction_source
  238. in [
  239. TransactionSource.COMPONENT,
  240. TransactionSource.ROUTE,
  241. TransactionSource.CUSTOM,
  242. ]
  243. )
  244. if not already_set:
  245. name, source = self._get_transaction_name_and_source(
  246. self.transaction_style, asgi_scope
  247. )
  248. event["transaction"] = name
  249. event["transaction_info"] = {"source": source}
  250. return event
  251. # Helper functions.
  252. #
  253. # Note: Those functions are not public API. If you want to mutate request
  254. # data to your liking it's recommended to use the `before_send` callback
  255. # for that.
  256. def _get_transaction_name_and_source(
  257. self: "SentryAsgiMiddleware", transaction_style: str, asgi_scope: "Any"
  258. ) -> "Tuple[str, str]":
  259. name = None
  260. source = SOURCE_FOR_STYLE[transaction_style]
  261. ty = asgi_scope.get("type")
  262. if transaction_style == "endpoint":
  263. endpoint = asgi_scope.get("endpoint")
  264. # Webframeworks like Starlette mutate the ASGI env once routing is
  265. # done, which is sometime after the request has started. If we have
  266. # an endpoint, overwrite our generic transaction name.
  267. if endpoint:
  268. name = transaction_from_function(endpoint) or ""
  269. else:
  270. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  271. source = TransactionSource.URL
  272. elif transaction_style == "url":
  273. # FastAPI includes the route object in the scope to let Sentry extract the
  274. # path from it for the transaction name
  275. route = asgi_scope.get("route")
  276. if route:
  277. path = getattr(route, "path", None)
  278. if path is not None:
  279. name = path
  280. else:
  281. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  282. source = TransactionSource.URL
  283. if name is None:
  284. name = _DEFAULT_TRANSACTION_NAME
  285. source = TransactionSource.ROUTE
  286. return name, source
  287. return name, source