asgi.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. """
  2. An ASGI middleware.
  3. Based on Tom Christie's `sentry-asgi <https://github.com/encode/sentry-asgi>`.
  4. """
  5. import sys
  6. import asyncio
  7. import inspect
  8. from copy import deepcopy
  9. from functools import partial
  10. import sentry_sdk
  11. from sentry_sdk.api import continue_trace
  12. from sentry_sdk.consts import OP
  13. from sentry_sdk.integrations._asgi_common import (
  14. _get_headers,
  15. _get_request_attributes,
  16. _get_request_data,
  17. _get_url,
  18. )
  19. from sentry_sdk.integrations._wsgi_common import (
  20. DEFAULT_HTTP_METHODS_TO_CAPTURE,
  21. nullcontext,
  22. )
  23. from sentry_sdk.sessions import track_session
  24. from sentry_sdk.traces import (
  25. StreamedSpan,
  26. SegmentSource,
  27. SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE,
  28. )
  29. from sentry_sdk.tracing import (
  30. SOURCE_FOR_STYLE,
  31. Transaction,
  32. TransactionSource,
  33. )
  34. from sentry_sdk.tracing_utils import has_span_streaming_enabled
  35. from sentry_sdk.utils import (
  36. ContextVar,
  37. event_from_exception,
  38. HAS_REAL_CONTEXTVARS,
  39. CONTEXTVARS_ERROR_MESSAGE,
  40. logger,
  41. transaction_from_function,
  42. _get_installed_modules,
  43. reraise,
  44. capture_internal_exceptions,
  45. qualname_from_function,
  46. )
  47. from typing import TYPE_CHECKING
  48. if TYPE_CHECKING:
  49. from typing import Any
  50. from typing import ContextManager
  51. from typing import Dict
  52. from typing import Optional
  53. from typing import Tuple
  54. from typing import Union
  55. from sentry_sdk._types import Attributes, Event, Hint
  56. from sentry_sdk.tracing import Span
  57. _asgi_middleware_applied = ContextVar("sentry_asgi_middleware_applied")
  58. _DEFAULT_TRANSACTION_NAME = "generic ASGI request"
  59. TRANSACTION_STYLE_VALUES = ("endpoint", "url")
  60. def _capture_exception(exc: "Any", mechanism_type: str = "asgi") -> None:
  61. event, hint = event_from_exception(
  62. exc,
  63. client_options=sentry_sdk.get_client().options,
  64. mechanism={"type": mechanism_type, "handled": False},
  65. )
  66. sentry_sdk.capture_event(event, hint=hint)
  67. def _looks_like_asgi3(app: "Any") -> bool:
  68. """
  69. Try to figure out if an application object supports ASGI3.
  70. This is how uvicorn figures out the application version as well.
  71. """
  72. if inspect.isclass(app):
  73. return hasattr(app, "__await__")
  74. elif inspect.isfunction(app):
  75. return asyncio.iscoroutinefunction(app)
  76. else:
  77. call = getattr(app, "__call__", None) # noqa
  78. return asyncio.iscoroutinefunction(call)
  79. class SentryAsgiMiddleware:
  80. __slots__ = (
  81. "app",
  82. "__call__",
  83. "transaction_style",
  84. "mechanism_type",
  85. "span_origin",
  86. "http_methods_to_capture",
  87. )
  88. def __init__(
  89. self,
  90. app: "Any",
  91. unsafe_context_data: bool = False,
  92. transaction_style: str = "endpoint",
  93. mechanism_type: str = "asgi",
  94. span_origin: str = "manual",
  95. http_methods_to_capture: "Tuple[str, ...]" = DEFAULT_HTTP_METHODS_TO_CAPTURE,
  96. asgi_version: "Optional[int]" = None,
  97. ) -> None:
  98. """
  99. Instrument an ASGI application with Sentry. Provides HTTP/websocket
  100. data to sent events and basic handling for exceptions bubbling up
  101. through the middleware.
  102. :param unsafe_context_data: Disable errors when a proper contextvars installation could not be found. We do not recommend changing this from the default.
  103. """
  104. if not unsafe_context_data and not HAS_REAL_CONTEXTVARS:
  105. # We better have contextvars or we're going to leak state between
  106. # requests.
  107. raise RuntimeError(
  108. "The ASGI middleware for Sentry requires Python 3.7+ "
  109. "or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
  110. )
  111. if transaction_style not in TRANSACTION_STYLE_VALUES:
  112. raise ValueError(
  113. "Invalid value for transaction_style: %s (must be in %s)"
  114. % (transaction_style, TRANSACTION_STYLE_VALUES)
  115. )
  116. asgi_middleware_while_using_starlette_or_fastapi = (
  117. mechanism_type == "asgi" and "starlette" in _get_installed_modules()
  118. )
  119. if asgi_middleware_while_using_starlette_or_fastapi:
  120. logger.warning(
  121. "The Sentry Python SDK can now automatically support ASGI frameworks like Starlette and FastAPI. "
  122. "Please remove 'SentryAsgiMiddleware' from your project. "
  123. "See https://docs.sentry.io/platforms/python/guides/asgi/ for more information."
  124. )
  125. self.transaction_style = transaction_style
  126. self.mechanism_type = mechanism_type
  127. self.span_origin = span_origin
  128. self.app = app
  129. self.http_methods_to_capture = http_methods_to_capture
  130. if asgi_version is None:
  131. if _looks_like_asgi3(app):
  132. asgi_version = 3
  133. else:
  134. asgi_version = 2
  135. if asgi_version == 3:
  136. self.__call__ = self._run_asgi3
  137. elif asgi_version == 2:
  138. self.__call__ = self._run_asgi2 # type: ignore
  139. def _capture_lifespan_exception(self, exc: Exception) -> None:
  140. """Capture exceptions raise in application lifespan handlers.
  141. The separate function is needed to support overriding in derived integrations that use different catching mechanisms.
  142. """
  143. return _capture_exception(exc=exc, mechanism_type=self.mechanism_type)
  144. def _capture_request_exception(self, exc: Exception) -> None:
  145. """Capture exceptions raised in incoming request handlers.
  146. The separate function is needed to support overriding in derived integrations that use different catching mechanisms.
  147. """
  148. return _capture_exception(exc=exc, mechanism_type=self.mechanism_type)
  149. def _run_asgi2(self, scope: "Any") -> "Any":
  150. async def inner(receive: "Any", send: "Any") -> "Any":
  151. return await self._run_app(scope, receive, send, asgi_version=2)
  152. return inner
  153. async def _run_asgi3(self, scope: "Any", receive: "Any", send: "Any") -> "Any":
  154. return await self._run_app(scope, receive, send, asgi_version=3)
  155. async def _run_app(
  156. self, scope: "Any", receive: "Any", send: "Any", asgi_version: int
  157. ) -> "Any":
  158. is_recursive_asgi_middleware = _asgi_middleware_applied.get(False)
  159. is_lifespan = scope["type"] == "lifespan"
  160. if is_recursive_asgi_middleware or is_lifespan:
  161. try:
  162. if asgi_version == 2:
  163. return await self.app(scope)(receive, send)
  164. else:
  165. return await self.app(scope, receive, send)
  166. except Exception as exc:
  167. suppress_chained_exceptions = (
  168. sentry_sdk.get_client()
  169. .options.get("_experiments", {})
  170. .get("suppress_asgi_chained_exceptions", True)
  171. )
  172. if suppress_chained_exceptions:
  173. self._capture_lifespan_exception(exc)
  174. raise exc from None
  175. exc_info = sys.exc_info()
  176. with capture_internal_exceptions():
  177. self._capture_lifespan_exception(exc)
  178. reraise(*exc_info)
  179. client = sentry_sdk.get_client()
  180. span_streaming = has_span_streaming_enabled(client.options)
  181. _asgi_middleware_applied.set(True)
  182. try:
  183. with sentry_sdk.isolation_scope() as sentry_scope:
  184. with track_session(sentry_scope, session_mode="request"):
  185. sentry_scope.clear_breadcrumbs()
  186. sentry_scope._name = "asgi"
  187. processor = partial(self.event_processor, asgi_scope=scope)
  188. sentry_scope.add_event_processor(processor)
  189. ty = scope["type"]
  190. (
  191. transaction_name,
  192. transaction_source,
  193. ) = self._get_transaction_name_and_source(
  194. self.transaction_style,
  195. scope,
  196. )
  197. method = scope.get("method", "").upper()
  198. span_ctx: "ContextManager[Union[Span, StreamedSpan, None]]"
  199. if span_streaming:
  200. segment: "Optional[StreamedSpan]" = None
  201. attributes: "Attributes" = {
  202. "sentry.span.source": getattr(
  203. transaction_source, "value", transaction_source
  204. ),
  205. "sentry.origin": self.span_origin,
  206. "network.protocol.name": ty,
  207. }
  208. if ty in ("http", "websocket"):
  209. if (
  210. ty == "websocket"
  211. or method in self.http_methods_to_capture
  212. ):
  213. sentry_sdk.traces.continue_trace(_get_headers(scope))
  214. sentry_scope.set_custom_sampling_context(
  215. {"asgi_scope": scope}
  216. )
  217. attributes["sentry.op"] = f"{ty}.server"
  218. segment = sentry_sdk.traces.start_span(
  219. name=transaction_name, attributes=attributes
  220. )
  221. else:
  222. sentry_sdk.traces.new_trace()
  223. sentry_scope.set_custom_sampling_context(
  224. {"asgi_scope": scope}
  225. )
  226. attributes["sentry.op"] = OP.HTTP_SERVER
  227. segment = sentry_sdk.traces.start_span(
  228. name=transaction_name, attributes=attributes
  229. )
  230. span_ctx = segment or nullcontext()
  231. else:
  232. transaction = None
  233. if ty in ("http", "websocket"):
  234. if (
  235. ty == "websocket"
  236. or method in self.http_methods_to_capture
  237. ):
  238. transaction = continue_trace(
  239. _get_headers(scope),
  240. op="{}.server".format(ty),
  241. name=transaction_name,
  242. source=transaction_source,
  243. origin=self.span_origin,
  244. )
  245. else:
  246. transaction = Transaction(
  247. op=OP.HTTP_SERVER,
  248. name=transaction_name,
  249. source=transaction_source,
  250. origin=self.span_origin,
  251. )
  252. if transaction:
  253. transaction.set_tag("asgi.type", ty)
  254. span_ctx = (
  255. sentry_sdk.start_transaction(
  256. transaction,
  257. custom_sampling_context={"asgi_scope": scope},
  258. )
  259. if transaction is not None
  260. else nullcontext()
  261. )
  262. with span_ctx as span:
  263. if isinstance(span, StreamedSpan):
  264. for attribute, value in _get_request_attributes(
  265. scope
  266. ).items():
  267. span.set_attribute(attribute, value)
  268. try:
  269. async def _sentry_wrapped_send(
  270. event: "Dict[str, Any]",
  271. ) -> "Any":
  272. if span is not None:
  273. is_http_response = (
  274. event.get("type") == "http.response.start"
  275. and "status" in event
  276. )
  277. if is_http_response:
  278. if isinstance(span, StreamedSpan):
  279. span.status = (
  280. "error"
  281. if event["status"] >= 400
  282. else "ok"
  283. )
  284. span.set_attribute(
  285. "http.response.status_code",
  286. event["status"],
  287. )
  288. else:
  289. span.set_http_status(event["status"])
  290. return await send(event)
  291. if asgi_version == 2:
  292. return await self.app(scope)(
  293. receive, _sentry_wrapped_send
  294. )
  295. else:
  296. return await self.app(
  297. scope, receive, _sentry_wrapped_send
  298. )
  299. except Exception as exc:
  300. suppress_chained_exceptions = (
  301. sentry_sdk.get_client()
  302. .options.get("_experiments", {})
  303. .get("suppress_asgi_chained_exceptions", True)
  304. )
  305. if suppress_chained_exceptions:
  306. self._capture_request_exception(exc)
  307. raise exc from None
  308. exc_info = sys.exc_info()
  309. with capture_internal_exceptions():
  310. self._capture_request_exception(exc)
  311. reraise(*exc_info)
  312. finally:
  313. if isinstance(span, StreamedSpan):
  314. already_set = (
  315. span is not None
  316. and span.name != _DEFAULT_TRANSACTION_NAME
  317. and span.get_attributes().get("sentry.span.source")
  318. in [
  319. SegmentSource.COMPONENT.value,
  320. SegmentSource.ROUTE.value,
  321. SegmentSource.CUSTOM.value,
  322. ]
  323. )
  324. with capture_internal_exceptions():
  325. if not already_set:
  326. name, source = (
  327. self._get_segment_name_and_source(
  328. self.transaction_style, scope
  329. )
  330. )
  331. span.name = name
  332. span.set_attribute("sentry.span.source", source)
  333. finally:
  334. _asgi_middleware_applied.set(False)
  335. def event_processor(
  336. self, event: "Event", hint: "Hint", asgi_scope: "Any"
  337. ) -> "Optional[Event]":
  338. request_data = event.get("request", {})
  339. request_data.update(_get_request_data(asgi_scope))
  340. event["request"] = deepcopy(request_data)
  341. # Only set transaction name if not already set by Starlette or FastAPI (or other frameworks)
  342. transaction = event.get("transaction")
  343. transaction_source = (event.get("transaction_info") or {}).get("source")
  344. already_set = (
  345. transaction is not None
  346. and transaction != _DEFAULT_TRANSACTION_NAME
  347. and transaction_source
  348. in [
  349. TransactionSource.COMPONENT,
  350. TransactionSource.ROUTE,
  351. TransactionSource.CUSTOM,
  352. ]
  353. )
  354. if not already_set:
  355. name, source = self._get_transaction_name_and_source(
  356. self.transaction_style, asgi_scope
  357. )
  358. event["transaction"] = name
  359. event["transaction_info"] = {"source": source}
  360. return event
  361. # Helper functions.
  362. #
  363. # Note: Those functions are not public API. If you want to mutate request
  364. # data to your liking it's recommended to use the `before_send` callback
  365. # for that.
  366. def _get_transaction_name_and_source(
  367. self: "SentryAsgiMiddleware", transaction_style: str, asgi_scope: "Any"
  368. ) -> "Tuple[str, str]":
  369. name = None
  370. source = SOURCE_FOR_STYLE[transaction_style]
  371. ty = asgi_scope.get("type")
  372. if transaction_style == "endpoint":
  373. endpoint = asgi_scope.get("endpoint")
  374. # Webframeworks like Starlette mutate the ASGI env once routing is
  375. # done, which is sometime after the request has started. If we have
  376. # an endpoint, overwrite our generic transaction name.
  377. if endpoint:
  378. name = transaction_from_function(endpoint) or ""
  379. else:
  380. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  381. source = TransactionSource.URL
  382. elif transaction_style == "url":
  383. # FastAPI includes the route object in the scope to let Sentry extract the
  384. # path from it for the transaction name
  385. route = asgi_scope.get("route")
  386. if route:
  387. path = getattr(route, "path", None)
  388. if path is not None:
  389. name = path
  390. else:
  391. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  392. source = TransactionSource.URL
  393. if name is None:
  394. name = _DEFAULT_TRANSACTION_NAME
  395. source = TransactionSource.ROUTE
  396. return name, source
  397. return name, source
  398. def _get_segment_name_and_source(
  399. self: "SentryAsgiMiddleware", segment_style: str, asgi_scope: "Any"
  400. ) -> "Tuple[str, str]":
  401. name = None
  402. source = SEGMENT_SOURCE_FOR_STYLE[segment_style].value
  403. ty = asgi_scope.get("type")
  404. if segment_style == "endpoint":
  405. endpoint = asgi_scope.get("endpoint")
  406. # Webframeworks like Starlette mutate the ASGI env once routing is
  407. # done, which is sometime after the request has started. If we have
  408. # an endpoint, overwrite our generic transaction name.
  409. if endpoint:
  410. name = qualname_from_function(endpoint) or ""
  411. else:
  412. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  413. source = SegmentSource.URL.value
  414. elif segment_style == "url":
  415. # FastAPI includes the route object in the scope to let Sentry extract the
  416. # path from it for the transaction name
  417. route = asgi_scope.get("route")
  418. if route:
  419. path = getattr(route, "path", None)
  420. if path is not None:
  421. name = path
  422. else:
  423. name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
  424. source = SegmentSource.URL.value
  425. if name is None:
  426. name = _DEFAULT_TRANSACTION_NAME
  427. source = SegmentSource.ROUTE.value
  428. return name, source
  429. return name, source