11import io
22import logging
33import os
4+ import time
45import urllib .parse
56import urllib .request
67import urllib .error
3435
3536
3637class SpotlightClient :
38+ """
39+ A client for sending envelopes to Sentry Spotlight.
40+
41+ Implements exponential backoff retry logic per the SDK spec:
42+ - Logs error at least once when server is unreachable
43+ - Does not log for every failed envelope
44+ - Uses exponential backoff to avoid hammering an unavailable server
45+ - Never blocks normal Sentry operation
46+ """
47+
48+ # Exponential backoff settings
49+ INITIAL_RETRY_DELAY = 1.0 # Start with 1 second
50+ MAX_RETRY_DELAY = 60.0 # Max 60 seconds
51+
3752 def __init__ (self , url ):
3853 # type: (str) -> None
3954 self .url = url
4055 self .http = urllib3 .PoolManager ()
41- self .fails = 0
56+ self ._retry_delay = self .INITIAL_RETRY_DELAY
57+ self ._last_error_time = 0.0 # type: float
4258
4359 def capture_envelope (self , envelope ):
4460 # type: (Envelope) -> None
61+
62+ # Check if we're in backoff period - skip sending to avoid blocking
63+ if self ._last_error_time > 0 :
64+ time_since_error = time .time () - self ._last_error_time
65+ if time_since_error < self ._retry_delay :
66+ # Still in backoff period, skip this envelope
67+ return
68+
4569 body = io .BytesIO ()
4670 envelope .serialize_into (body )
4771 try :
@@ -54,18 +78,23 @@ def capture_envelope(self, envelope):
5478 },
5579 )
5680 req .close ()
57- self .fails = 0
81+ # Success - reset backoff state
82+ self ._retry_delay = self .INITIAL_RETRY_DELAY
83+ self ._last_error_time = 0.0
5884 except Exception as e :
59- if self .fails < 2 :
60- sentry_logger .warning (str (e ))
61- self .fails += 1
62- elif self .fails == 2 :
63- self .fails += 1
64- sentry_logger .warning (
65- "Looks like Spotlight is not running, will keep trying to send events but will not log errors."
66- )
67- # omitting self.fails += 1 in the `else:` case intentionally
68- # to avoid overflowing the variable if Spotlight never becomes reachable
85+ self ._last_error_time = time .time ()
86+
87+ # Increase backoff delay exponentially first, so logged value matches actual wait
88+ self ._retry_delay = min (self ._retry_delay * 2 , self .MAX_RETRY_DELAY )
89+
90+ # Log error once per backoff cycle (we skip sends during backoff, so only one failure per cycle)
91+ sentry_logger .warning (
92+ "Failed to send envelope to Spotlight at %s: %s. "
93+ "Will retry after %.1f seconds." ,
94+ self .url ,
95+ e ,
96+ self ._retry_delay ,
97+ )
6998
7099
71100try :
@@ -207,20 +236,83 @@ def process_exception(self, _request, exception):
207236 settings = None
208237
209238
239+ def _resolve_spotlight_url (spotlight_config , sentry_logger ):
240+ # type: (Any, Any) -> Optional[str]
241+ """
242+ Resolve the Spotlight URL based on config and environment variable.
243+
244+ Implements precedence rules per the SDK spec:
245+ https://develop.sentry.dev/sdk/expected-features/spotlight/
246+
247+ Returns the resolved URL string, or None if Spotlight should be disabled.
248+ """
249+ spotlight_env_value = os .environ .get ("SENTRY_SPOTLIGHT" )
250+
251+ # Parse env var to determine if it's a boolean or URL
252+ spotlight_from_env = None # type: Optional[bool]
253+ spotlight_env_url = None # type: Optional[str]
254+ if spotlight_env_value :
255+ parsed = env_to_bool (spotlight_env_value , strict = True )
256+ if parsed is None :
257+ # It's a URL string
258+ spotlight_from_env = True
259+ spotlight_env_url = spotlight_env_value
260+ else :
261+ spotlight_from_env = parsed
262+
263+ # Apply precedence rules per spec:
264+ # https://develop.sentry.dev/sdk/expected-features/spotlight/#precedence-rules
265+ if spotlight_config is False :
266+ # Config explicitly disables spotlight - warn if env var was set
267+ if spotlight_from_env :
268+ sentry_logger .warning (
269+ "Spotlight is disabled via spotlight=False config option, "
270+ "ignoring SENTRY_SPOTLIGHT environment variable."
271+ )
272+ return None
273+ elif spotlight_config is True :
274+ # Config enables spotlight with boolean true
275+ # If env var has URL, use env var URL per spec
276+ if spotlight_env_url :
277+ return spotlight_env_url
278+ else :
279+ return DEFAULT_SPOTLIGHT_URL
280+ elif isinstance (spotlight_config , str ):
281+ # Config has URL string - use config URL, warn if env var differs
282+ if spotlight_env_value and spotlight_env_value != spotlight_config :
283+ sentry_logger .warning (
284+ "Spotlight URL from config (%s) takes precedence over "
285+ "SENTRY_SPOTLIGHT environment variable (%s)." ,
286+ spotlight_config ,
287+ spotlight_env_value ,
288+ )
289+ return spotlight_config
290+ elif spotlight_config is None :
291+ # No config - use env var
292+ if spotlight_env_url :
293+ return spotlight_env_url
294+ elif spotlight_from_env :
295+ return DEFAULT_SPOTLIGHT_URL
296+ # else: stays None (disabled)
297+
298+ return None
299+
300+
210301def setup_spotlight (options ):
211302 # type: (Dict[str, Any]) -> Optional[SpotlightClient]
303+ url = _resolve_spotlight_url (options .get ("spotlight" ), sentry_logger )
304+
305+ if url is None :
306+ return None
307+
308+ # Only set up logging handler when spotlight is actually enabled
212309 _handler = logging .StreamHandler (sys .stderr )
213310 _handler .setFormatter (logging .Formatter (" [spotlight] %(levelname)s: %(message)s" ))
214311 logger .addHandler (_handler )
215312 logger .setLevel (logging .INFO )
216313
217- url = options .get ("spotlight" )
218-
219- if url is True :
220- url = DEFAULT_SPOTLIGHT_URL
221-
222- if not isinstance (url , str ):
223- return None
314+ # Update options with resolved URL for consistency
315+ options ["spotlight" ] = url
224316
225317 with capture_internal_exceptions ():
226318 if (
0 commit comments