5
5
import urllib .request
6
6
import urllib .error
7
7
import urllib3
8
+ import sys
8
9
9
- from itertools import chain
10
+ from itertools import chain , product
10
11
11
12
from typing import TYPE_CHECKING
12
13
15
16
from typing import Callable
16
17
from typing import Dict
17
18
from typing import Optional
19
+ from typing import Self
18
20
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
+ )
20
26
from sentry_sdk .envelope import Envelope
21
27
22
28
29
+ logger = logging .getLogger ("spotlight" )
30
+
31
+
23
32
DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
24
33
DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware"
25
34
@@ -34,7 +43,7 @@ def __init__(self, url):
34
43
def capture_envelope (self , envelope ):
35
44
# type: (Envelope) -> None
36
45
if self .tries > 3 :
37
- logger .warning (
46
+ sentry_logger .warning (
38
47
"Too many errors sending to Spotlight, stop sending events there."
39
48
)
40
49
return
@@ -52,50 +61,137 @@ def capture_envelope(self, envelope):
52
61
req .close ()
53
62
except Exception as e :
54
63
self .tries += 1
55
- logger .warning (str (e ))
64
+ sentry_logger .warning (str (e ))
56
65
57
66
58
67
try :
59
- from django .http import HttpResponseServerError
68
+ from django .utils .deprecation import MiddlewareMixin
69
+ from django .http import HttpResponseServerError , HttpResponse , HttpRequest
60
70
from django .conf import settings
61
71
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]
70
89
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 )
75
93
76
94
import sentry_sdk .api
77
95
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
79
99
if spotlight_client is None :
100
+ sentry_logger .warning (
101
+ "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
102
+ )
80
103
return None
81
-
82
104
# 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
84
181
85
182
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
+ )
87
186
except urllib .error .URLError :
88
187
return None
89
188
else :
90
- event_id = sentry_sdk . api .capture_exception (exception )
189
+ event_id = self . sentry_sdk .capture_exception (exception )
91
190
return HttpResponseServerError (
92
191
spotlight .replace (
93
192
"<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
99
195
),
100
196
)
101
197
)
@@ -106,6 +202,10 @@ def process_exception(self, _request, exception):
106
202
107
203
def setup_spotlight (options ):
108
204
# 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 )
109
209
110
210
url = options .get ("spotlight" )
111
211
@@ -119,16 +219,17 @@ def setup_spotlight(options):
119
219
settings is not None
120
220
and settings .DEBUG
121
221
and env_to_bool (os .environ .get ("SENTRY_SPOTLIGHT_ON_ERROR" , "1" ))
222
+ and env_to_bool (os .environ .get ("SENTRY_SPOTLIGHT_MIDDLEWARE" , "1" ))
122
223
):
123
224
with capture_internal_exceptions ():
124
225
middleware = settings .MIDDLEWARE
125
226
if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware :
126
227
settings .MIDDLEWARE = type (middleware )(
127
228
chain (middleware , (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH ,))
128
229
)
129
- logging .info ("Enabled Spotlight integration for Django" )
230
+ logger .info ("Enabled Spotlight integration for Django" )
130
231
131
232
client = SpotlightClient (url )
132
- logging .info ("Enabled Spotlight at %s" , url )
233
+ logger .info ("Enabled Spotlight using sidecar at %s" , url )
133
234
134
235
return client
0 commit comments