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