55import urllib .request
66import urllib .error
77import urllib3
8+ import sys
89
9- from itertools import chain
10+ from itertools import chain , product
1011
1112from typing import TYPE_CHECKING
1213
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+ )
2026from sentry_sdk .envelope import Envelope
2127
2228
29+ logger = logging .getLogger ("spotlight" )
30+
31+
2332DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
2433DJANGO_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
5867try :
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
107203def 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