From 417be9ffe5e2c72e459646dc7ec14399f78c015e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 12 Nov 2024 13:28:51 +0000 Subject: [PATCH] feat(spotlight): Inject Spotlight button on Django (#3751) This patch expands the `SpotlightMiddleware` for Django and injects the Spotlight button to all HTML responses when Spotlight is enabled and running. It requires Spotlight 2.6.0 to work this way. Ref: getsentry/spotlight#543 --- sentry_sdk/spotlight.py | 159 ++++++++++++++++++++++++++++++++-------- 1 file changed, 130 insertions(+), 29 deletions(-) diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index e7e90f9822..806ba5a09e 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -5,8 +5,9 @@ import urllib.request import urllib.error import urllib3 +import sys -from itertools import chain +from itertools import chain, product from typing import TYPE_CHECKING @@ -15,11 +16,19 @@ from typing import Callable from typing import Dict from typing import Optional + from typing import Self -from sentry_sdk.utils import logger, env_to_bool, capture_internal_exceptions +from sentry_sdk.utils import ( + logger as sentry_logger, + env_to_bool, + capture_internal_exceptions, +) from sentry_sdk.envelope import Envelope +logger = logging.getLogger("spotlight") + + DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream" DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware" @@ -34,7 +43,7 @@ def __init__(self, url): def capture_envelope(self, envelope): # type: (Envelope) -> None if self.tries > 3: - logger.warning( + sentry_logger.warning( "Too many errors sending to Spotlight, stop sending events there." ) return @@ -52,50 +61,137 @@ def capture_envelope(self, envelope): req.close() except Exception as e: self.tries += 1 - logger.warning(str(e)) + sentry_logger.warning(str(e)) try: - from django.http import HttpResponseServerError + from django.utils.deprecation import MiddlewareMixin + from django.http import HttpResponseServerError, HttpResponse, HttpRequest from django.conf import settings - class SpotlightMiddleware: - def __init__(self, get_response): - # type: (Any, Callable[..., Any]) -> None - self.get_response = get_response - - def __call__(self, request): - # type: (Any, Any) -> Any - return self.get_response(request) + SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js" + SPOTLIGHT_JS_SNIPPET_PATTERN = ( + '' + ) + SPOTLIGHT_ERROR_PAGE_SNIPPET = ( + '\n' + '\n' + ) + CHARSET_PREFIX = "charset=" + BODY_TAG_NAME = "body" + BODY_CLOSE_TAG_POSSIBILITIES = tuple( + "".format("".join(chars)) + for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower())) + ) + + class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc] + _spotlight_script = None # type: Optional[str] - def process_exception(self, _request, exception): - # type: (Any, Any, Exception) -> Optional[HttpResponseServerError] - if not settings.DEBUG: - return None + def __init__(self, get_response): + # type: (Self, Callable[..., HttpResponse]) -> None + super().__init__(get_response) import sentry_sdk.api - spotlight_client = sentry_sdk.api.get_client().spotlight + self.sentry_sdk = sentry_sdk.api + + spotlight_client = self.sentry_sdk.get_client().spotlight if spotlight_client is None: + sentry_logger.warning( + "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware." + ) return None - # Spotlight URL has a trailing `/stream` part at the end so split it off - spotlight_url = spotlight_client.url.rsplit("/", 1)[0] + self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../") + + @property + def spotlight_script(self): + # type: (Self) -> Optional[str] + if self._spotlight_script is None: + try: + spotlight_js_url = urllib.parse.urljoin( + self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH + ) + req = urllib.request.Request( + spotlight_js_url, + method="HEAD", + ) + urllib.request.urlopen(req) + self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format( + spotlight_js_url + ) + except urllib.error.URLError as err: + sentry_logger.debug( + "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.", + spotlight_js_url, + exc_info=err, + ) + + return self._spotlight_script + + def process_response(self, _request, response): + # type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse] + content_type_header = tuple( + p.strip() + for p in response.headers.get("Content-Type", "").lower().split(";") + ) + content_type = content_type_header[0] + if len(content_type_header) > 1 and content_type_header[1].startswith( + CHARSET_PREFIX + ): + encoding = content_type_header[1][len(CHARSET_PREFIX) :] + else: + encoding = "utf-8" + + if ( + self.spotlight_script is not None + and not response.streaming + and content_type == "text/html" + ): + content_length = len(response.content) + injection = self.spotlight_script.encode(encoding) + injection_site = next( + ( + idx + for idx in ( + response.content.rfind(body_variant.encode(encoding)) + for body_variant in BODY_CLOSE_TAG_POSSIBILITIES + ) + if idx > -1 + ), + content_length, + ) + + # This approach works even when we don't have a `` tag + response.content = ( + response.content[:injection_site] + + injection + + response.content[injection_site:] + ) + + if response.has_header("Content-Length"): + response.headers["Content-Length"] = content_length + len(injection) + + return response + + def process_exception(self, _request, exception): + # type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError] + if not settings.DEBUG: + return None try: - spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8") + spotlight = ( + urllib.request.urlopen(self._spotlight_url).read().decode("utf-8") + ) except urllib.error.URLError: return None else: - event_id = sentry_sdk.api.capture_exception(exception) + event_id = self.sentry_sdk.capture_exception(exception) return HttpResponseServerError( spotlight.replace( "", - ( - f'' - ''.format( - event_id=event_id - ) + SPOTLIGHT_ERROR_PAGE_SNIPPET.format( + spotlight_url=self._spotlight_url, event_id=event_id ), ) ) @@ -106,6 +202,10 @@ def process_exception(self, _request, exception): def setup_spotlight(options): # type: (Dict[str, Any]) -> Optional[SpotlightClient] + _handler = logging.StreamHandler(sys.stderr) + _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s")) + logger.addHandler(_handler) + logger.setLevel(logging.INFO) url = options.get("spotlight") @@ -119,6 +219,7 @@ def setup_spotlight(options): settings is not None and settings.DEBUG and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1")) + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1")) ): with capture_internal_exceptions(): middleware = settings.MIDDLEWARE @@ -126,9 +227,9 @@ def setup_spotlight(options): settings.MIDDLEWARE = type(middleware)( chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,)) ) - logging.info("Enabled Spotlight integration for Django") + logger.info("Enabled Spotlight integration for Django") client = SpotlightClient(url) - logging.info("Enabled Spotlight at %s", url) + logger.info("Enabled Spotlight using sidecar at %s", url) return client