Skip to content

Commit 6a6881e

Browse files
committed
feat(spotlight): Inject Spotlight button on Django
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.
1 parent 200d0cd commit 6a6881e

File tree

1 file changed

+125
-26
lines changed

1 file changed

+125
-26
lines changed

sentry_sdk/spotlight.py

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import urllib.error
77
import urllib3
88

9-
from itertools import chain
9+
from itertools import chain, product
1010

1111
from typing import TYPE_CHECKING
1212

@@ -16,7 +16,11 @@
1616
from typing import Dict
1717
from typing import Optional
1818

19-
from sentry_sdk.utils import logger, env_to_bool, capture_internal_exceptions
19+
from sentry_sdk.utils import (
20+
logger as sentry_logger,
21+
env_to_bool,
22+
capture_internal_exceptions,
23+
)
2024
from sentry_sdk.envelope import Envelope
2125

2226

@@ -34,7 +38,7 @@ def __init__(self, url):
3438
def capture_envelope(self, envelope):
3539
# type: (Envelope) -> None
3640
if self.tries > 3:
37-
logger.warning(
41+
sentry_logger.warning(
3842
"Too many errors sending to Spotlight, stop sending events there."
3943
)
4044
return
@@ -52,50 +56,144 @@ def capture_envelope(self, envelope):
5256
req.close()
5357
except Exception as e:
5458
self.tries += 1
55-
logger.warning(str(e))
59+
sentry_logger.warning(str(e))
5660

5761

5862
try:
59-
from django.http import HttpResponseServerError
60-
from django.conf import settings
63+
from typing import Self, Optional
6164

62-
class SpotlightMiddleware:
63-
def __init__(self, get_response):
64-
# type: (Any, Callable[..., Any]) -> None
65-
self.get_response = get_response
65+
from django.utils.deprecation import MiddlewareMixin
66+
from django.http import HttpResponseServerError, HttpResponse, HttpRequest
67+
from django.conf import settings
6668

67-
def __call__(self, request):
68-
# type: (Any, Any) -> Any
69-
return self.get_response(request)
69+
SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js"
70+
SPOTLIGHT_JS_SNIPPET_PATTERN = (
71+
'<script type="module" crossorigin src="{}"></script>'
72+
)
73+
SPOTLIGHT_ERROR_PAGE_SNIPPET = (
74+
'<html><base href="{spotlight_url}">\n'
75+
'<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\n'
76+
)
77+
CHARSET_PREFIX = "charset="
78+
BODY_CLOSE_TAG = "</body>"
79+
BODY_CLOSE_TAG_POSSIBILITIES = [
80+
"".join(l)
81+
for l in product(*zip(BODY_CLOSE_TAG.upper(), BODY_CLOSE_TAG.lower()))
82+
]
83+
84+
class SpotlightMiddleware(MiddlewareMixin):
85+
_spotlight_script: Optional[str]
86+
_spotlight_url: str
7087

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

7692
import sentry_sdk.api
7793

94+
self.sentry_sdk = sentry_sdk.api
95+
7896
spotlight_client = sentry_sdk.api.get_client().spotlight
7997
if spotlight_client is None:
98+
sentry_logger.warning(
99+
"Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
100+
)
80101
return None
81-
82102
# Spotlight URL has a trailing `/stream` part at the end so split it off
83-
spotlight_url = spotlight_client.url.rsplit("/", 1)[0]
103+
spotlight_url = self._spotlight_url = urllib.parse.urljoin(
104+
spotlight_client.url, "../"
105+
)
84106

85107
try:
86-
spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8")
108+
spotlight_js_url = urllib.parse.urljoin(
109+
spotlight_url, SPOTLIGHT_JS_ENTRY_PATH
110+
)
111+
req = urllib.request.Request(
112+
spotlight_js_url,
113+
method="HEAD",
114+
)
115+
status_code = urllib.request.urlopen(req).status
116+
if status_code >= 200 and status_code < 400:
117+
self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format(
118+
spotlight_js_url
119+
)
120+
else:
121+
sentry_logger.debug(
122+
"Could not get Spotlight JS from %s (status: %s), SpotlightMiddleware will not be useful.",
123+
spotlight_js_url,
124+
status_code,
125+
)
126+
self._spotlight_script = None
127+
except urllib.error.URLError as err:
128+
sentry_logger.debug(
129+
"Cannot get Spotlight JS to inject. SpotlightMiddleware will not be very useful.",
130+
exc_info=err,
131+
)
132+
self._spotlight_script = None
133+
134+
def process_response(self, _request, response):
135+
# type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse]
136+
content_type_header = tuple(
137+
p.strip()
138+
for p in response.headers.get("Content-Type", "").lower().split(";")
139+
)
140+
content_type = content_type_header[0]
141+
if len(content_type_header) > 1 and content_type_header[1].startswith(
142+
CHARSET_PREFIX
143+
):
144+
encoding = content_type_header[1][len(CHARSET_PREFIX) :]
145+
else:
146+
encoding = "utf-8"
147+
148+
if (
149+
self._spotlight_script is not None
150+
and not response.streaming
151+
and content_type == "text/html"
152+
):
153+
content_length = len(response.content)
154+
injection = self._spotlight_script.encode(encoding)
155+
injection_site = next(
156+
(
157+
idx
158+
for idx in (
159+
response.content.rfind(body_variant.encode(encoding))
160+
for body_variant in BODY_CLOSE_TAG_POSSIBILITIES
161+
)
162+
if idx > -1
163+
),
164+
content_length,
165+
)
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+
174+
if response.has_header("Content-Length"):
175+
response.headers["Content-Length"] = content_length + len(injection)
176+
177+
return response
178+
179+
def process_exception(self, _request, exception):
180+
# type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError]
181+
if not settings.DEBUG:
182+
return None
183+
184+
try:
185+
spotlight = (
186+
urllib.request.urlopen(self._spotlight_url).read().decode("utf-8")
187+
)
87188
except urllib.error.URLError:
88189
return None
89190
else:
90-
event_id = sentry_sdk.api.capture_exception(exception)
191+
event_id = self.sentry_sdk.capture_exception(exception)
91192
return HttpResponseServerError(
92193
spotlight.replace(
93194
"<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-
)
195+
SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
196+
spotlight_url=self._spotlight_url, event_id=event_id
99197
),
100198
)
101199
)
@@ -119,6 +217,7 @@ def setup_spotlight(options):
119217
settings is not None
120218
and settings.DEBUG
121219
and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1"))
220+
and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1"))
122221
):
123222
with capture_internal_exceptions():
124223
middleware = settings.MIDDLEWARE

0 commit comments

Comments
 (0)