You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

243 line
8.5 KiB

  1. import io
  2. import logging
  3. import os
  4. import urllib.parse
  5. import urllib.request
  6. import urllib.error
  7. import urllib3
  8. import sys
  9. from itertools import chain, product
  10. from typing import TYPE_CHECKING
  11. if TYPE_CHECKING:
  12. from typing import Any
  13. from typing import Callable
  14. from typing import Dict
  15. from typing import Optional
  16. from typing import Self
  17. from sentry_sdk.utils import (
  18. logger as sentry_logger,
  19. env_to_bool,
  20. capture_internal_exceptions,
  21. )
  22. from sentry_sdk.envelope import Envelope
  23. logger = logging.getLogger("spotlight")
  24. DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
  25. DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware"
  26. class SpotlightClient:
  27. def __init__(self, url):
  28. # type: (str) -> None
  29. self.url = url
  30. self.http = urllib3.PoolManager()
  31. self.fails = 0
  32. def capture_envelope(self, envelope):
  33. # type: (Envelope) -> None
  34. body = io.BytesIO()
  35. envelope.serialize_into(body)
  36. try:
  37. req = self.http.request(
  38. url=self.url,
  39. body=body.getvalue(),
  40. method="POST",
  41. headers={
  42. "Content-Type": "application/x-sentry-envelope",
  43. },
  44. )
  45. req.close()
  46. self.fails = 0
  47. except Exception as e:
  48. if self.fails < 2:
  49. sentry_logger.warning(str(e))
  50. self.fails += 1
  51. elif self.fails == 2:
  52. self.fails += 1
  53. sentry_logger.warning(
  54. "Looks like Spotlight is not running, will keep trying to send events but will not log errors."
  55. )
  56. # omitting self.fails += 1 in the `else:` case intentionally
  57. # to avoid overflowing the variable if Spotlight never becomes reachable
  58. try:
  59. from django.utils.deprecation import MiddlewareMixin
  60. from django.http import HttpResponseServerError, HttpResponse, HttpRequest
  61. from django.conf import settings
  62. SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js"
  63. SPOTLIGHT_JS_SNIPPET_PATTERN = (
  64. "<script>window.__spotlight = {{ initOptions: {{ sidecarUrl: '{spotlight_url}', fullPage: false }} }};</script>\n"
  65. '<script type="module" crossorigin src="{spotlight_js_url}"></script>\n'
  66. )
  67. SPOTLIGHT_ERROR_PAGE_SNIPPET = (
  68. '<html><base href="{spotlight_url}">\n'
  69. '<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\n'
  70. )
  71. CHARSET_PREFIX = "charset="
  72. BODY_TAG_NAME = "body"
  73. BODY_CLOSE_TAG_POSSIBILITIES = tuple(
  74. "</{}>".format("".join(chars))
  75. for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower()))
  76. )
  77. class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc]
  78. _spotlight_script = None # type: Optional[str]
  79. _spotlight_url = None # type: Optional[str]
  80. def __init__(self, get_response):
  81. # type: (Self, Callable[..., HttpResponse]) -> None
  82. super().__init__(get_response)
  83. import sentry_sdk.api
  84. self.sentry_sdk = sentry_sdk.api
  85. spotlight_client = self.sentry_sdk.get_client().spotlight
  86. if spotlight_client is None:
  87. sentry_logger.warning(
  88. "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
  89. )
  90. return None
  91. # Spotlight URL has a trailing `/stream` part at the end so split it off
  92. self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../")
  93. @property
  94. def spotlight_script(self):
  95. # type: (Self) -> Optional[str]
  96. if self._spotlight_url is not None and self._spotlight_script is None:
  97. try:
  98. spotlight_js_url = urllib.parse.urljoin(
  99. self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH
  100. )
  101. req = urllib.request.Request(
  102. spotlight_js_url,
  103. method="HEAD",
  104. )
  105. urllib.request.urlopen(req)
  106. self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format(
  107. spotlight_url=self._spotlight_url,
  108. spotlight_js_url=spotlight_js_url,
  109. )
  110. except urllib.error.URLError as err:
  111. sentry_logger.debug(
  112. "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.",
  113. spotlight_js_url,
  114. exc_info=err,
  115. )
  116. return self._spotlight_script
  117. def process_response(self, _request, response):
  118. # type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse]
  119. content_type_header = tuple(
  120. p.strip()
  121. for p in response.headers.get("Content-Type", "").lower().split(";")
  122. )
  123. content_type = content_type_header[0]
  124. if len(content_type_header) > 1 and content_type_header[1].startswith(
  125. CHARSET_PREFIX
  126. ):
  127. encoding = content_type_header[1][len(CHARSET_PREFIX) :]
  128. else:
  129. encoding = "utf-8"
  130. if (
  131. self.spotlight_script is not None
  132. and not response.streaming
  133. and content_type == "text/html"
  134. ):
  135. content_length = len(response.content)
  136. injection = self.spotlight_script.encode(encoding)
  137. injection_site = next(
  138. (
  139. idx
  140. for idx in (
  141. response.content.rfind(body_variant.encode(encoding))
  142. for body_variant in BODY_CLOSE_TAG_POSSIBILITIES
  143. )
  144. if idx > -1
  145. ),
  146. content_length,
  147. )
  148. # This approach works even when we don't have a `</body>` tag
  149. response.content = (
  150. response.content[:injection_site]
  151. + injection
  152. + response.content[injection_site:]
  153. )
  154. if response.has_header("Content-Length"):
  155. response.headers["Content-Length"] = content_length + len(injection)
  156. return response
  157. def process_exception(self, _request, exception):
  158. # type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError]
  159. if not settings.DEBUG or not self._spotlight_url:
  160. return None
  161. try:
  162. spotlight = (
  163. urllib.request.urlopen(self._spotlight_url).read().decode("utf-8")
  164. )
  165. except urllib.error.URLError:
  166. return None
  167. else:
  168. event_id = self.sentry_sdk.capture_exception(exception)
  169. return HttpResponseServerError(
  170. spotlight.replace(
  171. "<html>",
  172. SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
  173. spotlight_url=self._spotlight_url, event_id=event_id
  174. ),
  175. )
  176. )
  177. except ImportError:
  178. settings = None
  179. def setup_spotlight(options):
  180. # type: (Dict[str, Any]) -> Optional[SpotlightClient]
  181. _handler = logging.StreamHandler(sys.stderr)
  182. _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s"))
  183. logger.addHandler(_handler)
  184. logger.setLevel(logging.INFO)
  185. url = options.get("spotlight")
  186. if url is True:
  187. url = DEFAULT_SPOTLIGHT_URL
  188. if not isinstance(url, str):
  189. return None
  190. with capture_internal_exceptions():
  191. if (
  192. settings is not None
  193. and settings.DEBUG
  194. and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1"))
  195. and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1"))
  196. ):
  197. middleware = settings.MIDDLEWARE
  198. if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware:
  199. settings.MIDDLEWARE = type(middleware)(
  200. chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,))
  201. )
  202. logger.info("Enabled Spotlight integration for Django")
  203. client = SpotlightClient(url)
  204. logger.info("Enabled Spotlight using sidecar at %s", url)
  205. return client