Skip to content

Commit ea91e54

Browse files
merge master
2 parents 470bbbb + 9c9510d commit ea91e54

File tree

12 files changed

+821
-49
lines changed

12 files changed

+821
-49
lines changed

sentry_sdk/client.py

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ def _init_impl(self):
360360

361361
def _capture_envelope(envelope):
362362
# type: (Envelope) -> None
363+
if self.spotlight is not None:
364+
self.spotlight.capture_envelope(envelope)
363365
if self.transport is not None:
364366
self.transport.capture_envelope(envelope)
365367

@@ -387,6 +389,18 @@ def _record_lost_event(
387389
if self.options["enable_backpressure_handling"]:
388390
self.monitor = Monitor(self.transport)
389391

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
403+
390404
self.session_flusher = SessionFlusher(capture_func=_capture_envelope)
391405

392406
self.log_batcher = None
@@ -437,29 +451,6 @@ def _record_lost_event(
437451
options=self.options,
438452
)
439453

440-
spotlight_config = self.options.get("spotlight")
441-
if spotlight_config is None and "SENTRY_SPOTLIGHT" in os.environ:
442-
spotlight_env_value = os.environ["SENTRY_SPOTLIGHT"]
443-
spotlight_config = env_to_bool(spotlight_env_value, strict=True)
444-
self.options["spotlight"] = (
445-
spotlight_config
446-
if spotlight_config is not None
447-
else spotlight_env_value
448-
)
449-
450-
if self.options.get("spotlight"):
451-
# This is intentionally here to prevent setting up spotlight
452-
# stuff we don't need unless spotlight is explicitly enabled
453-
from sentry_sdk.spotlight import setup_spotlight
454-
455-
self.spotlight = setup_spotlight(self.options)
456-
if not self.options["dsn"]:
457-
sample_all = lambda *_args, **_kwargs: 1.0
458-
self.options["send_default_pii"] = True
459-
self.options["error_sampler"] = sample_all
460-
self.options["traces_sampler"] = sample_all
461-
self.options["profiles_sampler"] = sample_all
462-
463454
sdk_name = get_sdk_name(list(self.integrations.keys()))
464455
SDK_INFO["name"] = sdk_name
465456
logger.debug("Setting SDK name to '%s'", sdk_name)

sentry_sdk/integrations/anthropic.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def _set_input_data(span, kwargs, integration):
124124
"""
125125
Set input data for the span based on the provided keyword arguments for the anthropic message creation.
126126
"""
127+
set_data_normalized(span, SPANDATA.GEN_AI_OPERATION_NAME, "chat")
127128
system_prompt = kwargs.get("system")
128129
messages = kwargs.get("messages")
129130
if (

sentry_sdk/integrations/openai_agents/patches/agent_run.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ async def patched_run_single_turn(cls, *args, **kwargs):
8787
end_invoke_agent_span(context_wrapper, current_agent)
8888

8989
span = _start_invoke_agent_span(context_wrapper, agent, kwargs)
90+
agent._sentry_agent_span = span
9091

9192
# Call original method with all the correct parameters
9293
try:

sentry_sdk/integrations/openai_agents/patches/models.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from sentry_sdk.integrations import DidNotEnable
44

55
from ..spans import ai_client_span, update_ai_client_span
6+
from sentry_sdk.consts import SPANDATA
67

78
from typing import TYPE_CHECKING
89

@@ -33,13 +34,37 @@ def wrapped_get_model(cls, agent, run_config):
3334
model = original_get_model(agent, run_config)
3435
original_get_response = model.get_response
3536

37+
# Wrap _fetch_response if it exists (for OpenAI models) to capture raw response model
38+
if hasattr(model, "_fetch_response"):
39+
original_fetch_response = model._fetch_response
40+
41+
@wraps(original_fetch_response)
42+
async def wrapped_fetch_response(*args, **kwargs):
43+
# type: (*Any, **Any) -> Any
44+
response = await original_fetch_response(*args, **kwargs)
45+
if hasattr(response, "model"):
46+
agent._sentry_raw_response_model = str(response.model)
47+
return response
48+
49+
model._fetch_response = wrapped_fetch_response
50+
3651
@wraps(original_get_response)
3752
async def wrapped_get_response(*args, **kwargs):
3853
# type: (*Any, **Any) -> Any
3954
with ai_client_span(agent, kwargs) as span:
4055
result = await original_get_response(*args, **kwargs)
4156

42-
update_ai_client_span(span, agent, kwargs, result)
57+
response_model = getattr(agent, "_sentry_raw_response_model", None)
58+
if response_model:
59+
agent_span = getattr(agent, "_sentry_agent_span", None)
60+
if agent_span:
61+
agent_span.set_data(
62+
SPANDATA.GEN_AI_RESPONSE_MODEL, response_model
63+
)
64+
65+
delattr(agent, "_sentry_raw_response_model")
66+
67+
update_ai_client_span(span, agent, kwargs, result, response_model)
4368

4469
return result
4570

sentry_sdk/integrations/openai_agents/patches/runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ async def wrapper(*args, **kwargs):
3232
# Isolate each workflow so that when agents are run in asyncio tasks they
3333
# don't touch each other's scopes
3434
with sentry_sdk.isolation_scope():
35-
agent = args[0]
35+
# Clone agent because agent invocation spans are attached per run.
36+
agent = args[0].clone()
3637
with agent_workflow_span(agent):
38+
args = (agent, *args[1:])
3739
try:
3840
run_result = await original_func(*args, **kwargs)
3941
except AgentsException as exc:

sentry_sdk/integrations/openai_agents/spans/ai_client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
if TYPE_CHECKING:
1616
from agents import Agent
17-
from typing import Any
17+
from typing import Any, Optional
1818

1919

2020
def ai_client_span(agent, get_response_kwargs):
@@ -35,8 +35,14 @@ def ai_client_span(agent, get_response_kwargs):
3535
return span
3636

3737

38-
def update_ai_client_span(span, agent, get_response_kwargs, result):
39-
# type: (sentry_sdk.tracing.Span, Agent, dict[str, Any], Any) -> None
38+
def update_ai_client_span(
39+
span, agent, get_response_kwargs, result, response_model=None
40+
):
41+
# type: (sentry_sdk.tracing.Span, Agent, dict[str, Any], Any, Optional[str]) -> None
4042
_set_usage_data(span, result.usage)
4143
_set_output_data(span, result)
4244
_create_mcp_execute_tool_spans(span, result)
45+
46+
# Set response model if captured from raw response
47+
if response_model is not None:
48+
span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response_model)

sentry_sdk/integrations/openai_agents/spans/invoke_agent.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sentry_sdk.utils import safe_serialize
1111

1212
from ..consts import SPAN_ORIGIN
13-
from ..utils import _set_agent_data
13+
from ..utils import _set_agent_data, _set_usage_data
1414

1515
from typing import TYPE_CHECKING
1616

@@ -84,6 +84,10 @@ def update_invoke_agent_span(context, agent, output):
8484
span = getattr(context, "_sentry_agent_span", None)
8585

8686
if span:
87+
# Add aggregated usage data from context_wrapper
88+
if hasattr(context, "usage"):
89+
_set_usage_data(span, context.usage)
90+
8791
if should_send_default_pii():
8892
set_data_normalized(
8993
span, SPANDATA.GEN_AI_RESPONSE_TEXT, output, unpack=False

sentry_sdk/spotlight.py

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import io
22
import logging
33
import os
4+
import time
45
import urllib.parse
56
import urllib.request
67
import urllib.error
@@ -34,14 +35,37 @@
3435

3536

3637
class 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

71100
try:
@@ -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+
210301
def 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

Comments
 (0)