66import urllib .error
77import urllib3
88
9- from itertools import chain
9+ from itertools import chain , product
1010
1111from typing import TYPE_CHECKING
1212
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+ )
2024from 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
5862try :
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