Skip to content

Commit 3703b3c

Browse files
committed
fix(spotlight): align behavior with SDK spec
Align the Spotlight implementation with the official spec at https://develop.sentry.dev/sdk/expected-features/spotlight/ Changes: 1. Fix precedence rules: - When spotlight=True and SENTRY_SPOTLIGHT env var is a URL, use the env var URL (per spec requirement) - Log warning when config URL overrides env var URL - Log warning when spotlight=False explicitly disables despite env var being set 2. Route all envelopes to Spotlight: - Sessions, logs, and metrics now also get sent to Spotlight via the _capture_envelope callback - Move Spotlight initialization before batchers are created 3. Add exponential backoff retry logic: - SpotlightClient now implements proper exponential backoff when the Spotlight server is unreachable - Skips sending during backoff period to avoid blocking - Logs errors only once per backoff cycle 4. Update tests: - Fix test expectation for spotlight=True + env URL case - Add tests for warning scenarios - Add test for session envelope routing to Spotlight
1 parent 977b306 commit 3703b3c

File tree

2 files changed

+93
-87
lines changed

2 files changed

+93
-87
lines changed

sentry_sdk/client.py

Lines changed: 11 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -389,68 +389,17 @@ def _record_lost_event(
389389
if self.options["enable_backpressure_handling"]:
390390
self.monitor = Monitor(self.transport)
391391

392-
# Setup Spotlight before creating batchers so _capture_envelope can use it
393-
spotlight_config = self.options.get("spotlight")
394-
spotlight_env_value = os.environ.get("SENTRY_SPOTLIGHT")
395-
396-
# Parse env var to determine if it's a boolean or URL
397-
spotlight_from_env = None # type: Optional[bool]
398-
spotlight_env_url = None # type: Optional[str]
399-
if spotlight_env_value:
400-
parsed = env_to_bool(spotlight_env_value, strict=True)
401-
if parsed is None:
402-
# It's a URL string
403-
spotlight_from_env = True
404-
spotlight_env_url = spotlight_env_value
405-
else:
406-
spotlight_from_env = parsed
407-
408-
# Apply precedence rules per spec
409-
if spotlight_config is False:
410-
# Config explicitly disables spotlight - warn if env var was set
411-
if spotlight_from_env:
412-
logger.warning(
413-
"Spotlight is disabled via spotlight=False config option, "
414-
"ignoring SENTRY_SPOTLIGHT environment variable."
415-
)
416-
self.options["spotlight"] = False
417-
elif spotlight_config is True:
418-
# Config enables spotlight with boolean true
419-
# If env var has URL, use env var URL per spec
420-
if spotlight_env_url:
421-
self.options["spotlight"] = spotlight_env_url
422-
else:
423-
self.options["spotlight"] = True
424-
elif isinstance(spotlight_config, str):
425-
# Config has URL string - use config URL, warn if env var differs
426-
if spotlight_env_value and spotlight_env_value != spotlight_config:
427-
logger.warning(
428-
"Spotlight URL from config (%s) takes precedence over "
429-
"SENTRY_SPOTLIGHT environment variable (%s).",
430-
spotlight_config,
431-
spotlight_env_value,
432-
)
433-
# self.options["spotlight"] already has the config URL
434-
elif spotlight_config is None:
435-
# No config - use env var
436-
if spotlight_env_url:
437-
self.options["spotlight"] = spotlight_env_url
438-
elif spotlight_from_env:
439-
self.options["spotlight"] = True
440-
# else: stays None (disabled)
441-
442-
if self.options.get("spotlight"):
443-
# This is intentionally here to prevent setting up spotlight
444-
# stuff we don't need unless spotlight is explicitly enabled
445-
from sentry_sdk.spotlight import setup_spotlight
446-
447-
self.spotlight = setup_spotlight(self.options)
448-
if not self.options["dsn"]:
449-
sample_all = lambda *_args, **_kwargs: 1.0
450-
self.options["send_default_pii"] = True
451-
self.options["error_sampler"] = sample_all
452-
self.options["traces_sampler"] = sample_all
453-
self.options["profiles_sampler"] = sample_all
392+
# Setup Spotlight before creating batchers so _capture_envelope can use it.
393+
# setup_spotlight handles all config/env var resolution per the SDK spec.
394+
from sentry_sdk.spotlight import setup_spotlight
395+
396+
self.spotlight = setup_spotlight(self.options)
397+
if self.spotlight is not None and not self.options["dsn"]:
398+
sample_all = lambda *_args, **_kwargs: 1.0
399+
self.options["send_default_pii"] = True
400+
self.options["error_sampler"] = sample_all
401+
self.options["traces_sampler"] = sample_all
402+
self.options["profiles_sampler"] = sample_all
454403

455404
self.session_flusher = SessionFlusher(capture_func=_capture_envelope)
456405

sentry_sdk/spotlight.py

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,15 @@ def __init__(self, url):
5353
# type: (str) -> None
5454
self.url = url
5555
self.http = urllib3.PoolManager()
56-
self._error_logged = False
5756
self._retry_delay = self.INITIAL_RETRY_DELAY
5857
self._last_error_time = 0.0 # type: float
5958

6059
def capture_envelope(self, envelope):
6160
# type: (Envelope) -> None
6261

6362
# Check if we're in backoff period - skip sending to avoid blocking
64-
current_time = time.time()
6563
if self._last_error_time > 0:
66-
time_since_error = current_time - self._last_error_time
64+
time_since_error = time.time() - self._last_error_time
6765
if time_since_error < self._retry_delay:
6866
# Still in backoff period, skip this envelope
6967
return
@@ -81,27 +79,23 @@ def capture_envelope(self, envelope):
8179
)
8280
req.close()
8381
# Success - reset backoff state
84-
self._error_logged = False
8582
self._retry_delay = self.INITIAL_RETRY_DELAY
8683
self._last_error_time = 0.0
8784
except Exception as e:
88-
current_time = time.time()
89-
self._last_error_time = current_time
85+
self._last_error_time = time.time()
9086

91-
# Log error only once per backoff cycle
92-
if not self._error_logged:
93-
sentry_logger.warning(
94-
"Failed to send envelope to Spotlight at %s: %s. "
95-
"Will retry after %.1f seconds.",
96-
self.url,
97-
e,
98-
self._retry_delay,
99-
)
100-
self._error_logged = True
101-
102-
# Increase backoff delay exponentially
87+
# Increase backoff delay exponentially first, so logged value matches actual wait
10388
self._retry_delay = min(self._retry_delay * 2, self.MAX_RETRY_DELAY)
10489

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+
)
98+
10599

106100
try:
107101
from django.utils.deprecation import MiddlewareMixin
@@ -242,20 +236,83 @@ def process_exception(self, _request, exception):
242236
settings = None
243237

244238

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+
245301
def setup_spotlight(options):
246302
# 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
247309
_handler = logging.StreamHandler(sys.stderr)
248310
_handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s"))
249311
logger.addHandler(_handler)
250312
logger.setLevel(logging.INFO)
251313

252-
url = options.get("spotlight")
253-
254-
if url is True:
255-
url = DEFAULT_SPOTLIGHT_URL
256-
257-
if not isinstance(url, str):
258-
return None
314+
# Update options with resolved URL for consistency
315+
options["spotlight"] = url
259316

260317
with capture_internal_exceptions():
261318
if (

0 commit comments

Comments
 (0)