spotlight.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import io
  2. import logging
  3. import os
  4. import time
  5. import urllib.parse
  6. import urllib.request
  7. import urllib.error
  8. import urllib3
  9. import sys
  10. from itertools import chain, product
  11. from typing import TYPE_CHECKING
  12. if TYPE_CHECKING:
  13. from typing import Any
  14. from typing import Callable
  15. from typing import Dict
  16. from typing import Optional
  17. from typing import Self
  18. from sentry_sdk.utils import (
  19. logger as sentry_logger,
  20. env_to_bool,
  21. capture_internal_exceptions,
  22. )
  23. from sentry_sdk.envelope import Envelope
  24. logger = logging.getLogger("spotlight")
  25. DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
  26. DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware"
  27. class SpotlightClient:
  28. """
  29. A client for sending envelopes to Sentry Spotlight.
  30. Implements exponential backoff retry logic per the SDK spec:
  31. - Logs error at least once when server is unreachable
  32. - Does not log for every failed envelope
  33. - Uses exponential backoff to avoid hammering an unavailable server
  34. - Never blocks normal Sentry operation
  35. """
  36. # Exponential backoff settings
  37. INITIAL_RETRY_DELAY = 1.0 # Start with 1 second
  38. MAX_RETRY_DELAY = 60.0 # Max 60 seconds
  39. def __init__(self, url: str) -> None:
  40. self.url = url
  41. self.http = urllib3.PoolManager()
  42. self._retry_delay = self.INITIAL_RETRY_DELAY
  43. self._last_error_time: float = 0.0
  44. def capture_envelope(self, envelope: "Envelope") -> None:
  45. # Check if we're in backoff period - skip sending to avoid blocking
  46. if self._last_error_time > 0:
  47. time_since_error = time.time() - self._last_error_time
  48. if time_since_error < self._retry_delay:
  49. # Still in backoff period, skip this envelope
  50. return
  51. body = io.BytesIO()
  52. envelope.serialize_into(body)
  53. try:
  54. req = self.http.request(
  55. url=self.url,
  56. body=body.getvalue(),
  57. method="POST",
  58. headers={
  59. "Content-Type": "application/x-sentry-envelope",
  60. },
  61. )
  62. req.close()
  63. # Success - reset backoff state
  64. self._retry_delay = self.INITIAL_RETRY_DELAY
  65. self._last_error_time = 0.0
  66. except Exception as e:
  67. self._last_error_time = time.time()
  68. # Increase backoff delay exponentially first, so logged value matches actual wait
  69. self._retry_delay = min(self._retry_delay * 2, self.MAX_RETRY_DELAY)
  70. # Log error once per backoff cycle (we skip sends during backoff, so only one failure per cycle)
  71. sentry_logger.warning(
  72. "Failed to send envelope to Spotlight at %s: %s. "
  73. "Will retry after %.1f seconds.",
  74. self.url,
  75. e,
  76. self._retry_delay,
  77. )
  78. try:
  79. from django.utils.deprecation import MiddlewareMixin
  80. from django.http import HttpResponseServerError, HttpResponse, HttpRequest
  81. from django.conf import settings
  82. SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js"
  83. SPOTLIGHT_JS_SNIPPET_PATTERN = (
  84. "<script>window.__spotlight = {{ initOptions: {{ sidecarUrl: '{spotlight_url}', fullPage: false }} }};</script>\n"
  85. '<script type="module" crossorigin src="{spotlight_js_url}"></script>\n'
  86. )
  87. SPOTLIGHT_ERROR_PAGE_SNIPPET = (
  88. '<html><base href="{spotlight_url}">\n'
  89. '<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\n'
  90. )
  91. CHARSET_PREFIX = "charset="
  92. BODY_TAG_NAME = "body"
  93. BODY_CLOSE_TAG_POSSIBILITIES = tuple(
  94. "</{}>".format("".join(chars))
  95. for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower()))
  96. )
  97. class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc]
  98. _spotlight_script: "Optional[str]" = None
  99. _spotlight_url: "Optional[str]" = None
  100. def __init__(self: "Self", get_response: "Callable[..., HttpResponse]") -> None:
  101. super().__init__(get_response)
  102. import sentry_sdk.api
  103. self.sentry_sdk = sentry_sdk.api
  104. spotlight_client = self.sentry_sdk.get_client().spotlight
  105. if spotlight_client is None:
  106. sentry_logger.warning(
  107. "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
  108. )
  109. return None
  110. # Spotlight URL has a trailing `/stream` part at the end so split it off
  111. self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../")
  112. @property
  113. def spotlight_script(self: "Self") -> "Optional[str]":
  114. if self._spotlight_url is not None and self._spotlight_script is None:
  115. try:
  116. spotlight_js_url = urllib.parse.urljoin(
  117. self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH
  118. )
  119. req = urllib.request.Request(
  120. spotlight_js_url,
  121. method="HEAD",
  122. )
  123. urllib.request.urlopen(req)
  124. self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format(
  125. spotlight_url=self._spotlight_url,
  126. spotlight_js_url=spotlight_js_url,
  127. )
  128. except urllib.error.URLError as err:
  129. sentry_logger.debug(
  130. "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.",
  131. spotlight_js_url,
  132. exc_info=err,
  133. )
  134. return self._spotlight_script
  135. def process_response(
  136. self: "Self", _request: "HttpRequest", response: "HttpResponse"
  137. ) -> "Optional[HttpResponse]":
  138. content_type_header = tuple(
  139. p.strip()
  140. for p in response.headers.get("Content-Type", "").lower().split(";")
  141. )
  142. content_type = content_type_header[0]
  143. if len(content_type_header) > 1 and content_type_header[1].startswith(
  144. CHARSET_PREFIX
  145. ):
  146. encoding = content_type_header[1][len(CHARSET_PREFIX) :]
  147. else:
  148. encoding = "utf-8"
  149. if (
  150. self.spotlight_script is not None
  151. and not response.streaming
  152. and content_type == "text/html"
  153. ):
  154. content_length = len(response.content)
  155. injection = self.spotlight_script.encode(encoding)
  156. injection_site = next(
  157. (
  158. idx
  159. for idx in (
  160. response.content.rfind(body_variant.encode(encoding))
  161. for body_variant in BODY_CLOSE_TAG_POSSIBILITIES
  162. )
  163. if idx > -1
  164. ),
  165. content_length,
  166. )
  167. # This approach works even when we don't have a `</body>` tag
  168. response.content = (
  169. response.content[:injection_site]
  170. + injection
  171. + response.content[injection_site:]
  172. )
  173. if response.has_header("Content-Length"):
  174. response.headers["Content-Length"] = content_length + len(injection)
  175. return response
  176. def process_exception(
  177. self: "Self", _request: "HttpRequest", exception: Exception
  178. ) -> "Optional[HttpResponseServerError]":
  179. if not settings.DEBUG or not self._spotlight_url:
  180. return None
  181. try:
  182. spotlight = (
  183. urllib.request.urlopen(self._spotlight_url).read().decode("utf-8")
  184. )
  185. except urllib.error.URLError:
  186. return None
  187. else:
  188. event_id = self.sentry_sdk.capture_exception(exception)
  189. return HttpResponseServerError(
  190. spotlight.replace(
  191. "<html>",
  192. SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
  193. spotlight_url=self._spotlight_url, event_id=event_id
  194. ),
  195. )
  196. )
  197. except ImportError:
  198. settings = None
  199. def _resolve_spotlight_url(
  200. spotlight_config: "Any", sentry_logger: "Any"
  201. ) -> "Optional[str]":
  202. """
  203. Resolve the Spotlight URL based on config and environment variable.
  204. Implements precedence rules per the SDK spec:
  205. https://develop.sentry.dev/sdk/expected-features/spotlight/
  206. Returns the resolved URL string, or None if Spotlight should be disabled.
  207. """
  208. spotlight_env_value = os.environ.get("SENTRY_SPOTLIGHT")
  209. # Parse env var to determine if it's a boolean or URL
  210. spotlight_from_env: "Optional[bool]" = None
  211. spotlight_env_url: "Optional[str]" = None
  212. if spotlight_env_value:
  213. parsed = env_to_bool(spotlight_env_value, strict=True)
  214. if parsed is None:
  215. # It's a URL string
  216. spotlight_from_env = True
  217. spotlight_env_url = spotlight_env_value
  218. else:
  219. spotlight_from_env = parsed
  220. # Apply precedence rules per spec:
  221. # https://develop.sentry.dev/sdk/expected-features/spotlight/#precedence-rules
  222. if spotlight_config is False:
  223. # Config explicitly disables spotlight - warn if env var was set
  224. if spotlight_from_env:
  225. sentry_logger.warning(
  226. "Spotlight is disabled via spotlight=False config option, "
  227. "ignoring SENTRY_SPOTLIGHT environment variable."
  228. )
  229. return None
  230. elif spotlight_config is True:
  231. # Config enables spotlight with boolean true
  232. # If env var has URL, use env var URL per spec
  233. if spotlight_env_url:
  234. return spotlight_env_url
  235. else:
  236. return DEFAULT_SPOTLIGHT_URL
  237. elif isinstance(spotlight_config, str):
  238. # Config has URL string - use config URL, warn if env var differs
  239. if spotlight_env_value and spotlight_env_value != spotlight_config:
  240. sentry_logger.warning(
  241. "Spotlight URL from config (%s) takes precedence over "
  242. "SENTRY_SPOTLIGHT environment variable (%s).",
  243. spotlight_config,
  244. spotlight_env_value,
  245. )
  246. return spotlight_config
  247. elif spotlight_config is None:
  248. # No config - use env var
  249. if spotlight_env_url:
  250. return spotlight_env_url
  251. elif spotlight_from_env:
  252. return DEFAULT_SPOTLIGHT_URL
  253. # else: stays None (disabled)
  254. return None
  255. def setup_spotlight(options: "Dict[str, Any]") -> "Optional[SpotlightClient]":
  256. url = _resolve_spotlight_url(options.get("spotlight"), sentry_logger)
  257. if url is None:
  258. return None
  259. # Only set up logging handler when spotlight is actually enabled
  260. _handler = logging.StreamHandler(sys.stderr)
  261. _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s"))
  262. logger.addHandler(_handler)
  263. logger.setLevel(logging.INFO)
  264. # Update options with resolved URL for consistency
  265. options["spotlight"] = url
  266. with capture_internal_exceptions():
  267. if (
  268. settings is not None
  269. and settings.DEBUG
  270. and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1"))
  271. and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1"))
  272. ):
  273. middleware = settings.MIDDLEWARE
  274. if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware:
  275. settings.MIDDLEWARE = type(middleware)(
  276. chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,))
  277. )
  278. logger.info("Enabled Spotlight integration for Django")
  279. client = SpotlightClient(url)
  280. logger.info("Enabled Spotlight using sidecar at %s", url)
  281. return client