Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(spotlight): Inject Spotlight button on Django #3751

Merged
merged 11 commits into from
Nov 12, 2024
159 changes: 130 additions & 29 deletions sentry_sdk/spotlight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"

Expand All @@ -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
Expand All @@ -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 = (
'<script type="module" crossorigin src="{}"></script>'
)
SPOTLIGHT_ERROR_PAGE_SNIPPET = (
'<html><base href="{spotlight_url}">\n'
'<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\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,
)
sentrivana marked this conversation as resolved.
Show resolved Hide resolved

# This approach works even when we don't have a `</body>` 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(
"<html>",
(
f'<html><base href="{spotlight_url}">'
'<script>window.__spotlight = {{ initOptions: {{ startFrom: "/errors/{event_id}" }}}};</script>'.format(
event_id=event_id
)
SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
spotlight_url=self._spotlight_url, event_id=event_id
),
)
)
Expand All @@ -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")

Expand All @@ -119,16 +219,17 @@ 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
if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware:
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
Loading