sanic.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. import sys
  2. import weakref
  3. from inspect import isawaitable
  4. from urllib.parse import urlsplit
  5. import sentry_sdk
  6. from sentry_sdk import continue_trace
  7. from sentry_sdk.consts import OP
  8. from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
  9. from sentry_sdk.integrations._wsgi_common import RequestExtractor, _filter_headers
  10. from sentry_sdk.integrations.logging import ignore_logger
  11. from sentry_sdk.tracing import TransactionSource
  12. from sentry_sdk.utils import (
  13. capture_internal_exceptions,
  14. ensure_integration_enabled,
  15. event_from_exception,
  16. HAS_REAL_CONTEXTVARS,
  17. CONTEXTVARS_ERROR_MESSAGE,
  18. parse_version,
  19. reraise,
  20. )
  21. from typing import TYPE_CHECKING
  22. if TYPE_CHECKING:
  23. from collections.abc import Container
  24. from typing import Any
  25. from typing import Callable
  26. from typing import Optional
  27. from typing import Union
  28. from typing import Dict
  29. from sanic.request import Request, RequestParameters
  30. from sanic.response import BaseHTTPResponse
  31. from sentry_sdk._types import Event, EventProcessor, ExcInfo, Hint
  32. from sanic.router import Route
  33. try:
  34. from sanic import Sanic, __version__ as SANIC_VERSION
  35. from sanic.exceptions import SanicException
  36. from sanic.router import Router
  37. from sanic.handlers import ErrorHandler
  38. except ImportError:
  39. raise DidNotEnable("Sanic not installed")
  40. old_error_handler_lookup = ErrorHandler.lookup
  41. old_handle_request = Sanic.handle_request
  42. old_router_get = Router.get
  43. try:
  44. # This method was introduced in Sanic v21.9
  45. old_startup = Sanic._startup
  46. except AttributeError:
  47. pass
  48. class SanicIntegration(Integration):
  49. identifier = "sanic"
  50. origin = f"auto.http.{identifier}"
  51. version = None
  52. def __init__(self, unsampled_statuses=frozenset({404})):
  53. # type: (Optional[Container[int]]) -> None
  54. """
  55. The unsampled_statuses parameter can be used to specify for which HTTP statuses the
  56. transactions should not be sent to Sentry. By default, transactions are sent for all
  57. HTTP statuses, except 404. Set unsampled_statuses to None to send transactions for all
  58. HTTP statuses, including 404.
  59. """
  60. self._unsampled_statuses = unsampled_statuses or set()
  61. @staticmethod
  62. def setup_once():
  63. # type: () -> None
  64. SanicIntegration.version = parse_version(SANIC_VERSION)
  65. _check_minimum_version(SanicIntegration, SanicIntegration.version)
  66. if not HAS_REAL_CONTEXTVARS:
  67. # We better have contextvars or we're going to leak state between
  68. # requests.
  69. raise DidNotEnable(
  70. "The sanic integration for Sentry requires Python 3.7+ "
  71. " or the aiocontextvars package." + CONTEXTVARS_ERROR_MESSAGE
  72. )
  73. if SANIC_VERSION.startswith("0.8."):
  74. # Sanic 0.8 and older creates a logger named "root" and puts a
  75. # stringified version of every exception in there (without exc_info),
  76. # which our error deduplication can't detect.
  77. #
  78. # We explicitly check the version here because it is a very
  79. # invasive step to ignore this logger and not necessary in newer
  80. # versions at all.
  81. #
  82. # https://github.com/huge-success/sanic/issues/1332
  83. ignore_logger("root")
  84. if SanicIntegration.version is not None and SanicIntegration.version < (21, 9):
  85. _setup_legacy_sanic()
  86. return
  87. _setup_sanic()
  88. class SanicRequestExtractor(RequestExtractor):
  89. def content_length(self):
  90. # type: () -> int
  91. if self.request.body is None:
  92. return 0
  93. return len(self.request.body)
  94. def cookies(self):
  95. # type: () -> Dict[str, str]
  96. return dict(self.request.cookies)
  97. def raw_data(self):
  98. # type: () -> bytes
  99. return self.request.body
  100. def form(self):
  101. # type: () -> RequestParameters
  102. return self.request.form
  103. def is_json(self):
  104. # type: () -> bool
  105. raise NotImplementedError()
  106. def json(self):
  107. # type: () -> Optional[Any]
  108. return self.request.json
  109. def files(self):
  110. # type: () -> RequestParameters
  111. return self.request.files
  112. def size_of_file(self, file):
  113. # type: (Any) -> int
  114. return len(file.body or ())
  115. def _setup_sanic():
  116. # type: () -> None
  117. Sanic._startup = _startup
  118. ErrorHandler.lookup = _sentry_error_handler_lookup
  119. def _setup_legacy_sanic():
  120. # type: () -> None
  121. Sanic.handle_request = _legacy_handle_request
  122. Router.get = _legacy_router_get
  123. ErrorHandler.lookup = _sentry_error_handler_lookup
  124. async def _startup(self):
  125. # type: (Sanic) -> None
  126. # This happens about as early in the lifecycle as possible, just after the
  127. # Request object is created. The body has not yet been consumed.
  128. self.signal("http.lifecycle.request")(_context_enter)
  129. # This happens after the handler is complete. In v21.9 this signal is not
  130. # dispatched when there is an exception. Therefore we need to close out
  131. # and call _context_exit from the custom exception handler as well.
  132. # See https://github.com/sanic-org/sanic/issues/2297
  133. self.signal("http.lifecycle.response")(_context_exit)
  134. # This happens inside of request handling immediately after the route
  135. # has been identified by the router.
  136. self.signal("http.routing.after")(_set_transaction)
  137. # The above signals need to be declared before this can be called.
  138. await old_startup(self)
  139. async def _context_enter(request):
  140. # type: (Request) -> None
  141. request.ctx._sentry_do_integration = (
  142. sentry_sdk.get_client().get_integration(SanicIntegration) is not None
  143. )
  144. if not request.ctx._sentry_do_integration:
  145. return
  146. weak_request = weakref.ref(request)
  147. request.ctx._sentry_scope = sentry_sdk.isolation_scope()
  148. scope = request.ctx._sentry_scope.__enter__()
  149. scope.clear_breadcrumbs()
  150. scope.add_event_processor(_make_request_processor(weak_request))
  151. transaction = continue_trace(
  152. dict(request.headers),
  153. op=OP.HTTP_SERVER,
  154. # Unless the request results in a 404 error, the name and source will get overwritten in _set_transaction
  155. name=request.path,
  156. source=TransactionSource.URL,
  157. origin=SanicIntegration.origin,
  158. )
  159. request.ctx._sentry_transaction = sentry_sdk.start_transaction(
  160. transaction
  161. ).__enter__()
  162. async def _context_exit(request, response=None):
  163. # type: (Request, Optional[BaseHTTPResponse]) -> None
  164. with capture_internal_exceptions():
  165. if not request.ctx._sentry_do_integration:
  166. return
  167. integration = sentry_sdk.get_client().get_integration(SanicIntegration)
  168. response_status = None if response is None else response.status
  169. # This capture_internal_exceptions block has been intentionally nested here, so that in case an exception
  170. # happens while trying to end the transaction, we still attempt to exit the hub.
  171. with capture_internal_exceptions():
  172. request.ctx._sentry_transaction.set_http_status(response_status)
  173. request.ctx._sentry_transaction.sampled &= (
  174. isinstance(integration, SanicIntegration)
  175. and response_status not in integration._unsampled_statuses
  176. )
  177. request.ctx._sentry_transaction.__exit__(None, None, None)
  178. request.ctx._sentry_scope.__exit__(None, None, None)
  179. async def _set_transaction(request, route, **_):
  180. # type: (Request, Route, **Any) -> None
  181. if request.ctx._sentry_do_integration:
  182. with capture_internal_exceptions():
  183. scope = sentry_sdk.get_current_scope()
  184. route_name = route.name.replace(request.app.name, "").strip(".")
  185. scope.set_transaction_name(route_name, source=TransactionSource.COMPONENT)
  186. def _sentry_error_handler_lookup(self, exception, *args, **kwargs):
  187. # type: (Any, Exception, *Any, **Any) -> Optional[object]
  188. _capture_exception(exception)
  189. old_error_handler = old_error_handler_lookup(self, exception, *args, **kwargs)
  190. if old_error_handler is None:
  191. return None
  192. if sentry_sdk.get_client().get_integration(SanicIntegration) is None:
  193. return old_error_handler
  194. async def sentry_wrapped_error_handler(request, exception):
  195. # type: (Request, Exception) -> Any
  196. try:
  197. response = old_error_handler(request, exception)
  198. if isawaitable(response):
  199. response = await response
  200. return response
  201. except Exception:
  202. # Report errors that occur in Sanic error handler. These
  203. # exceptions will not even show up in Sanic's
  204. # `sanic.exceptions` logger.
  205. exc_info = sys.exc_info()
  206. _capture_exception(exc_info)
  207. reraise(*exc_info)
  208. finally:
  209. # As mentioned in previous comment in _startup, this can be removed
  210. # after https://github.com/sanic-org/sanic/issues/2297 is resolved
  211. if SanicIntegration.version and SanicIntegration.version == (21, 9):
  212. await _context_exit(request)
  213. return sentry_wrapped_error_handler
  214. async def _legacy_handle_request(self, request, *args, **kwargs):
  215. # type: (Any, Request, *Any, **Any) -> Any
  216. if sentry_sdk.get_client().get_integration(SanicIntegration) is None:
  217. return await old_handle_request(self, request, *args, **kwargs)
  218. weak_request = weakref.ref(request)
  219. with sentry_sdk.isolation_scope() as scope:
  220. scope.clear_breadcrumbs()
  221. scope.add_event_processor(_make_request_processor(weak_request))
  222. response = old_handle_request(self, request, *args, **kwargs)
  223. if isawaitable(response):
  224. response = await response
  225. return response
  226. def _legacy_router_get(self, *args):
  227. # type: (Any, Union[Any, Request]) -> Any
  228. rv = old_router_get(self, *args)
  229. if sentry_sdk.get_client().get_integration(SanicIntegration) is not None:
  230. with capture_internal_exceptions():
  231. scope = sentry_sdk.get_isolation_scope()
  232. if SanicIntegration.version and SanicIntegration.version >= (21, 3):
  233. # Sanic versions above and including 21.3 append the app name to the
  234. # route name, and so we need to remove it from Route name so the
  235. # transaction name is consistent across all versions
  236. sanic_app_name = self.ctx.app.name
  237. sanic_route = rv[0].name
  238. if sanic_route.startswith("%s." % sanic_app_name):
  239. # We add a 1 to the len of the sanic_app_name because there is a dot
  240. # that joins app name and the route name
  241. # Format: app_name.route_name
  242. sanic_route = sanic_route[len(sanic_app_name) + 1 :]
  243. scope.set_transaction_name(
  244. sanic_route, source=TransactionSource.COMPONENT
  245. )
  246. else:
  247. scope.set_transaction_name(
  248. rv[0].__name__, source=TransactionSource.COMPONENT
  249. )
  250. return rv
  251. @ensure_integration_enabled(SanicIntegration)
  252. def _capture_exception(exception):
  253. # type: (Union[ExcInfo, BaseException]) -> None
  254. with capture_internal_exceptions():
  255. event, hint = event_from_exception(
  256. exception,
  257. client_options=sentry_sdk.get_client().options,
  258. mechanism={"type": "sanic", "handled": False},
  259. )
  260. if hint and hasattr(hint["exc_info"][0], "quiet") and hint["exc_info"][0].quiet:
  261. return
  262. sentry_sdk.capture_event(event, hint=hint)
  263. def _make_request_processor(weak_request):
  264. # type: (Callable[[], Request]) -> EventProcessor
  265. def sanic_processor(event, hint):
  266. # type: (Event, Optional[Hint]) -> Optional[Event]
  267. try:
  268. if hint and issubclass(hint["exc_info"][0], SanicException):
  269. return None
  270. except KeyError:
  271. pass
  272. request = weak_request()
  273. if request is None:
  274. return event
  275. with capture_internal_exceptions():
  276. extractor = SanicRequestExtractor(request)
  277. extractor.extract_into_event(event)
  278. request_info = event["request"]
  279. urlparts = urlsplit(request.url)
  280. request_info["url"] = "%s://%s%s" % (
  281. urlparts.scheme,
  282. urlparts.netloc,
  283. urlparts.path,
  284. )
  285. request_info["query_string"] = urlparts.query
  286. request_info["method"] = request.method
  287. request_info["env"] = {"REMOTE_ADDR": request.remote_addr}
  288. request_info["headers"] = _filter_headers(dict(request.headers))
  289. return event
  290. return sanic_processor