Skip to content

Commit 417be9f

Browse files
authored
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
1 parent d424226 commit 417be9f

File tree

1 file changed

+130
-29
lines changed

1 file changed

+130
-29
lines changed

sentry_sdk/spotlight.py

Lines changed: 130 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import urllib.request
66
import urllib.error
77
import urllib3
8+
import sys
89

9-
from itertools import chain
10+
from itertools import chain, product
1011

1112
from typing import TYPE_CHECKING
1213

@@ -15,11 +16,19 @@
1516
from typing import Callable
1617
from typing import Dict
1718
from typing import Optional
19+
from typing import Self
1820

19-
from sentry_sdk.utils import logger, env_to_bool, capture_internal_exceptions
21+
from sentry_sdk.utils import (
22+
logger as sentry_logger,
23+
env_to_bool,
24+
capture_internal_exceptions,
25+
)
2026
from sentry_sdk.envelope import Envelope
2127

2228

29+
logger = logging.getLogger("spotlight")
30+
31+
2332
DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
2433
DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware"
2534

@@ -34,7 +43,7 @@ def __init__(self, url):
3443
def capture_envelope(self, envelope):
3544
# type: (Envelope) -> None
3645
if self.tries > 3:
37-
logger.warning(
46+
sentry_logger.warning(
3847
"Too many errors sending to Spotlight, stop sending events there."
3948
)
4049
return
@@ -52,50 +61,137 @@ def capture_envelope(self, envelope):
5261
req.close()
5362
except Exception as e:
5463
self.tries += 1
55-
logger.warning(str(e))
64+
sentry_logger.warning(str(e))
5665

5766

5867
try:
59-
from django.http import HttpResponseServerError
68+
from django.utils.deprecation import MiddlewareMixin
69+
from django.http import HttpResponseServerError, HttpResponse, HttpRequest
6070
from django.conf import settings
6171

62-
class SpotlightMiddleware:
63-
def __init__(self, get_response):
64-
# type: (Any, Callable[..., Any]) -> None
65-
self.get_response = get_response
66-
67-
def __call__(self, request):
68-
# type: (Any, Any) -> Any
69-
return self.get_response(request)
72+
SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js"
73+
SPOTLIGHT_JS_SNIPPET_PATTERN = (
74+
'<script type="module" crossorigin src="{}"></script>'
75+
)
76+
SPOTLIGHT_ERROR_PAGE_SNIPPET = (
77+
'<html><base href="{spotlight_url}">\n'
78+
'<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\n'
79+
)
80+
CHARSET_PREFIX = "charset="
81+
BODY_TAG_NAME = "body"
82+
BODY_CLOSE_TAG_POSSIBILITIES = tuple(
83+
"</{}>".format("".join(chars))
84+
for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower()))
85+
)
86+
87+
class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc]
88+
_spotlight_script = None # type: Optional[str]
7089

71-
def process_exception(self, _request, exception):
72-
# type: (Any, Any, Exception) -> Optional[HttpResponseServerError]
73-
if not settings.DEBUG:
74-
return None
90+
def __init__(self, get_response):
91+
# type: (Self, Callable[..., HttpResponse]) -> None
92+
super().__init__(get_response)
7593

7694
import sentry_sdk.api
7795

78-
spotlight_client = sentry_sdk.api.get_client().spotlight
96+
self.sentry_sdk = sentry_sdk.api
97+
98+
spotlight_client = self.sentry_sdk.get_client().spotlight
7999
if spotlight_client is None:
100+
sentry_logger.warning(
101+
"Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
102+
)
80103
return None
81-
82104
# Spotlight URL has a trailing `/stream` part at the end so split it off
83-
spotlight_url = spotlight_client.url.rsplit("/", 1)[0]
105+
self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../")
106+
107+
@property
108+
def spotlight_script(self):
109+
# type: (Self) -> Optional[str]
110+
if self._spotlight_script is None:
111+
try:
112+
spotlight_js_url = urllib.parse.urljoin(
113+
self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH
114+
)
115+
req = urllib.request.Request(
116+
spotlight_js_url,
117+
method="HEAD",
118+
)
119+
urllib.request.urlopen(req)
120+
self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format(
121+
spotlight_js_url
122+
)
123+
except urllib.error.URLError as err:
124+
sentry_logger.debug(
125+
"Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.",
126+
spotlight_js_url,
127+
exc_info=err,
128+
)
129+
130+
return self._spotlight_script
131+
132+
def process_response(self, _request, response):
133+
# type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse]
134+
content_type_header = tuple(
135+
p.strip()
136+
for p in response.headers.get("Content-Type", "").lower().split(";")
137+
)
138+
content_type = content_type_header[0]
139+
if len(content_type_header) > 1 and content_type_header[1].startswith(
140+
CHARSET_PREFIX
141+
):
142+
encoding = content_type_header[1][len(CHARSET_PREFIX) :]
143+
else:
144+
encoding = "utf-8"
145+
146+
if (
147+
self.spotlight_script is not None
148+
and not response.streaming
149+
and content_type == "text/html"
150+
):
151+
content_length = len(response.content)
152+
injection = self.spotlight_script.encode(encoding)
153+
injection_site = next(
154+
(
155+
idx
156+
for idx in (
157+
response.content.rfind(body_variant.encode(encoding))
158+
for body_variant in BODY_CLOSE_TAG_POSSIBILITIES
159+
)
160+
if idx > -1
161+
),
162+
content_length,
163+
)
164+
165+
# This approach works even when we don't have a `</body>` tag
166+
response.content = (
167+
response.content[:injection_site]
168+
+ injection
169+
+ response.content[injection_site:]
170+
)
171+
172+
if response.has_header("Content-Length"):
173+
response.headers["Content-Length"] = content_length + len(injection)
174+
175+
return response
176+
177+
def process_exception(self, _request, exception):
178+
# type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError]
179+
if not settings.DEBUG:
180+
return None
84181

85182
try:
86-
spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8")
183+
spotlight = (
184+
urllib.request.urlopen(self._spotlight_url).read().decode("utf-8")
185+
)
87186
except urllib.error.URLError:
88187
return None
89188
else:
90-
event_id = sentry_sdk.api.capture_exception(exception)
189+
event_id = self.sentry_sdk.capture_exception(exception)
91190
return HttpResponseServerError(
92191
spotlight.replace(
93192
"<html>",
94-
(
95-
f'<html><base href="{spotlight_url}">'
96-
'<script>window.__spotlight = {{ initOptions: {{ startFrom: "/errors/{event_id}" }}}};</script>'.format(
97-
event_id=event_id
98-
)
193+
SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
194+
spotlight_url=self._spotlight_url, event_id=event_id
99195
),
100196
)
101197
)
@@ -106,6 +202,10 @@ def process_exception(self, _request, exception):
106202

107203
def setup_spotlight(options):
108204
# type: (Dict[str, Any]) -> Optional[SpotlightClient]
205+
_handler = logging.StreamHandler(sys.stderr)
206+
_handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s"))
207+
logger.addHandler(_handler)
208+
logger.setLevel(logging.INFO)
109209

110210
url = options.get("spotlight")
111211

@@ -119,16 +219,17 @@ def setup_spotlight(options):
119219
settings is not None
120220
and settings.DEBUG
121221
and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1"))
222+
and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1"))
122223
):
123224
with capture_internal_exceptions():
124225
middleware = settings.MIDDLEWARE
125226
if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware:
126227
settings.MIDDLEWARE = type(middleware)(
127228
chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,))
128229
)
129-
logging.info("Enabled Spotlight integration for Django")
230+
logger.info("Enabled Spotlight integration for Django")
130231

131232
client = SpotlightClient(url)
132-
logging.info("Enabled Spotlight at %s", url)
233+
logger.info("Enabled Spotlight using sidecar at %s", url)
133234

134235
return client

0 commit comments

Comments
 (0)