From fb1bc54767afd09b46ba6818d6e5b9c54348e8a0 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Fri, 19 Sep 2025 13:03:11 +0200 Subject: [PATCH 01/16] feat(ai): Add `python-genai` integration --- pyproject.toml | 4 + scripts/populate_tox/config.py | 7 + sentry_sdk/integrations/__init__.py | 2 + .../integrations/google_genai/__init__.py | 290 +++++ .../integrations/google_genai/consts.py | 16 + .../integrations/google_genai/streaming.py | 261 ++++ sentry_sdk/integrations/google_genai/utils.py | 479 ++++++++ setup.py | 1 + tests/integrations/google_genai/__init__.py | 0 .../google_genai/test_google_genai.py | 1059 +++++++++++++++++ 10 files changed, 2119 insertions(+) create mode 100644 sentry_sdk/integrations/google_genai/__init__.py create mode 100644 sentry_sdk/integrations/google_genai/consts.py create mode 100644 sentry_sdk/integrations/google_genai/streaming.py create mode 100644 sentry_sdk/integrations/google_genai/utils.py create mode 100644 tests/integrations/google_genai/__init__.py create mode 100644 tests/integrations/google_genai/test_google_genai.py diff --git a/pyproject.toml b/pyproject.toml index 5b86531014..4441660c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,10 @@ ignore_missing_imports = true module = "langgraph.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "google.genai.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "executing.*" ignore_missing_imports = true diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index f6b90e75e6..fabb373782 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -142,6 +142,13 @@ "package": "gql[all]", "num_versions": 2, }, + "google_genai": { + "package": "google-genai", + "deps": { + "*": ["pytest-asyncio"], + }, + "python": ">=3.8", + }, "graphene": { "package": "graphene", "deps": { diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 3f71f0f4ba..43585023eb 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -91,6 +91,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.flask.FlaskIntegration", "sentry_sdk.integrations.gql.GQLIntegration", "sentry_sdk.integrations.graphene.GrapheneIntegration", + "sentry_sdk.integrations.google_genai.GoogleGenAIIntegration", "sentry_sdk.integrations.httpx.HttpxIntegration", "sentry_sdk.integrations.huey.HueyIntegration", "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", @@ -140,6 +141,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "flask": (1, 1, 4), "gql": (3, 4, 1), "graphene": (3, 3), + "google_genai": (1, 0, 0), # google-genai "grpc": (1, 32, 0), # grpcio "httpx": (0, 16, 0), "huggingface_hub": (0, 24, 7), diff --git a/sentry_sdk/integrations/google_genai/__init__.py b/sentry_sdk/integrations/google_genai/__init__.py new file mode 100644 index 0000000000..34d97efbc2 --- /dev/null +++ b/sentry_sdk/integrations/google_genai/__init__.py @@ -0,0 +1,290 @@ +from functools import wraps +from typing import ( + Any, + AsyncIterator, + Callable, + Iterator, + List, +) + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.consts import OP, SPANDATA + + +try: + from google import genai + from google.genai.models import Models, AsyncModels +except ImportError: + raise DidNotEnable("google-genai not installed") + + +from .consts import IDENTIFIER, ORIGIN, GEN_AI_SYSTEM +from .utils import ( + set_span_data_for_request, + set_span_data_for_response, + capture_exception, +) +from .streaming import ( + set_span_data_for_streaming_response, + accumulate_streaming_response, + prepare_generate_content_args, +) + + +class GoogleGenAIIntegration(Integration): + identifier = IDENTIFIER + origin = ORIGIN + + def __init__(self, include_prompts=True): + # type: (GoogleGenAIIntegration, bool) -> None + self.include_prompts = include_prompts + + @staticmethod + def setup_once(): + # type: () -> None + # Patch sync methods + Models.generate_content = _wrap_generate_content(Models.generate_content) + Models.generate_content_stream = _wrap_generate_content_stream( + Models.generate_content_stream + ) + + # Patch async methods + AsyncModels.generate_content = _wrap_async_generate_content( + AsyncModels.generate_content + ) + AsyncModels.generate_content_stream = _wrap_async_generate_content_stream( + AsyncModels.generate_content_stream + ) + + +def _wrap_generate_content_stream(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + @wraps(f) + def new_generate_content_stream(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return f(self, *args, **kwargs) + + _model, contents, model_name = prepare_generate_content_args(args, kwargs) + + span = sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + chat_span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request(chat_span, integration, model_name, contents, kwargs) + chat_span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + try: + stream = f(self, *args, **kwargs) + + # Create wrapper iterator to accumulate responses + def new_iterator(): + # type: () -> Iterator[Any] + chunks = [] # type: List[Any] + try: + for chunk in stream: + chunks.append(chunk) + yield chunk + except Exception as exc: + capture_exception(exc) + raise + finally: + # Accumulate all chunks and set final response data on spans + if chunks: + accumulated_response = accumulate_streaming_response(chunks) + set_span_data_for_streaming_response( + chat_span, integration, accumulated_response + ) + set_span_data_for_streaming_response( + span, integration, accumulated_response + ) + chat_span.finish() + span.finish() + + return new_iterator() + + except Exception as exc: + capture_exception(exc) + chat_span.finish() + span.finish() + raise + + return new_generate_content_stream + + +def _wrap_async_generate_content_stream(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + @wraps(f) + async def new_async_generate_content_stream(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + _model, contents, model_name = prepare_generate_content_args(args, kwargs) + + span = sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + chat_span = sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request(chat_span, integration, model_name, contents, kwargs) + chat_span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + try: + stream = await f(self, *args, **kwargs) + + # Create wrapper async iterator to accumulate responses + async def new_async_iterator(): + # type: () -> AsyncIterator[Any] + chunks = [] # type: List[Any] + try: + async for chunk in stream: + chunks.append(chunk) + yield chunk + except Exception as exc: + capture_exception(exc) + raise + finally: + # Accumulate all chunks and set final response data on spans + if chunks: + accumulated_response = accumulate_streaming_response(chunks) + set_span_data_for_streaming_response( + chat_span, integration, accumulated_response + ) + set_span_data_for_streaming_response( + span, integration, accumulated_response + ) + chat_span.finish() + span.finish() + + return new_async_iterator() + + except Exception as exc: + capture_exception(exc) + chat_span.finish() + span.finish() + raise + + return new_async_generate_content_stream + + +def _wrap_generate_content(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + @wraps(f) + def new_generate_content(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return f(self, *args, **kwargs) + + model, contents, model_name = prepare_generate_content_args(args, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + + try: + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + + response = f(self, *args, **kwargs) + + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) + + return response + except Exception as exc: + capture_exception(exc) + raise + + return new_generate_content + + +def _wrap_async_generate_content(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + @wraps(f) + async def new_async_generate_content(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(GoogleGenAIIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + model, contents, model_name = prepare_generate_content_args(args, kwargs) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name="invoke_agent", + origin=ORIGIN, + ) as span: + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + set_span_data_for_request(span, integration, model_name, contents, kwargs) + + try: + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + + response = await f(self, *args, **kwargs) + + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) + + return response + except Exception as exc: + capture_exception(exc) + raise + + return new_async_generate_content diff --git a/sentry_sdk/integrations/google_genai/consts.py b/sentry_sdk/integrations/google_genai/consts.py new file mode 100644 index 0000000000..5b53ebf0e2 --- /dev/null +++ b/sentry_sdk/integrations/google_genai/consts.py @@ -0,0 +1,16 @@ +GEN_AI_SYSTEM = "gcp.gemini" + +# Mapping of tool attributes to their descriptions +# These are all tools that are available in the Google GenAI API +TOOL_ATTRIBUTES_MAP = { + "google_search_retrieval": "Google Search retrieval tool", + "google_search": "Google Search tool", + "retrieval": "Retrieval tool", + "enterprise_web_search": "Enterprise web search tool", + "google_maps": "Google Maps tool", + "code_execution": "Code execution tool", + "computer_use": "Computer use tool", +} + +IDENTIFIER = "google_genai" +ORIGIN = f"auto.ai.{IDENTIFIER}" diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py new file mode 100644 index 0000000000..3af8ce9e43 --- /dev/null +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -0,0 +1,261 @@ +from typing import ( + TYPE_CHECKING, + Any, + List, +) + +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import SPANDATA +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import ( + safe_serialize, +) +from .utils import ( + get_model_name, + wrapped_config_with_tools, +) + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span + + +def prepare_generate_content_args(args, kwargs): + # type: (tuple, dict[str, Any]) -> tuple[Any, Any, str] + """Extract and prepare common arguments for generate_content methods.""" + model = args[0] if args else kwargs.get("model", "unknown") + contents = args[1] if len(args) > 1 else kwargs.get("contents") + model_name = get_model_name(model) + + # Wrap config with tools + config = kwargs.get("config") + wrapped_config = wrapped_config_with_tools(config) + if wrapped_config is not config: + kwargs["config"] = wrapped_config + + return model, contents, model_name + + +def accumulate_streaming_response(chunks): + # type: (List[Any]) -> dict[str, Any] + """Accumulate streaming chunks into a single response-like object.""" + accumulated_text = [] + finish_reasons = [] + tool_calls = [] + total_prompt_tokens = 0 + total_tool_use_prompt_tokens = 0 + total_output_tokens = 0 + total_tokens = 0 + total_cached_tokens = 0 + total_reasoning_tokens = 0 + response_id = None + model = None + + for chunk in chunks: + # Extract text and tool calls + if hasattr(chunk, "candidates") and chunk.candidates: + for candidate in chunk.candidates: + if hasattr(candidate, "content") and hasattr( + candidate.content, "parts" + ): + for part in candidate.content.parts: + if hasattr(part, "text") and part.text: + accumulated_text.append(part.text) + + # Extract function calls + if hasattr(part, "function_call") and part.function_call: + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + if hasattr(function_call, "args"): + tool_call["arguments"] = safe_serialize( + function_call.args + ) + tool_calls.append(tool_call) + + # Get finish reason from last chunk + if hasattr(candidate, "finish_reason") and candidate.finish_reason: + reason = str(candidate.finish_reason) + if "." in reason: + reason = reason.split(".")[-1] + if reason not in finish_reasons: + finish_reasons.append(reason) + + # Extract from automatic_function_calling_history + if ( + hasattr(chunk, "automatic_function_calling_history") + and chunk.automatic_function_calling_history + ): + for content in chunk.automatic_function_calling_history: + if hasattr(content, "parts") and content.parts: + for part in content.parts: + if hasattr(part, "function_call") and part.function_call: + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + if hasattr(function_call, "args"): + tool_call["arguments"] = safe_serialize( + function_call.args + ) + tool_calls.append(tool_call) + + # Accumulate token usage + if hasattr(chunk, "usage_metadata") and chunk.usage_metadata: + usage = chunk.usage_metadata + if ( + hasattr(usage, "prompt_token_count") + and usage.prompt_token_count is not None + ): + total_prompt_tokens = max(total_prompt_tokens, usage.prompt_token_count) + if ( + hasattr(usage, "tool_use_prompt_token_count") + and usage.tool_use_prompt_token_count is not None + ): + total_tool_use_prompt_tokens = max( + total_tool_use_prompt_tokens, usage.tool_use_prompt_token_count + ) + if ( + hasattr(usage, "candidates_token_count") + and usage.candidates_token_count is not None + ): + total_output_tokens += usage.candidates_token_count + if ( + hasattr(usage, "cached_content_token_count") + and usage.cached_content_token_count is not None + ): + total_cached_tokens = max( + total_cached_tokens, usage.cached_content_token_count + ) + if ( + hasattr(usage, "thoughts_token_count") + and usage.thoughts_token_count is not None + ): + total_reasoning_tokens += usage.thoughts_token_count + if ( + hasattr(usage, "total_token_count") + and usage.total_token_count is not None + ): + # Only use the final total_token_count from the last chunk + total_tokens = usage.total_token_count + + # Get response metadata from first chunk with it + if response_id is None and hasattr(chunk, "response_id") and chunk.response_id: + response_id = chunk.response_id + if model is None and hasattr(chunk, "model_version") and chunk.model_version: + model = chunk.model_version + + # Create a synthetic response object with accumulated data + accumulated_response = { + "text": "".join(accumulated_text), + "finish_reasons": finish_reasons, + "tool_calls": tool_calls, + "usage_metadata": { + "prompt_token_count": total_prompt_tokens, + "candidates_token_count": total_output_tokens, # Keep original output tokens + "cached_content_token_count": total_cached_tokens, + "thoughts_token_count": total_reasoning_tokens, + "total_token_count": ( + total_tokens + if total_tokens > 0 + else ( + total_prompt_tokens + + total_tool_use_prompt_tokens + + total_output_tokens + + total_reasoning_tokens + + total_cached_tokens + ) + ), + }, + } + + # Add optional token counts if present + if total_tool_use_prompt_tokens > 0: + accumulated_response["usage_metadata"]["tool_use_prompt_token_count"] = ( + total_tool_use_prompt_tokens + ) + if total_cached_tokens > 0: + accumulated_response["usage_metadata"]["cached_content_token_count"] = ( + total_cached_tokens + ) + if total_reasoning_tokens > 0: + accumulated_response["usage_metadata"]["thoughts_token_count"] = ( + total_reasoning_tokens + ) + + if response_id: + accumulated_response["id"] = response_id + if model: + accumulated_response["model"] = model + + return accumulated_response + + +def set_span_data_for_streaming_response(span, integration, accumulated_response): + # type: (Span, Any, dict[str, Any]) -> None + """Set span data for accumulated streaming response.""" + # Set response text + if ( + should_send_default_pii() + and integration.include_prompts + and accumulated_response.get("text") + ): + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TEXT, + safe_serialize([accumulated_response["text"]]), + ) + + # Set finish reasons + if accumulated_response.get("finish_reasons"): + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, + accumulated_response["finish_reasons"], + ) + + # Set tool calls + if accumulated_response.get("tool_calls"): + span.set_data( + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + safe_serialize(accumulated_response["tool_calls"]), + ) + + # Set response ID and model + if accumulated_response.get("id"): + span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, accumulated_response["id"]) + if accumulated_response.get("model"): + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, accumulated_response["model"]) + + # Set token usage + usage = accumulated_response.get("usage_metadata", {}) + + # Input tokens should include both prompt and tool use prompt tokens + prompt_tokens = usage.get("prompt_token_count", 0) + tool_use_prompt_tokens = usage.get("tool_use_prompt_token_count", 0) + if prompt_tokens or tool_use_prompt_tokens: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, prompt_tokens + tool_use_prompt_tokens + ) + + # Output tokens should include reasoning tokens + output_tokens = usage.get("candidates_token_count", 0) + reasoning_tokens = usage.get("thoughts_token_count", 0) + if output_tokens or reasoning_tokens: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens + reasoning_tokens + ) + + if usage.get("total_token_count"): + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["total_token_count"]) + if usage.get("cached_content_token_count"): + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + usage["cached_content_token_count"], + ) + if usage.get("thoughts_token_count"): + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + usage["thoughts_token_count"], + ) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py new file mode 100644 index 0000000000..12f29c73dd --- /dev/null +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -0,0 +1,479 @@ +import copy +import inspect +from functools import wraps +from .consts import ORIGIN, TOOL_ATTRIBUTES_MAP, GEN_AI_SYSTEM +from typing import ( + TYPE_CHECKING, + Any, + Callable, + List, + Optional, + Union, +) + +import sentry_sdk +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, + safe_serialize, +) + +if TYPE_CHECKING: + from sentry_sdk.tracing import Span + from google.genai.types import ( + GenerateContentResponse, + ContentListUnion, + GenerateContentConfig, + Tool, + Model, + ) + + +def capture_exception(exc): + # type: (Any) -> None + """Capture exception with Google GenAI mechanism.""" + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "google_genai", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + +def get_model_name(model): + # type: (Union[str, Model]) -> str + """Extract model name from model parameter.""" + if isinstance(model, str): + return model + # Handle case where model might be an object with a name attribute + if hasattr(model, "name"): + return str(model.name) + return str(model) + + +def _extract_contents_text(contents): + # type: (ContentListUnion) -> Optional[str] + """Extract text from contents parameter which can have various formats.""" + if contents is None: + return None + + # Simple string case + if isinstance(contents, str): + return contents + + # List of contents or parts + if isinstance(contents, list): + texts = [] + for item in contents: + # Recursively extract text from each item + extracted = _extract_contents_text(item) + if extracted: + texts.append(extracted) + return " ".join(texts) if texts else None + + # Dictionary case + if isinstance(contents, dict): + if "text" in contents: + return contents["text"] + # Try to extract from parts if present in dict + if "parts" in contents: + return _extract_contents_text(contents["parts"]) + + # Content object with parts - recurse into parts + if hasattr(contents, "parts") and contents.parts: + return _extract_contents_text(contents.parts) + + # Direct text attribute + if hasattr(contents, "text"): + return contents.text + + return None + + +def _format_tools_for_span(tools): + # type: (Tool | Callable[..., Any]) -> Optional[List[dict[str, Any]]] + """Format tools parameter for span data.""" + formatted_tools = [] + for tool in tools: + if callable(tool): + # Handle callable functions passed directly + formatted_tools.append( + { + "name": getattr(tool, "__name__", "unknown"), + "description": getattr(tool, "__doc__", None), + } + ) + elif ( + hasattr(tool, "function_declarations") + and tool.function_declarations is not None + ): + # Tool object with function declarations + for func_decl in tool.function_declarations: + formatted_tools.append( + { + "name": getattr(func_decl, "name", None), + "description": getattr(func_decl, "description", None), + } + ) + else: + # Check for predefined tool attributes - each of these tools + # is an attribute of the tool object, by default set to None + for attr_name, description in TOOL_ATTRIBUTES_MAP.items(): + if hasattr(tool, attr_name) and getattr(tool, attr_name) is not None: + formatted_tools.append( + { + "name": attr_name, + "description": description, + } + ) + break + + return formatted_tools if formatted_tools else None + + +def _extract_tool_calls(response): + # type: (GenerateContentResponse) -> Optional[List[dict[str, Any]]] + """Extract tool/function calls from response candidates and automatic function calling history.""" + + tool_calls = [] + + # Extract from candidates, sometimes tool calls are nested under the content.parts object + if hasattr(response, "candidates"): + for candidate in response.candidates: + if not hasattr(candidate, "content") or not hasattr( + candidate.content, "parts" + ): + continue + + for part in candidate.content.parts: + if hasattr(part, "function_call") and part.function_call: + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + + # Extract arguments if available + if hasattr(function_call, "args"): + tool_call["arguments"] = safe_serialize(function_call.args) + + tool_calls.append(tool_call) + + # Extract from automatic_function_calling_history + # This is the history of tool calls made by the model + if ( + hasattr(response, "automatic_function_calling_history") + and response.automatic_function_calling_history + ): + for content in response.automatic_function_calling_history: + if not hasattr(content, "parts") or not content.parts: + continue + + for part in content.parts: + if hasattr(part, "function_call") and part.function_call: + function_call = part.function_call + tool_call = { + "name": getattr(function_call, "name", None), + "type": "function_call", + } + + # Extract arguments if available + if hasattr(function_call, "args"): + tool_call["arguments"] = safe_serialize(function_call.args) + + tool_calls.append(tool_call) + + return tool_calls if tool_calls else None + + +def _capture_tool_input(args, kwargs, tool): + # type: (tuple, dict[str, Any], Tool) -> dict[str, Any] + """Capture tool input from args and kwargs.""" + tool_input = kwargs.copy() if kwargs else {} + + # If we have positional args, try to map them to the function signature + if args: + try: + sig = inspect.signature(tool) + param_names = list(sig.parameters.keys()) + for i, arg in enumerate(args): + if i < len(param_names): + tool_input[param_names[i]] = arg + except Exception: + # Fallback if we can't get the signature + tool_input["args"] = args + + return tool_input + + +def _create_tool_span(tool_name, tool_doc): + # type: (str, Optional[str]) -> Span + """Create a span for tool execution.""" + span = sentry_sdk.start_span( + op=OP.GEN_AI_EXECUTE_TOOL, + name=f"execute_tool {tool_name}", + origin=ORIGIN, + ) + span.set_data(SPANDATA.GEN_AI_TOOL_NAME, tool_name) + span.set_data(SPANDATA.GEN_AI_TOOL_TYPE, "function") + if tool_doc: + span.set_data(SPANDATA.GEN_AI_TOOL_DESCRIPTION, tool_doc) + return span + + +def wrapped_tool(tool): + # type: (Tool | Callable[..., Any]) -> Tool | Callable[..., Any] + """Wrap a tool to emit execute_tool spans when called.""" + if not callable(tool): + # Not a callable function, return as-is (predefined tools) + return tool + + tool_name = getattr(tool, "__name__", "unknown") + tool_doc = tool.__doc__ + + if inspect.iscoroutinefunction(tool): + # Async function + @wraps(tool) + async def async_wrapped(*args, **kwargs): + with _create_tool_span(tool_name, tool_doc) as span: + # Capture tool input + tool_input = _capture_tool_input(args, kwargs, tool) + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_input) + ) + + try: + result = await tool(*args, **kwargs) + + # Capture tool output + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result) + ) + + return result + except Exception as exc: + capture_exception(exc) + raise + + return async_wrapped + else: + # Sync function + @wraps(tool) + def sync_wrapped(*args, **kwargs): + with _create_tool_span(tool_name, tool_doc) as span: + # Capture tool input + tool_input = _capture_tool_input(args, kwargs, tool) + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_INPUT, safe_serialize(tool_input) + ) + + try: + result = tool(*args, **kwargs) + + # Capture tool output + with capture_internal_exceptions(): + span.set_data( + SPANDATA.GEN_AI_TOOL_OUTPUT, safe_serialize(result) + ) + + return result + except Exception as exc: + capture_exception(exc) + raise + + return sync_wrapped + + +def wrapped_config_with_tools(config): + # type: (GenerateContentConfig) -> GenerateContentConfig + """Wrap tools in config to emit execute_tool spans. Tools are sometimes passed directly as + callable functions as a part of the config object.""" + + if not config or not hasattr(config, "tools") or not config.tools: + return config + + result = copy.copy(config) + result.tools = [wrapped_tool(tool) for tool in config.tools] + + return result + + +def _extract_response_text(response): + # type: (GenerateContentResponse) -> Optional[List[str]] + """Extract text from response candidates.""" + + if not response or not hasattr(response, "candidates"): + return None + + texts = [] + for candidate in response.candidates: + if not hasattr(candidate, "content") or not hasattr(candidate.content, "parts"): + continue + + for part in candidate.content.parts: + if hasattr(part, "text") and part.text: + texts.append(part.text) + + return texts if texts else None + + +def _extract_finish_reasons(response): + # type: (GenerateContentResponse) -> Optional[List[str]] + """Extract finish reasons from response candidates.""" + if not response or not hasattr(response, "candidates"): + return None + + finish_reasons = [] + for candidate in response.candidates: + if hasattr(candidate, "finish_reason") and candidate.finish_reason: + # Convert enum value to string if necessary + reason = str(candidate.finish_reason) + # Remove enum prefix if present (e.g., "FinishReason.STOP" -> "STOP") + if "." in reason: + reason = reason.split(".")[-1] + finish_reasons.append(reason) + + return finish_reasons if finish_reasons else None + + +def set_span_data_for_request(span, integration, model, contents, kwargs): + # type: (Span, Integration, str, ContentListUnion, dict[str, Any]) -> None + """Set span data for the request.""" + span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + + # Set model configuration parameters + config = kwargs.get("config") + + # Extract parameters directly from config (not nested under generation_config) + for param, span_key in [ + ("temperature", SPANDATA.GEN_AI_REQUEST_TEMPERATURE), + ("top_p", SPANDATA.GEN_AI_REQUEST_TOP_P), + ("top_k", SPANDATA.GEN_AI_REQUEST_TOP_K), + ("max_output_tokens", SPANDATA.GEN_AI_REQUEST_MAX_TOKENS), + ("presence_penalty", SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY), + ("frequency_penalty", SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY), + ("seed", SPANDATA.GEN_AI_REQUEST_SEED), + ]: + if hasattr(config, param): + value = getattr(config, param) + if value is not None: + span.set_data(span_key, value) + + # Set streaming flag + if kwargs.get("stream", False): + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + + # Set tools if available + if hasattr(config, "tools"): + tools = config.tools + if tools: + formatted_tools = _format_tools_for_span(tools) + if formatted_tools: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + formatted_tools, + unpack=False, + ) + + # Set input messages/prompts if PII is allowed + if should_send_default_pii() and integration.include_prompts: + messages = [] + + # Add system instruction if present + if hasattr(config, "system_instruction"): + system_instruction = config.system_instruction + if system_instruction: + system_text = _extract_contents_text(system_instruction) + if system_text: + messages.append({"role": "system", "content": system_text}) + + # Add user message + contents_text = _extract_contents_text(contents) + if contents_text: + messages.append({"role": "user", "content": contents_text}) + + if messages: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages, + unpack=False, + ) + + +def set_span_data_for_response(span, integration, response): + # type: (Span, Integration, GenerateContentResponse) -> None + """Set span data for the response.""" + if not response: + return + + # Extract and set response text + if should_send_default_pii() and integration.include_prompts: + response_texts = _extract_response_text(response) + if response_texts: + # Format as JSON string array as per documentation + span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(response_texts)) + + # Extract and set tool calls + tool_calls = _extract_tool_calls(response) + if tool_calls: + # Tool calls should be JSON serialized + span.set_data(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) + + # Extract and set finish reasons + finish_reasons = _extract_finish_reasons(response) + if finish_reasons: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons + ) + + # Set response ID if available + if hasattr(response, "response_id") and response.response_id: + span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, response.response_id) + + # Set response model if available + if hasattr(response, "model_version") and response.model_version: + span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_version) + + # Set token usage if available + if hasattr(response, "usage_metadata"): + usage = response.usage_metadata + + # Input tokens should include both prompt and tool use prompt tokens + prompt_tokens = getattr(usage, "prompt_token_count", 0) or 0 + tool_use_prompt_tokens = getattr(usage, "tool_use_prompt_token_count", 0) or 0 + if prompt_tokens or tool_use_prompt_tokens: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + prompt_tokens + tool_use_prompt_tokens, + ) + + # Output tokens should include reasoning tokens + output_tokens = getattr(usage, "candidates_token_count", 0) or 0 + reasoning_tokens = getattr(usage, "thoughts_token_count", 0) or 0 + if output_tokens or reasoning_tokens: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens + reasoning_tokens + ) + + if hasattr(usage, "total_token_count"): + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_token_count) + if hasattr(usage, "cached_content_token_count"): + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + usage.cached_content_token_count, + ) + if hasattr(usage, "thoughts_token_count"): + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + usage.thoughts_token_count, + ) diff --git a/setup.py b/setup.py index fbb8694e5e..e874a182e4 100644 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ def get_file_text(file_name): "statsig": ["statsig>=0.55.3"], "tornado": ["tornado>=6"], "unleash": ["UnleashClient>=6.0.1"], + "google-genai": ["google-genai"], }, entry_points={ "opentelemetry_propagator": [ diff --git a/tests/integrations/google_genai/__init__.py b/tests/integrations/google_genai/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py new file mode 100644 index 0000000000..6ade87abce --- /dev/null +++ b/tests/integrations/google_genai/test_google_genai.py @@ -0,0 +1,1059 @@ +import json +import pytest +from unittest import mock + +try: + from google import genai + from google.genai import types as genai_types + from google.genai.models import Models +except ImportError: + # If google.genai is not installed, skip the tests + pytest.skip("google-genai not installed", allow_module_level=True) + +from sentry_sdk import start_transaction +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.google_genai import GoogleGenAIIntegration + + +# Create test responses using real types +def create_test_part(text): + """Create a Part with text content.""" + return genai_types.Part(text=text) + + +def create_test_content(parts): + """Create Content with the given parts.""" + return genai_types.Content(parts=parts, role="model") + + +def create_test_candidate(content, finish_reason=None): + """Create a Candidate with content.""" + return genai_types.Candidate( + content=content, + finish_reason=finish_reason or genai_types.FinishReason.STOP, + ) + + +def create_test_usage_metadata( + prompt_token_count=0, + candidates_token_count=0, + total_token_count=0, + cached_content_token_count=0, + thoughts_token_count=0, +): + """Create usage metadata.""" + return genai_types.GenerateContentResponseUsageMetadata( + prompt_token_count=prompt_token_count, + candidates_token_count=candidates_token_count, + total_token_count=total_token_count, + cached_content_token_count=cached_content_token_count, + thoughts_token_count=thoughts_token_count, + ) + + +def create_test_response( + candidates, + usage_metadata=None, + response_id=None, + model_version=None, +): + """Create a GenerateContentResponse.""" + return genai_types.GenerateContentResponse( + candidates=candidates, + usage_metadata=usage_metadata, + response_id=response_id, + model_version=model_version, + ) + + +def create_test_config( + temperature=None, + top_p=None, + top_k=None, + max_output_tokens=None, + presence_penalty=None, + frequency_penalty=None, + seed=None, + system_instruction=None, + tools=None, +): + """Create a GenerateContentConfig.""" + config_dict = {} + + if temperature is not None: + config_dict["temperature"] = temperature + if top_p is not None: + config_dict["top_p"] = top_p + if top_k is not None: + config_dict["top_k"] = top_k + if max_output_tokens is not None: + config_dict["max_output_tokens"] = max_output_tokens + if presence_penalty is not None: + config_dict["presence_penalty"] = presence_penalty + if frequency_penalty is not None: + config_dict["frequency_penalty"] = frequency_penalty + if seed is not None: + config_dict["seed"] = seed + if system_instruction is not None: + # Convert string to Content for system instruction + if isinstance(system_instruction, str): + system_instruction = genai_types.Content( + parts=[genai_types.Part(text=system_instruction)], role="system" + ) + config_dict["system_instruction"] = system_instruction + if tools is not None: + config_dict["tools"] = tools + + return genai_types.GenerateContentConfig(**config_dict) + + +# Sample responses +EXAMPLE_RESPONSE = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content( + parts=[create_test_part("Hello! How can I help you today?")] + ), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=10, + candidates_token_count=20, + total_token_count=30, + cached_content_token_count=5, + thoughts_token_count=3, + ), + response_id="response-id-123", + model_version="gemini-1.5-flash", +) + + +@pytest.fixture +def mock_models_instance(): + """Mock the Models instance and its generate_content method""" + # Create a mock API client + mock_api_client = mock.Mock() + + # Create a real Models instance with the mock API client + models_instance = Models(mock_api_client) + + # Return the instance for use in tests + yield models_instance + + +def setup_mock_generate_content(mock_instance, return_value=None, side_effect=None): + """Helper to set up the mock generate_content method with proper wrapping.""" + # Create a mock method that simulates the behavior + original_mock = mock.Mock() + if side_effect: + original_mock.side_effect = side_effect + else: + original_mock.return_value = return_value + + # Create a bound method that will receive self as first argument + def mock_generate_content(self, *args, **kwargs): + # Call the original mock with all arguments + return original_mock(*args, **kwargs) + + # Apply the integration patch to our mock method + from sentry_sdk.integrations.google_genai import _wrap_generate_content + + wrapped_method = _wrap_generate_content(mock_generate_content) + + # Bind the wrapped method to the mock instance + mock_instance.generate_content = wrapped_method.__get__( + mock_instance, type(mock_instance) + ) + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +def test_nonstreaming_generate_content( + sentry_init, capture_events, send_default_pii, include_prompts, mock_models_instance +): + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + config = create_test_config(temperature=0.7, max_output_tokens=100) + # Create an instance and call generate_content + # Use the mock instance from the fixture + response = mock_models_instance.generate_content( + "gemini-1.5-flash", "Tell me a joke", config=config + ) + + assert response == EXAMPLE_RESPONSE + + assert len(events) == 1 + (event,) = events + + assert event["type"] == "transaction" + assert event["transaction"] == "google_genai" + + # Should have 2 spans: invoke_agent and chat + assert len(event["spans"]) == 2 + invoke_span, chat_span = event["spans"] + + # Check invoke_agent span + assert invoke_span["op"] == OP.GEN_AI_INVOKE_AGENT + assert invoke_span["description"] == "invoke_agent" + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-flash" + assert invoke_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini" + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash" + assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent" + + # Check chat span + assert chat_span["op"] == OP.GEN_AI_CHAT + assert chat_span["description"] == "chat gemini-1.5-flash" + assert chat_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "chat" + assert chat_span["data"][SPANDATA.GEN_AI_SYSTEM] == "gcp.gemini" + assert chat_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash" + + if send_default_pii and include_prompts: + # Messages are serialized as JSON strings + messages = json.loads(invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES]) + assert messages == [{"role": "user", "content": "Tell me a joke"}] + + # Response text is stored as a JSON array + response_text = chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + # Parse the JSON array + response_texts = json.loads(response_text) + assert response_texts == ["Hello! How can I help you today?"] + else: + assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span["data"] + assert SPANDATA.GEN_AI_RESPONSE_TEXT not in chat_span["data"] + + # Check token usage + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 + # Output tokens now include reasoning tokens: candidates_token_count (20) + thoughts_token_count (3) = 23 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 23 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3 + + # Check configuration parameters + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 100 + + +def test_generate_content_with_system_instruction( + sentry_init, capture_events, mock_models_instance +): + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + config = create_test_config( + system_instruction="You are a helpful assistant", + temperature=0.5, + ) + # Verify config has system_instruction + assert hasattr(config, "system_instruction") + assert config.system_instruction is not None + + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "What is 2+2?", config=config + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Check that system instruction is included in messages + # (PII is enabled and include_prompts is True in this test) + messages_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + # Parse the JSON string to verify content + messages = json.loads(messages_str) + assert len(messages) == 2 + assert messages[0] == {"role": "system", "content": "You are a helpful assistant"} + assert messages[1] == {"role": "user", "content": "What is 2+2?"} + + +def test_generate_content_with_tools(sentry_init, capture_events, mock_models_instance): + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create a mock tool function + def get_weather(location: str) -> str: + """Get the weather for a location""" + return f"The weather in {location} is sunny" + + # Create a tool with function declarations using real types + function_declaration = genai_types.FunctionDeclaration( + name="get_weather_tool", + description="Get weather information (tool object)", + parameters=genai_types.Schema( + type=genai_types.Type.OBJECT, + properties={ + "location": genai_types.Schema( + type=genai_types.Type.STRING, + description="The location to get weather for", + ) + }, + required=["location"], + ), + ) + + mock_tool = genai_types.Tool(function_declarations=[function_declaration]) + + # Mock the response to include tool usage + tool_response = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content( + parts=[create_test_part("I'll check the weather.")] + ), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=15, candidates_token_count=10, total_token_count=25 + ), + ) + + setup_mock_generate_content(mock_models_instance, return_value=tool_response) + + with start_transaction(name="google_genai"): + config = create_test_config(tools=[get_weather, mock_tool]) + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "What's the weather?", config=config + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Check that tools are recorded (data is serialized as a string) + tools_data_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS] + # Parse the JSON string to verify content + tools_data = json.loads(tools_data_str) + assert len(tools_data) == 2 + + # The order of tools may not be guaranteed, so sort by name and description for comparison + sorted_tools = sorted( + tools_data, key=lambda t: (t.get("name", ""), t.get("description", "")) + ) + + # The function tool + assert sorted_tools[0]["name"] == "get_weather" + assert sorted_tools[0]["description"] == "Get the weather for a location" + + # The FunctionDeclaration tool + assert sorted_tools[1]["name"] == "get_weather_tool" + assert sorted_tools[1]["description"] == "Get weather information (tool object)" + + +def test_tool_execution(sentry_init, capture_events): + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + # Create a mock tool function + def get_weather(location: str) -> str: + """Get the weather for a location""" + return f"The weather in {location} is sunny" + + # Create wrapped version of the tool + from sentry_sdk.integrations.google_genai.utils import wrapped_tool + + wrapped_weather = wrapped_tool(get_weather) + + # Execute the wrapped tool + with start_transaction(name="test_tool"): + result = wrapped_weather("San Francisco") + + assert result == "The weather in San Francisco is sunny" + + (event,) = events + assert len(event["spans"]) == 1 + tool_span = event["spans"][0] + + assert tool_span["op"] == OP.GEN_AI_EXECUTE_TOOL + assert tool_span["description"] == "execute_tool get_weather" + assert tool_span["data"][SPANDATA.GEN_AI_TOOL_NAME] == "get_weather" + assert tool_span["data"][SPANDATA.GEN_AI_TOOL_TYPE] == "function" + assert ( + tool_span["data"][SPANDATA.GEN_AI_TOOL_DESCRIPTION] + == "Get the weather for a location" + ) + + +def test_error_handling(sentry_init, capture_events, mock_models_instance): + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Mock an error + setup_mock_generate_content( + mock_models_instance, side_effect=Exception("API Error") + ) + + with start_transaction(name="google_genai"): + with pytest.raises(Exception, match="API Error"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "This will fail", config=create_test_config() + ) + + # Should have both transaction and error events + assert len(events) == 2 + error_event, transaction_event = events + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "Exception" + assert error_event["exception"]["values"][0]["value"] == "API Error" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "google_genai" + + +def test_streaming_generate_content(sentry_init, capture_events, mock_models_instance): + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + config = create_test_config() + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Stream me a response", config=config, stream=True + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Check that streaming flag is set + assert invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True + + +def test_different_content_formats(sentry_init, capture_events, mock_models_instance): + """Test different content formats that can be passed to generate_content""" + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + # Test with list of content parts + with start_transaction(name="test1"): + config = create_test_config() + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", + [{"text": "Part 1"}, {"text": "Part 2"}], + config=config, + ) + + # Test with object that has text attribute + class ContentWithText: + def __init__(self, text): + self.text = text + + with start_transaction(name="test2"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", + ContentWithText("Object with text"), + config=config, + ) + + events_list = list(events) + assert len(events_list) == 2 + + # Check first transaction (PII is enabled and include_prompts is True) + messages1_str = events_list[0]["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + # Parse the JSON string to verify content + import json + + messages1 = json.loads(messages1_str) + assert messages1[0]["content"] == "Part 1 Part 2" + + # Check second transaction + messages2_str = events_list[1]["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + messages2 = json.loads(messages2_str) + assert messages2[0]["content"] == "Object with text" + + +def test_span_origin(sentry_init, capture_events, mock_models_instance): + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + config = create_test_config() + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Test origin", config=config + ) + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + for span in event["spans"]: + assert span["origin"] == "auto.ai.google_genai" + + +def test_response_without_usage_metadata( + sentry_init, capture_events, mock_models_instance +): + """Test handling of responses without usage metadata""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create response without usage metadata + response_without_usage = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content(parts=[create_test_part("No usage data")]), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + usage_metadata=None, + ) + + setup_mock_generate_content( + mock_models_instance, return_value=response_without_usage + ) + + with start_transaction(name="google_genai"): + config = create_test_config() + # Use the mock instance from the fixture + mock_models_instance.generate_content("gemini-1.5-flash", "Test", config=config) + + (event,) = events + chat_span = event["spans"][1] + + # Usage data should not be present + assert SPANDATA.GEN_AI_USAGE_INPUT_TOKENS not in chat_span["data"] + assert SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS not in chat_span["data"] + assert SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS not in chat_span["data"] + + +def test_multiple_candidates(sentry_init, capture_events, mock_models_instance): + """Test handling of multiple response candidates""" + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + # Create response with multiple candidates + multi_candidate_response = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content(parts=[create_test_part("Response 1")]), + finish_reason=genai_types.FinishReason.STOP, + ), + create_test_candidate( + content=create_test_content(parts=[create_test_part("Response 2")]), + finish_reason=genai_types.FinishReason.MAX_TOKENS, + ), + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=5, candidates_token_count=15, total_token_count=20 + ), + ) + + setup_mock_generate_content( + mock_models_instance, return_value=multi_candidate_response + ) + + with start_transaction(name="google_genai"): + config = create_test_config() + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Generate multiple", config=config + ) + + (event,) = events + chat_span = event["spans"][1] + + # Should capture all responses + # Response text is stored as a JSON string when there are multiple responses + response_text = chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + if isinstance(response_text, str) and response_text.startswith("["): + # It's a JSON array + response_list = json.loads(response_text) + assert response_list == ["Response 1", "Response 2"] + else: + # It's concatenated + assert response_text == "Response 1\nResponse 2" + + # Finish reasons are serialized as JSON + finish_reasons = json.loads( + chat_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] + ) + assert finish_reasons == ["STOP", "MAX_TOKENS"] + + +def test_model_as_string(sentry_init, capture_events, mock_models_instance): + """Test when model is passed as a string""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + # Pass model as string directly + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-pro", "Test prompt", config=create_test_config() + ) + + (event,) = events + invoke_span = event["spans"][0] + + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-pro" + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-pro" + + +def test_predefined_tools(sentry_init, capture_events, mock_models_instance): + """Test handling of predefined Google tools""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create mock tools with different predefined tool types + # Create non-callable objects to represent predefined tools + class MockGoogleSearchTool: + def __init__(self): + self.google_search_retrieval = mock.Mock() + self.function_declarations = None + + class MockCodeExecutionTool: + def __init__(self): + self.code_execution = mock.Mock() + self.function_declarations = None + + class MockRetrievalTool: + def __init__(self): + self.retrieval = mock.Mock() + self.function_declarations = None + + google_search_tool = MockGoogleSearchTool() + code_execution_tool = MockCodeExecutionTool() + retrieval_tool = MockRetrievalTool() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + # Create a mock config instead of using create_test_config which validates + mock_config = mock.Mock() + mock_config.tools = [google_search_tool, code_execution_tool, retrieval_tool] + mock_config.temperature = None + mock_config.top_p = None + mock_config.top_k = None + mock_config.max_output_tokens = None + mock_config.presence_penalty = None + mock_config.frequency_penalty = None + mock_config.seed = None + mock_config.system_instruction = None + + with start_transaction(name="google_genai"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Use tools", config=mock_config + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Check that tools are recorded (data is serialized as a string) + tools_data_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS] + tools_data = json.loads(tools_data_str) + assert len(tools_data) == 3 + assert tools_data[0]["name"] == "google_search_retrieval" + assert tools_data[1]["name"] == "code_execution" + assert tools_data[2]["name"] == "retrieval" + + +def test_all_configuration_parameters( + sentry_init, capture_events, mock_models_instance +): + """Test that all configuration parameters are properly recorded""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + config = create_test_config( + temperature=0.8, + top_p=0.95, + top_k=40, + max_output_tokens=2048, + presence_penalty=0.1, + frequency_penalty=0.2, + seed=12345, + ) + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Test all params", config=config + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Check all parameters are recorded + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.8 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.95 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_TOP_K] == 40 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 2048 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.1 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.2 + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_SEED] == 12345 + + +def test_model_with_name_attribute(sentry_init, capture_events, mock_models_instance): + """Test when model is an object with name attribute""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create a model object with name attribute + class ModelWithName: + name = "gemini-ultra" + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + ModelWithName(), "Test prompt", config=create_test_config() + ) + + (event,) = events + invoke_span = event["spans"][0] + + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-ultra" + assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-ultra" + + +def test_empty_response(sentry_init, capture_events, mock_models_instance): + """Test handling of empty or None response""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Return None response + setup_mock_generate_content(mock_models_instance, return_value=None) + + with start_transaction(name="google_genai"): + # Use the mock instance from the fixture + response = mock_models_instance.generate_content( + "gemini-1.5-flash", "Test", config=create_test_config() + ) + + assert response is None + + (event,) = events + # Should still create spans even with None response + assert len(event["spans"]) == 2 + + +def test_response_with_different_id_fields( + sentry_init, capture_events, mock_models_instance +): + """Test handling of different response ID field names""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Test with response_id instead of id + response_with_response_id = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content(parts=[create_test_part("Test")]), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + ) + response_with_response_id.response_id = "resp-456" + response_with_response_id.model_version = "gemini-1.5-flash-001" + + setup_mock_generate_content( + mock_models_instance, return_value=response_with_response_id + ) + + with start_transaction(name="google_genai"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", "Test", config=create_test_config() + ) + + (event,) = events + chat_span = event["spans"][1] + + assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_ID] == "resp-456" + assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == "gemini-1.5-flash-001" + + +def test_integration_not_enabled(sentry_init, mock_models_instance): + """Test that integration doesn't interfere when not enabled""" + sentry_init( + integrations=[], # No GoogleGenAIIntegration + traces_sample_rate=1.0, + ) + + # Mock the method without wrapping (since integration is not enabled) + mock_models_instance.generate_content = mock.Mock(return_value=EXAMPLE_RESPONSE) + + # Should work without creating spans + # Use the mock instance from the fixture + response = mock_models_instance.generate_content( + "gemini-1.5-flash", "Test", config=create_test_config() + ) + + assert response == EXAMPLE_RESPONSE + + +def test_tool_with_async_function(sentry_init, capture_events): + """Test that async tool functions are properly wrapped""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + capture_events() + + # Create an async tool function + async def async_tool(param: str) -> str: + """An async tool""" + return f"Async result: {param}" + + # Import is skipped in sync tests, but we can test the wrapping logic + from sentry_sdk.integrations.google_genai.utils import wrapped_tool + + # The wrapper should handle async functions + wrapped_async_tool = wrapped_tool(async_tool) + assert wrapped_async_tool != async_tool # Should be wrapped + assert hasattr(wrapped_async_tool, "__wrapped__") # Should preserve original + + +def test_contents_as_none(sentry_init, capture_events, mock_models_instance): + """Test handling when contents parameter is None""" + sentry_init( + integrations=[GoogleGenAIIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + + with start_transaction(name="google_genai"): + # Use the mock instance from the fixture + mock_models_instance.generate_content( + "gemini-1.5-flash", None, config=create_test_config() + ) + + (event,) = events + invoke_span = event["spans"][0] + + # Should handle None contents gracefully + messages = invoke_span["data"].get(SPANDATA.GEN_AI_REQUEST_MESSAGES, []) + # Should only have system message if any, not user message + assert all(msg["role"] != "user" or msg["content"] is not None for msg in messages) + + +def test_tool_calls_extraction(sentry_init, capture_events, mock_models_instance): + """Test extraction of tool/function calls from response""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create a response with function calls + function_call_response = create_test_response( + candidates=[ + create_test_candidate( + content=genai_types.Content( + parts=[ + genai_types.Part(text="I'll help you with that."), + genai_types.Part( + function_call=genai_types.FunctionCall( + name="get_weather", + args={"location": "San Francisco", "unit": "celsius"}, + ) + ), + genai_types.Part( + function_call=genai_types.FunctionCall( + name="get_time", args={"timezone": "PST"} + ) + ), + ], + role="model", + ), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=20, candidates_token_count=30, total_token_count=50 + ), + ) + + setup_mock_generate_content( + mock_models_instance, return_value=function_call_response + ) + + with start_transaction(name="google_genai"): + mock_models_instance.generate_content( + "gemini-1.5-flash", + "What's the weather and time?", + config=create_test_config(), + ) + + (event,) = events + chat_span = event["spans"][1] # The chat span + + # Check that tool calls are extracted and stored + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in chat_span["data"] + + # Parse the JSON string to verify content + tool_calls = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]) + + assert len(tool_calls) == 2 + + # First tool call + assert tool_calls[0]["name"] == "get_weather" + assert tool_calls[0]["type"] == "function_call" + # Arguments are serialized as JSON strings + assert json.loads(tool_calls[0]["arguments"]) == { + "location": "San Francisco", + "unit": "celsius", + } + + # Second tool call + assert tool_calls[1]["name"] == "get_time" + assert tool_calls[1]["type"] == "function_call" + # Arguments are serialized as JSON strings + assert json.loads(tool_calls[1]["arguments"]) == {"timezone": "PST"} + + +def test_tool_calls_with_automatic_function_calling( + sentry_init, capture_events, mock_models_instance +): + """Test extraction of tool calls from automatic_function_calling_history""" + sentry_init( + integrations=[GoogleGenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + # Create a response with automatic function calling history + response_with_auto_calls = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content( + parts=[create_test_part("Here's the information you requested.")] + ), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + ) + + # Add automatic_function_calling_history + response_with_auto_calls.automatic_function_calling_history = [ + genai_types.Content( + parts=[ + genai_types.Part( + function_call=genai_types.FunctionCall( + name="search_database", + args={"query": "user stats", "limit": 10}, + ) + ) + ], + role="model", + ), + genai_types.Content( + parts=[ + genai_types.Part( + function_response=genai_types.FunctionResponse( + name="search_database", response={"results": ["item1", "item2"]} + ) + ) + ], + role="function", + ), + ] + + setup_mock_generate_content( + mock_models_instance, return_value=response_with_auto_calls + ) + + with start_transaction(name="google_genai"): + mock_models_instance.generate_content( + "gemini-1.5-flash", "Get user statistics", config=create_test_config() + ) + + (event,) = events + chat_span = event["spans"][1] # The chat span + + # Check that tool calls from automatic history are extracted + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in chat_span["data"] + + tool_calls = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]) + + assert len(tool_calls) == 1 + assert tool_calls[0]["name"] == "search_database" + assert tool_calls[0]["type"] == "function_call" + # Arguments are serialized as JSON strings + assert json.loads(tool_calls[0]["arguments"]) == { + "query": "user stats", + "limit": 10, + } From 37919fb5b11706d90ab0e60f273144cc28c7292e Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 7 Oct 2025 15:07:06 +0200 Subject: [PATCH 02/16] fix test running --- .github/workflows/test-integrations-ai.yml | 4 ++++ scripts/populate_tox/config.py | 2 +- scripts/split_tox_gh_actions/split_tox_gh_actions.py | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index fcbb464078..cced9aa40c 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -82,6 +82,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-langgraph" + - name: Test google-genai + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-google-genai" - name: Test openai_agents run: | set -x # print commands that are executed diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index fabb373782..0f688c12a7 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -142,7 +142,7 @@ "package": "gql[all]", "num_versions": 2, }, - "google_genai": { + "google-genai": { "package": "google-genai", "deps": { "*": ["pytest-asyncio"], diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 81f887ad4f..9d59ec548b 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -78,6 +78,7 @@ "openai-base", "openai-notiktoken", "langgraph", + "google-genai", "openai_agents", "huggingface_hub", ], From a5b517c9204f4a909860bec4b31bce6d5a0944a2 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 8 Oct 2025 13:31:32 +0200 Subject: [PATCH 03/16] refactor --- .../integrations/google_genai/streaming.py | 122 ++++------ sentry_sdk/integrations/google_genai/utils.py | 230 ++++++++++++------ .../google_genai/test_google_genai.py | 157 +++++++++++- 3 files changed, 347 insertions(+), 162 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 3af8ce9e43..f70908d405 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -13,14 +13,19 @@ from .utils import ( get_model_name, wrapped_config_with_tools, + extract_tool_calls, + extract_finish_reasons, + extract_contents_text, + extract_usage_data, ) if TYPE_CHECKING: from sentry_sdk.tracing import Span + from google.genai.types import GenerateContentResponse def prepare_generate_content_args(args, kwargs): - # type: (tuple, dict[str, Any]) -> tuple[Any, Any, str] + # type: (tuple[Any, ...], dict[str, Any]) -> tuple[Any, Any, str] """Extract and prepare common arguments for generate_content methods.""" model = args[0] if args else kwargs.get("model", "unknown") contents = args[1] if len(args) > 1 else kwargs.get("contents") @@ -36,7 +41,7 @@ def prepare_generate_content_args(args, kwargs): def accumulate_streaming_response(chunks): - # type: (List[Any]) -> dict[str, Any] + # type: (List[GenerateContentResponse]) -> dict[str, Any] """Accumulate streaming chunks into a single response-like object.""" accumulated_text = [] finish_reasons = [] @@ -57,50 +62,17 @@ def accumulate_streaming_response(chunks): if hasattr(candidate, "content") and hasattr( candidate.content, "parts" ): - for part in candidate.content.parts: - if hasattr(part, "text") and part.text: - accumulated_text.append(part.text) + extracted_text = extract_contents_text(candidate.content) + if extracted_text: + accumulated_text.append(extracted_text) - # Extract function calls - if hasattr(part, "function_call") and part.function_call: - function_call = part.function_call - tool_call = { - "name": getattr(function_call, "name", None), - "type": "function_call", - } - if hasattr(function_call, "args"): - tool_call["arguments"] = safe_serialize( - function_call.args - ) - tool_calls.append(tool_call) + extracted_finish_reasons = extract_finish_reasons(chunk) + if extracted_finish_reasons: + finish_reasons.extend(extracted_finish_reasons) - # Get finish reason from last chunk - if hasattr(candidate, "finish_reason") and candidate.finish_reason: - reason = str(candidate.finish_reason) - if "." in reason: - reason = reason.split(".")[-1] - if reason not in finish_reasons: - finish_reasons.append(reason) - - # Extract from automatic_function_calling_history - if ( - hasattr(chunk, "automatic_function_calling_history") - and chunk.automatic_function_calling_history - ): - for content in chunk.automatic_function_calling_history: - if hasattr(content, "parts") and content.parts: - for part in content.parts: - if hasattr(part, "function_call") and part.function_call: - function_call = part.function_call - tool_call = { - "name": getattr(function_call, "name", None), - "type": "function_call", - } - if hasattr(function_call, "args"): - tool_call["arguments"] = safe_serialize( - function_call.args - ) - tool_calls.append(tool_call) + extracted_tool_calls = extract_tool_calls(chunk) + if extracted_tool_calls: + tool_calls.extend(extracted_tool_calls) # Accumulate token usage if hasattr(chunk, "usage_metadata") and chunk.usage_metadata: @@ -141,12 +113,6 @@ def accumulate_streaming_response(chunks): # Only use the final total_token_count from the last chunk total_tokens = usage.total_token_count - # Get response metadata from first chunk with it - if response_id is None and hasattr(chunk, "response_id") and chunk.response_id: - response_id = chunk.response_id - if model is None and hasattr(chunk, "model_version") and chunk.model_version: - model = chunk.model_version - # Create a synthetic response object with accumulated data accumulated_response = { "text": "".join(accumulated_text), @@ -173,17 +139,17 @@ def accumulate_streaming_response(chunks): # Add optional token counts if present if total_tool_use_prompt_tokens > 0: - accumulated_response["usage_metadata"]["tool_use_prompt_token_count"] = ( - total_tool_use_prompt_tokens - ) + accumulated_response["usage_metadata"][ + "tool_use_prompt_token_count" + ] = total_tool_use_prompt_tokens if total_cached_tokens > 0: - accumulated_response["usage_metadata"]["cached_content_token_count"] = ( - total_cached_tokens - ) + accumulated_response["usage_metadata"][ + "cached_content_token_count" + ] = total_cached_tokens if total_reasoning_tokens > 0: - accumulated_response["usage_metadata"]["thoughts_token_count"] = ( - total_reasoning_tokens - ) + accumulated_response["usage_metadata"][ + "thoughts_token_count" + ] = total_reasoning_tokens if response_id: accumulated_response["id"] = response_id @@ -229,33 +195,27 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, accumulated_response["model"]) # Set token usage - usage = accumulated_response.get("usage_metadata", {}) + usage_data = extract_usage_data(accumulated_response) - # Input tokens should include both prompt and tool use prompt tokens - prompt_tokens = usage.get("prompt_token_count", 0) - tool_use_prompt_tokens = usage.get("tool_use_prompt_token_count", 0) - if prompt_tokens or tool_use_prompt_tokens: - span.set_data( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, prompt_tokens + tool_use_prompt_tokens - ) + if usage_data["input_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage_data["input_tokens"]) - # Output tokens should include reasoning tokens - output_tokens = usage.get("candidates_token_count", 0) - reasoning_tokens = usage.get("thoughts_token_count", 0) - if output_tokens or reasoning_tokens: - span.set_data( - SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens + reasoning_tokens - ) - - if usage.get("total_token_count"): - span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["total_token_count"]) - if usage.get("cached_content_token_count"): + if usage_data["input_tokens_cached"]: span.set_data( SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, - usage["cached_content_token_count"], + usage_data["input_tokens_cached"], ) - if usage.get("thoughts_token_count"): + + # Output tokens already include reasoning tokens from extract_usage_data + if usage_data["output_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage_data["output_tokens"]) + + if usage_data["output_tokens_reasoning"]: span.set_data( SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, - usage["thoughts_token_count"], + usage_data["output_tokens_reasoning"], ) + + # Set total token count if available + if usage_data["total_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 12f29c73dd..21af75a334 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -3,36 +3,122 @@ from functools import wraps from .consts import ORIGIN, TOOL_ATTRIBUTES_MAP, GEN_AI_SYSTEM from typing import ( + cast, TYPE_CHECKING, + Iterable, Any, Callable, List, Optional, Union, + TypedDict, ) import sentry_sdk from sentry_sdk.ai.utils import set_data_normalized from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.integrations import Integration from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, safe_serialize, ) +from google.genai.types import GenerateContentConfig if TYPE_CHECKING: from sentry_sdk.tracing import Span from google.genai.types import ( GenerateContentResponse, ContentListUnion, - GenerateContentConfig, Tool, Model, ) +class UsageData(TypedDict): + """Structure for token usage data.""" + + input_tokens: int + input_tokens_cached: int + output_tokens: int + output_tokens_reasoning: int + total_tokens: int + + +def extract_usage_data(response): + # type: (Union[GenerateContentResponse, dict[str, Any]]) -> UsageData + """Extract usage data from response into a structured format. + + Args: + response: The GenerateContentResponse object or dictionary containing usage metadata + + Returns: + UsageData: Dictionary with input_tokens, input_tokens_cached, + output_tokens, and output_tokens_reasoning fields + """ + usage_data = UsageData( + input_tokens=0, + input_tokens_cached=0, + output_tokens=0, + output_tokens_reasoning=0, + total_tokens=0, + ) + + # Handle dictionary response (from streaming) + if isinstance(response, dict): + usage = response.get("usage_metadata", {}) + if not usage: + return usage_data + + prompt_tokens = usage.get("prompt_token_count", 0) or 0 + tool_use_prompt_tokens = usage.get("tool_use_prompt_token_count", 0) or 0 + usage_data["input_tokens"] = prompt_tokens + tool_use_prompt_tokens + + cached_tokens = usage.get("cached_content_token_count", 0) or 0 + usage_data["input_tokens_cached"] = cached_tokens + + reasoning_tokens = usage.get("thoughts_token_count", 0) or 0 + usage_data["output_tokens_reasoning"] = reasoning_tokens + + candidates_tokens = usage.get("candidates_token_count", 0) or 0 + # python-genai reports output and reasoning tokens separately + usage_data["output_tokens"] = candidates_tokens + reasoning_tokens + + total_tokens = usage.get("total_token_count", 0) or 0 + usage_data["total_tokens"] = total_tokens + + return usage_data + + # Handle response object + if not hasattr(response, "usage_metadata"): + return usage_data + + usage = response.usage_metadata + + # Input tokens include both prompt and tool use prompt tokens + prompt_tokens = getattr(usage, "prompt_token_count", 0) or 0 + tool_use_prompt_tokens = getattr(usage, "tool_use_prompt_token_count", 0) or 0 + usage_data["input_tokens"] = prompt_tokens + tool_use_prompt_tokens + + # Cached input tokens + cached_tokens = getattr(usage, "cached_content_token_count", 0) or 0 + usage_data["input_tokens_cached"] = cached_tokens + + # Reasoning tokens + reasoning_tokens = getattr(usage, "thoughts_token_count", 0) or 0 + usage_data["output_tokens_reasoning"] = reasoning_tokens + + # output_tokens = candidates_tokens + reasoning_tokens + # google-genai reports output and reasoning tokens separately + candidates_tokens = getattr(usage, "candidates_token_count", 0) or 0 + usage_data["output_tokens"] = candidates_tokens + reasoning_tokens + + total_tokens = getattr(usage, "total_token_count", 0) or 0 + usage_data["total_tokens"] = total_tokens + + return usage_data + + def capture_exception(exc): # type: (Any) -> None """Capture exception with Google GenAI mechanism.""" @@ -55,7 +141,7 @@ def get_model_name(model): return str(model) -def _extract_contents_text(contents): +def extract_contents_text(contents): # type: (ContentListUnion) -> Optional[str] """Extract text from contents parameter which can have various formats.""" if contents is None: @@ -70,7 +156,7 @@ def _extract_contents_text(contents): texts = [] for item in contents: # Recursively extract text from each item - extracted = _extract_contents_text(item) + extracted = extract_contents_text(item) if extracted: texts.append(extracted) return " ".join(texts) if texts else None @@ -81,11 +167,11 @@ def _extract_contents_text(contents): return contents["text"] # Try to extract from parts if present in dict if "parts" in contents: - return _extract_contents_text(contents["parts"]) + return extract_contents_text(contents["parts"]) # Content object with parts - recurse into parts if hasattr(contents, "parts") and contents.parts: - return _extract_contents_text(contents.parts) + return extract_contents_text(contents.parts) # Direct text attribute if hasattr(contents, "text"): @@ -95,7 +181,7 @@ def _extract_contents_text(contents): def _format_tools_for_span(tools): - # type: (Tool | Callable[..., Any]) -> Optional[List[dict[str, Any]]] + # type: (Iterable[Tool | Callable[..., Any]]) -> Optional[List[dict[str, Any]]] """Format tools parameter for span data.""" formatted_tools = [] for tool in tools: @@ -135,7 +221,7 @@ def _format_tools_for_span(tools): return formatted_tools if formatted_tools else None -def _extract_tool_calls(response): +def extract_tool_calls(response): # type: (GenerateContentResponse) -> Optional[List[dict[str, Any]]] """Extract tool/function calls from response candidates and automatic function calling history.""" @@ -191,7 +277,7 @@ def _extract_tool_calls(response): def _capture_tool_input(args, kwargs, tool): - # type: (tuple, dict[str, Any], Tool) -> dict[str, Any] + # type: (tuple[Any, ...], dict[str, Any], Tool) -> dict[str, Any] """Capture tool input from args and kwargs.""" tool_input = kwargs.copy() if kwargs else {} @@ -239,6 +325,7 @@ def wrapped_tool(tool): # Async function @wraps(tool) async def async_wrapped(*args, **kwargs): + # type: (Any, Any) -> Any with _create_tool_span(tool_name, tool_doc) as span: # Capture tool input tool_input = _capture_tool_input(args, kwargs, tool) @@ -266,6 +353,7 @@ async def async_wrapped(*args, **kwargs): # Sync function @wraps(tool) def sync_wrapped(*args, **kwargs): + # type: (Any, Any) -> Any with _create_tool_span(tool_name, tool_doc) as span: # Capture tool input tool_input = _capture_tool_input(args, kwargs, tool) @@ -324,7 +412,7 @@ def _extract_response_text(response): return texts if texts else None -def _extract_finish_reasons(response): +def extract_finish_reasons(response): # type: (GenerateContentResponse) -> Optional[List[str]] """Extract finish reasons from response candidates.""" if not response or not hasattr(response, "candidates"): @@ -344,14 +432,48 @@ def _extract_finish_reasons(response): def set_span_data_for_request(span, integration, model, contents, kwargs): - # type: (Span, Integration, str, ContentListUnion, dict[str, Any]) -> None + # type: (Span, Any, str, ContentListUnion, dict[str, Any]) -> None """Set span data for the request.""" span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) + # Set streaming flag + if kwargs.get("stream", False): + span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) + # Set model configuration parameters config = kwargs.get("config") + if config is None: + return + + config = cast(GenerateContentConfig, config) + + # Set input messages/prompts if PII is allowed + if should_send_default_pii() and integration.include_prompts: + messages = [] + + # Add system instruction if present + if hasattr(config, "system_instruction"): + system_instruction = config.system_instruction + if system_instruction: + system_text = extract_contents_text(system_instruction) + if system_text: + messages.append({"role": "system", "content": system_text}) + + # Add user message + contents_text = extract_contents_text(contents) + if contents_text: + messages.append({"role": "user", "content": contents_text}) + + if messages: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + messages, + unpack=False, + ) + # Extract parameters directly from config (not nested under generation_config) for param, span_key in [ ("temperature", SPANDATA.GEN_AI_REQUEST_TEMPERATURE), @@ -367,10 +489,6 @@ def set_span_data_for_request(span, integration, model, contents, kwargs): if value is not None: span.set_data(span_key, value) - # Set streaming flag - if kwargs.get("stream", False): - span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) - # Set tools if available if hasattr(config, "tools"): tools = config.tools @@ -384,34 +502,9 @@ def set_span_data_for_request(span, integration, model, contents, kwargs): unpack=False, ) - # Set input messages/prompts if PII is allowed - if should_send_default_pii() and integration.include_prompts: - messages = [] - - # Add system instruction if present - if hasattr(config, "system_instruction"): - system_instruction = config.system_instruction - if system_instruction: - system_text = _extract_contents_text(system_instruction) - if system_text: - messages.append({"role": "system", "content": system_text}) - - # Add user message - contents_text = _extract_contents_text(contents) - if contents_text: - messages.append({"role": "user", "content": contents_text}) - - if messages: - set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - messages, - unpack=False, - ) - def set_span_data_for_response(span, integration, response): - # type: (Span, Integration, GenerateContentResponse) -> None + # type: (Span, Any, GenerateContentResponse) -> None """Set span data for the response.""" if not response: return @@ -424,13 +517,13 @@ def set_span_data_for_response(span, integration, response): span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(response_texts)) # Extract and set tool calls - tool_calls = _extract_tool_calls(response) + tool_calls = extract_tool_calls(response) if tool_calls: # Tool calls should be JSON serialized span.set_data(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) # Extract and set finish reasons - finish_reasons = _extract_finish_reasons(response) + finish_reasons = extract_finish_reasons(response) if finish_reasons: set_data_normalized( span, SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons @@ -445,35 +538,26 @@ def set_span_data_for_response(span, integration, response): span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_version) # Set token usage if available - if hasattr(response, "usage_metadata"): - usage = response.usage_metadata - - # Input tokens should include both prompt and tool use prompt tokens - prompt_tokens = getattr(usage, "prompt_token_count", 0) or 0 - tool_use_prompt_tokens = getattr(usage, "tool_use_prompt_token_count", 0) or 0 - if prompt_tokens or tool_use_prompt_tokens: - span.set_data( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, - prompt_tokens + tool_use_prompt_tokens, - ) + usage_data = extract_usage_data(response) - # Output tokens should include reasoning tokens - output_tokens = getattr(usage, "candidates_token_count", 0) or 0 - reasoning_tokens = getattr(usage, "thoughts_token_count", 0) or 0 - if output_tokens or reasoning_tokens: - span.set_data( - SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens + reasoning_tokens - ) + if usage_data["input_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage_data["input_tokens"]) - if hasattr(usage, "total_token_count"): - span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage.total_token_count) - if hasattr(usage, "cached_content_token_count"): - span.set_data( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, - usage.cached_content_token_count, - ) - if hasattr(usage, "thoughts_token_count"): - span.set_data( - SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, - usage.thoughts_token_count, - ) + if usage_data["input_tokens_cached"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, + usage_data["input_tokens_cached"], + ) + + if usage_data["output_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage_data["output_tokens"]) + + if usage_data["output_tokens_reasoning"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, + usage_data["output_tokens_reasoning"], + ) + + # Set total token count if available + if usage_data["total_tokens"]: + span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index 6ade87abce..dc6c3d6958 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -26,11 +26,21 @@ def create_test_content(parts): return genai_types.Content(parts=parts, role="model") -def create_test_candidate(content, finish_reason=None): - """Create a Candidate with content.""" +def create_test_candidate(content, finish_reason="default"): + """Create a Candidate with content. + + Args: + content: The content for the candidate + finish_reason: Finish reason. Use "default" for STOP (for backwards compatibility), + None for no finish reason (for streaming chunks), or a specific + FinishReason value. + """ + if finish_reason == "default": + finish_reason = genai_types.FinishReason.STOP + return genai_types.Candidate( content=content, - finish_reason=finish_reason or genai_types.FinishReason.STOP, + finish_reason=finish_reason, ) @@ -167,6 +177,25 @@ def mock_generate_content(self, *args, **kwargs): ) +def setup_mock_generate_content_stream(mock_instance, stream_chunks): + """Helper to set up the mock generate_content_stream method with proper wrapping.""" + + # Create a generator function that yields the chunks + def mock_generate_content_stream(self, *args, **kwargs): + for chunk in stream_chunks: + yield chunk + + # Apply the integration patch to our mock method + from sentry_sdk.integrations.google_genai import _wrap_generate_content_stream + + wrapped_method = _wrap_generate_content_stream(mock_generate_content_stream) + + # Bind the wrapped method to the mock instance + mock_instance.generate_content_stream = wrapped_method.__get__( + mock_instance, type(mock_instance) + ) + + @pytest.mark.parametrize( "send_default_pii, include_prompts", [ @@ -434,6 +463,7 @@ def test_error_handling(sentry_init, capture_events, mock_models_instance): def test_streaming_generate_content(sentry_init, capture_events, mock_models_instance): + """Test streaming with generate_content_stream, verifying chunk accumulation.""" sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=True)], traces_sample_rate=1.0, @@ -441,20 +471,131 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_models_ins ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + # Create streaming chunks - simulating a multi-chunk response + # Chunk 1: First part of text with partial usage metadata + chunk1 = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content(parts=[create_test_part("Hello! ")]), + finish_reason=None, # Not finished yet + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=10, + candidates_token_count=2, + total_token_count=0, # Not set in intermediate chunks + ), + response_id="response-id-stream-123", + model_version="gemini-1.5-flash", + ) + + # Chunk 2: Second part of text with more usage metadata + chunk2 = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content(parts=[create_test_part("How can I ")]), + finish_reason=None, + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=10, + candidates_token_count=3, + total_token_count=0, + ), + ) + + # Chunk 3: Final part with finish reason and complete usage metadata + chunk3 = create_test_response( + candidates=[ + create_test_candidate( + content=create_test_content( + parts=[create_test_part("help you today?")] + ), + finish_reason=genai_types.FinishReason.STOP, + ) + ], + usage_metadata=create_test_usage_metadata( + prompt_token_count=10, + candidates_token_count=7, # Total output tokens across all chunks + total_token_count=22, # Final total from last chunk + cached_content_token_count=5, + thoughts_token_count=3, + ), + ) + + # Set up the streaming mock with our chunks + stream_chunks = [chunk1, chunk2, chunk3] + setup_mock_generate_content_stream(mock_models_instance, stream_chunks) with start_transaction(name="google_genai"): config = create_test_config() - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Stream me a response", config=config, stream=True + # Use the mock instance to get the stream + stream = mock_models_instance.generate_content_stream( + model="gemini-1.5-flash", contents="Stream me a response", config=config ) + # Consume the stream (this is what users do with the integration wrapper) + collected_chunks = list(stream) + + # Verify we got all chunks + assert len(collected_chunks) == 3 + assert collected_chunks[0].candidates[0].content.parts[0].text == "Hello! " + assert collected_chunks[1].candidates[0].content.parts[0].text == "How can I " + assert collected_chunks[2].candidates[0].content.parts[0].text == "help you today?" + (event,) = events + + # There should be 2 spans: invoke_agent and chat + assert len(event["spans"]) == 2 invoke_span = event["spans"][0] + chat_span = event["spans"][1] - # Check that streaming flag is set + # Check that streaming flag is set on both spans assert invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True + assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True + + # Verify accumulated response text (all chunks combined) + expected_full_text = "Hello! How can I help you today?" + # Response text is stored as a JSON string + chat_response_text = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT]) + invoke_response_text = json.loads( + invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + ) + assert chat_response_text == [expected_full_text] + assert invoke_response_text == [expected_full_text] + + # Verify finish reasons (only the final chunk has a finish reason) + # When there's a single finish reason, it's stored as a plain string (not JSON) + assert SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS in chat_span["data"] + assert SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS in invoke_span["data"] + assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "STOP" + assert invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "STOP" + + # Verify token counts - should reflect accumulated values + # Input tokens: max of all chunks = 10 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 + + # Output tokens: candidates (2 + 3 + 7 = 12) + reasoning (3) = 15 + # Note: output_tokens includes both candidates and reasoning tokens + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 15 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 15 + + # Total tokens: from the last chunk + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 22 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 22 + + # Cached tokens: max of all chunks = 5 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5 + + # Reasoning tokens: sum of thoughts_token_count = 3 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING] == 3 + + # Verify model name + assert chat_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-flash" + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-flash" def test_different_content_formats(sentry_init, capture_events, mock_models_instance): From 1492349970eecee008260166682ecb1a986aa137 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 8 Oct 2025 15:41:05 +0200 Subject: [PATCH 04/16] mock API response for tests --- .../google_genai/test_google_genai.py | 995 +++++++----------- 1 file changed, 353 insertions(+), 642 deletions(-) diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index dc6c3d6958..fb32705f3a 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -5,7 +5,6 @@ try: from google import genai from google.genai import types as genai_types - from google.genai.models import Models except ImportError: # If google.genai is not installed, skip the tests pytest.skip("google-genai not installed", allow_module_level=True) @@ -15,65 +14,69 @@ from sentry_sdk.integrations.google_genai import GoogleGenAIIntegration -# Create test responses using real types -def create_test_part(text): - """Create a Part with text content.""" - return genai_types.Part(text=text) - - -def create_test_content(parts): - """Create Content with the given parts.""" - return genai_types.Content(parts=parts, role="model") +@pytest.fixture +def mock_genai_client(): + """Fixture that creates a real genai.Client with mocked HTTP responses.""" + client = genai.Client(api_key="test-api-key") + return client -def create_test_candidate(content, finish_reason="default"): - """Create a Candidate with content. +def create_mock_http_response(response_body): + """ + Create a mock HTTP response that the API client's request() method would return. Args: - content: The content for the candidate - finish_reason: Finish reason. Use "default" for STOP (for backwards compatibility), - None for no finish reason (for streaming chunks), or a specific - FinishReason value. + response_body: The JSON body as a string or dict + + Returns: + An HttpResponse object with headers and body """ - if finish_reason == "default": - finish_reason = genai_types.FinishReason.STOP + if isinstance(response_body, dict): + response_body = json.dumps(response_body) - return genai_types.Candidate( - content=content, - finish_reason=finish_reason, + return genai_types.HttpResponse( + headers={ + "content-type": "application/json; charset=UTF-8", + }, + body=response_body, ) -def create_test_usage_metadata( - prompt_token_count=0, - candidates_token_count=0, - total_token_count=0, - cached_content_token_count=0, - thoughts_token_count=0, -): - """Create usage metadata.""" - return genai_types.GenerateContentResponseUsageMetadata( - prompt_token_count=prompt_token_count, - candidates_token_count=candidates_token_count, - total_token_count=total_token_count, - cached_content_token_count=cached_content_token_count, - thoughts_token_count=thoughts_token_count, - ) +def create_mock_streaming_responses(response_chunks): + """ + Create a generator that yields mock HTTP responses for streaming. + Args: + response_chunks: List of dicts, each representing a chunk's JSON body -def create_test_response( - candidates, - usage_metadata=None, - response_id=None, - model_version=None, -): - """Create a GenerateContentResponse.""" - return genai_types.GenerateContentResponse( - candidates=candidates, - usage_metadata=usage_metadata, - response_id=response_id, - model_version=model_version, - ) + Returns: + A generator that yields HttpResponse objects + """ + for chunk in response_chunks: + yield create_mock_http_response(chunk) + + +# Sample API response JSON (based on real API format from user) +EXAMPLE_API_RESPONSE_JSON = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Hello! How can I help you today?"}], + }, + "finishReason": "STOP", + } + ], + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 20, + "totalTokenCount": 30, + "cachedContentTokenCount": 5, + "thoughtsTokenCount": 3, + }, + "modelVersion": "gemini-1.5-flash", + "responseId": "response-id-123", +} def create_test_config( @@ -117,85 +120,6 @@ def create_test_config( return genai_types.GenerateContentConfig(**config_dict) -# Sample responses -EXAMPLE_RESPONSE = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content( - parts=[create_test_part("Hello! How can I help you today?")] - ), - finish_reason=genai_types.FinishReason.STOP, - ) - ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=10, - candidates_token_count=20, - total_token_count=30, - cached_content_token_count=5, - thoughts_token_count=3, - ), - response_id="response-id-123", - model_version="gemini-1.5-flash", -) - - -@pytest.fixture -def mock_models_instance(): - """Mock the Models instance and its generate_content method""" - # Create a mock API client - mock_api_client = mock.Mock() - - # Create a real Models instance with the mock API client - models_instance = Models(mock_api_client) - - # Return the instance for use in tests - yield models_instance - - -def setup_mock_generate_content(mock_instance, return_value=None, side_effect=None): - """Helper to set up the mock generate_content method with proper wrapping.""" - # Create a mock method that simulates the behavior - original_mock = mock.Mock() - if side_effect: - original_mock.side_effect = side_effect - else: - original_mock.return_value = return_value - - # Create a bound method that will receive self as first argument - def mock_generate_content(self, *args, **kwargs): - # Call the original mock with all arguments - return original_mock(*args, **kwargs) - - # Apply the integration patch to our mock method - from sentry_sdk.integrations.google_genai import _wrap_generate_content - - wrapped_method = _wrap_generate_content(mock_generate_content) - - # Bind the wrapped method to the mock instance - mock_instance.generate_content = wrapped_method.__get__( - mock_instance, type(mock_instance) - ) - - -def setup_mock_generate_content_stream(mock_instance, stream_chunks): - """Helper to set up the mock generate_content_stream method with proper wrapping.""" - - # Create a generator function that yields the chunks - def mock_generate_content_stream(self, *args, **kwargs): - for chunk in stream_chunks: - yield chunk - - # Apply the integration patch to our mock method - from sentry_sdk.integrations.google_genai import _wrap_generate_content_stream - - wrapped_method = _wrap_generate_content_stream(mock_generate_content_stream) - - # Bind the wrapped method to the mock instance - mock_instance.generate_content_stream = wrapped_method.__get__( - mock_instance, type(mock_instance) - ) - - @pytest.mark.parametrize( "send_default_pii, include_prompts", [ @@ -206,7 +130,7 @@ def mock_generate_content_stream(self, *args, **kwargs): ], ) def test_nonstreaming_generate_content( - sentry_init, capture_events, send_default_pii, include_prompts, mock_models_instance + sentry_init, capture_events, send_default_pii, include_prompts, mock_genai_client ): sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=include_prompts)], @@ -215,18 +139,19 @@ def test_nonstreaming_generate_content( ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - with start_transaction(name="google_genai"): - config = create_test_config(temperature=0.7, max_output_tokens=100) - # Create an instance and call generate_content - # Use the mock instance from the fixture - response = mock_models_instance.generate_content( - "gemini-1.5-flash", "Tell me a joke", config=config - ) - - assert response == EXAMPLE_RESPONSE - + # Mock the HTTP response at the _api_client.request() level + mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON) + + with mock.patch.object( + mock_genai_client._api_client, + "request", + return_value=mock_http_response, + ): + with start_transaction(name="google_genai"): + config = create_test_config(temperature=0.7, max_output_tokens=100) + response = mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Tell me a joke", config=config + ) assert len(events) == 1 (event,) = events @@ -280,7 +205,7 @@ def test_nonstreaming_generate_content( def test_generate_content_with_system_instruction( - sentry_init, capture_events, mock_models_instance + sentry_init, capture_events, mock_genai_client ): sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=True)], @@ -289,21 +214,19 @@ def test_generate_content_with_system_instruction( ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - with start_transaction(name="google_genai"): - config = create_test_config( - system_instruction="You are a helpful assistant", - temperature=0.5, - ) - # Verify config has system_instruction - assert hasattr(config, "system_instruction") - assert config.system_instruction is not None + mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON) - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "What is 2+2?", config=config - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config( + system_instruction="You are a helpful assistant", + temperature=0.5, + ) + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="What is 2+2?", config=config + ) (event,) = events invoke_span = event["spans"][0] @@ -318,7 +241,7 @@ def test_generate_content_with_system_instruction( assert messages[1] == {"role": "user", "content": "What is 2+2?"} -def test_generate_content_with_tools(sentry_init, capture_events, mock_models_instance): +def test_generate_content_with_tools(sentry_init, capture_events, mock_genai_client): sentry_init( integrations=[GoogleGenAIIntegration()], traces_sample_rate=1.0, @@ -348,29 +271,34 @@ def get_weather(location: str) -> str: mock_tool = genai_types.Tool(function_declarations=[function_declaration]) - # Mock the response to include tool usage - tool_response = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content( - parts=[create_test_part("I'll check the weather.")] - ), - finish_reason=genai_types.FinishReason.STOP, - ) + # API response for tool usage + tool_response_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "I'll check the weather."}], + }, + "finishReason": "STOP", + } ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=15, candidates_token_count=10, total_token_count=25 - ), - ) + "usageMetadata": { + "promptTokenCount": 15, + "candidatesTokenCount": 10, + "totalTokenCount": 25, + }, + } - setup_mock_generate_content(mock_models_instance, return_value=tool_response) + mock_http_response = create_mock_http_response(tool_response_json) - with start_transaction(name="google_genai"): - config = create_test_config(tools=[get_weather, mock_tool]) - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "What's the weather?", config=config - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config(tools=[get_weather, mock_tool]) + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="What's the weather?", config=config + ) (event,) = events invoke_span = event["spans"][0] @@ -433,24 +361,24 @@ def get_weather(location: str) -> str: ) -def test_error_handling(sentry_init, capture_events, mock_models_instance): +def test_error_handling(sentry_init, capture_events, mock_genai_client): sentry_init( integrations=[GoogleGenAIIntegration()], traces_sample_rate=1.0, ) events = capture_events() - # Mock an error - setup_mock_generate_content( - mock_models_instance, side_effect=Exception("API Error") - ) - - with start_transaction(name="google_genai"): - with pytest.raises(Exception, match="API Error"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "This will fail", config=create_test_config() - ) + # Mock an error at the HTTP level + with mock.patch.object( + mock_genai_client._api_client, "request", side_effect=Exception("API Error") + ): + with start_transaction(name="google_genai"): + with pytest.raises(Exception, match="API Error"): + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", + contents="This will fail", + config=create_test_config(), + ) # Should have both transaction and error events assert len(events) == 2 @@ -462,7 +390,7 @@ def test_error_handling(sentry_init, capture_events, mock_models_instance): assert error_event["exception"]["values"][0]["mechanism"]["type"] == "google_genai" -def test_streaming_generate_content(sentry_init, capture_events, mock_models_instance): +def test_streaming_generate_content(sentry_init, capture_events, mock_genai_client): """Test streaming with generate_content_stream, verifying chunk accumulation.""" sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=True)], @@ -473,69 +401,77 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_models_ins # Create streaming chunks - simulating a multi-chunk response # Chunk 1: First part of text with partial usage metadata - chunk1 = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content(parts=[create_test_part("Hello! ")]), - finish_reason=None, # Not finished yet - ) + chunk1_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Hello! "}], + }, + # No finishReason in intermediate chunks + } ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=10, - candidates_token_count=2, - total_token_count=0, # Not set in intermediate chunks - ), - response_id="response-id-stream-123", - model_version="gemini-1.5-flash", - ) + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 2, + "totalTokenCount": 0, # Not set in intermediate chunks + }, + "responseId": "response-id-stream-123", + "modelVersion": "gemini-1.5-flash", + } # Chunk 2: Second part of text with more usage metadata - chunk2 = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content(parts=[create_test_part("How can I ")]), - finish_reason=None, - ) + chunk2_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "How can I "}], + }, + } ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=10, - candidates_token_count=3, - total_token_count=0, - ), - ) + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 3, + "totalTokenCount": 0, + }, + } # Chunk 3: Final part with finish reason and complete usage metadata - chunk3 = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content( - parts=[create_test_part("help you today?")] - ), - finish_reason=genai_types.FinishReason.STOP, - ) + chunk3_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "help you today?"}], + }, + "finishReason": "STOP", + } ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=10, - candidates_token_count=7, # Total output tokens across all chunks - total_token_count=22, # Final total from last chunk - cached_content_token_count=5, - thoughts_token_count=3, - ), - ) - - # Set up the streaming mock with our chunks - stream_chunks = [chunk1, chunk2, chunk3] - setup_mock_generate_content_stream(mock_models_instance, stream_chunks) + "usageMetadata": { + "promptTokenCount": 10, + "candidatesTokenCount": 7, # Total output tokens across all chunks + "totalTokenCount": 22, # Final total from last chunk + "cachedContentTokenCount": 5, + "thoughtsTokenCount": 3, + }, + } - with start_transaction(name="google_genai"): - config = create_test_config() - # Use the mock instance to get the stream - stream = mock_models_instance.generate_content_stream( - model="gemini-1.5-flash", contents="Stream me a response", config=config - ) + # Create streaming mock responses + stream_chunks = [chunk1_json, chunk2_json, chunk3_json] + mock_stream = create_mock_streaming_responses(stream_chunks) + + with mock.patch.object( + mock_genai_client._api_client, "request_streamed", return_value=mock_stream + ): + with start_transaction(name="google_genai"): + config = create_test_config() + stream = mock_genai_client.models.generate_content_stream( + model="gemini-1.5-flash", contents="Stream me a response", config=config + ) - # Consume the stream (this is what users do with the integration wrapper) - collected_chunks = list(stream) + # Consume the stream (this is what users do with the integration wrapper) + collected_chunks = list(stream) # Verify we got all chunks assert len(collected_chunks) == 3 @@ -598,72 +534,23 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_models_ins assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-flash" -def test_different_content_formats(sentry_init, capture_events, mock_models_instance): - """Test different content formats that can be passed to generate_content""" - sentry_init( - integrations=[GoogleGenAIIntegration(include_prompts=True)], - traces_sample_rate=1.0, - send_default_pii=True, - ) - events = capture_events() - - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - # Test with list of content parts - with start_transaction(name="test1"): - config = create_test_config() - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", - [{"text": "Part 1"}, {"text": "Part 2"}], - config=config, - ) - - # Test with object that has text attribute - class ContentWithText: - def __init__(self, text): - self.text = text - - with start_transaction(name="test2"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", - ContentWithText("Object with text"), - config=config, - ) - - events_list = list(events) - assert len(events_list) == 2 - - # Check first transaction (PII is enabled and include_prompts is True) - messages1_str = events_list[0]["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] - # Parse the JSON string to verify content - import json - - messages1 = json.loads(messages1_str) - assert messages1[0]["content"] == "Part 1 Part 2" - - # Check second transaction - messages2_str = events_list[1]["spans"][0]["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] - messages2 = json.loads(messages2_str) - assert messages2[0]["content"] == "Object with text" - - -def test_span_origin(sentry_init, capture_events, mock_models_instance): +def test_span_origin(sentry_init, capture_events, mock_genai_client): sentry_init( integrations=[GoogleGenAIIntegration()], traces_sample_rate=1.0, ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON) - with start_transaction(name="google_genai"): - config = create_test_config() - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Test origin", config=config - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config() + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Test origin", config=config + ) (event,) = events @@ -673,7 +560,7 @@ def test_span_origin(sentry_init, capture_events, mock_models_instance): def test_response_without_usage_metadata( - sentry_init, capture_events, mock_models_instance + sentry_init, capture_events, mock_genai_client ): """Test handling of responses without usage metadata""" sentry_init( @@ -682,25 +569,29 @@ def test_response_without_usage_metadata( ) events = capture_events() - # Create response without usage metadata - response_without_usage = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content(parts=[create_test_part("No usage data")]), - finish_reason=genai_types.FinishReason.STOP, - ) + # Response without usage metadata + response_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "No usage data"}], + }, + "finishReason": "STOP", + } ], - usage_metadata=None, - ) + } - setup_mock_generate_content( - mock_models_instance, return_value=response_without_usage - ) + mock_http_response = create_mock_http_response(response_json) - with start_transaction(name="google_genai"): - config = create_test_config() - # Use the mock instance from the fixture - mock_models_instance.generate_content("gemini-1.5-flash", "Test", config=config) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config() + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Test", config=config + ) (event,) = events chat_span = event["spans"][1] @@ -711,7 +602,7 @@ def test_response_without_usage_metadata( assert SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS not in chat_span["data"] -def test_multiple_candidates(sentry_init, capture_events, mock_models_instance): +def test_multiple_candidates(sentry_init, capture_events, mock_genai_client): """Test handling of multiple response candidates""" sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=True)], @@ -720,33 +611,41 @@ def test_multiple_candidates(sentry_init, capture_events, mock_models_instance): ) events = capture_events() - # Create response with multiple candidates - multi_candidate_response = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content(parts=[create_test_part("Response 1")]), - finish_reason=genai_types.FinishReason.STOP, - ), - create_test_candidate( - content=create_test_content(parts=[create_test_part("Response 2")]), - finish_reason=genai_types.FinishReason.MAX_TOKENS, - ), + # Response with multiple candidates + multi_candidate_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Response 1"}], + }, + "finishReason": "STOP", + }, + { + "content": { + "role": "model", + "parts": [{"text": "Response 2"}], + }, + "finishReason": "MAX_TOKENS", + }, ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=5, candidates_token_count=15, total_token_count=20 - ), - ) + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 15, + "totalTokenCount": 20, + }, + } - setup_mock_generate_content( - mock_models_instance, return_value=multi_candidate_response - ) + mock_http_response = create_mock_http_response(multi_candidate_json) - with start_transaction(name="google_genai"): - config = create_test_config() - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Generate multiple", config=config - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config() + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Generate multiple", config=config + ) (event,) = events chat_span = event["spans"][1] @@ -769,94 +668,7 @@ def test_multiple_candidates(sentry_init, capture_events, mock_models_instance): assert finish_reasons == ["STOP", "MAX_TOKENS"] -def test_model_as_string(sentry_init, capture_events, mock_models_instance): - """Test when model is passed as a string""" - sentry_init( - integrations=[GoogleGenAIIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - with start_transaction(name="google_genai"): - # Pass model as string directly - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-pro", "Test prompt", config=create_test_config() - ) - - (event,) = events - invoke_span = event["spans"][0] - - assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-1.5-pro" - assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-1.5-pro" - - -def test_predefined_tools(sentry_init, capture_events, mock_models_instance): - """Test handling of predefined Google tools""" - sentry_init( - integrations=[GoogleGenAIIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - # Create mock tools with different predefined tool types - # Create non-callable objects to represent predefined tools - class MockGoogleSearchTool: - def __init__(self): - self.google_search_retrieval = mock.Mock() - self.function_declarations = None - - class MockCodeExecutionTool: - def __init__(self): - self.code_execution = mock.Mock() - self.function_declarations = None - - class MockRetrievalTool: - def __init__(self): - self.retrieval = mock.Mock() - self.function_declarations = None - - google_search_tool = MockGoogleSearchTool() - code_execution_tool = MockCodeExecutionTool() - retrieval_tool = MockRetrievalTool() - - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - # Create a mock config instead of using create_test_config which validates - mock_config = mock.Mock() - mock_config.tools = [google_search_tool, code_execution_tool, retrieval_tool] - mock_config.temperature = None - mock_config.top_p = None - mock_config.top_k = None - mock_config.max_output_tokens = None - mock_config.presence_penalty = None - mock_config.frequency_penalty = None - mock_config.seed = None - mock_config.system_instruction = None - - with start_transaction(name="google_genai"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Use tools", config=mock_config - ) - - (event,) = events - invoke_span = event["spans"][0] - - # Check that tools are recorded (data is serialized as a string) - tools_data_str = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS] - tools_data = json.loads(tools_data_str) - assert len(tools_data) == 3 - assert tools_data[0]["name"] == "google_search_retrieval" - assert tools_data[1]["name"] == "code_execution" - assert tools_data[2]["name"] == "retrieval" - - -def test_all_configuration_parameters( - sentry_init, capture_events, mock_models_instance -): +def test_all_configuration_parameters(sentry_init, capture_events, mock_genai_client): """Test that all configuration parameters are properly recorded""" sentry_init( integrations=[GoogleGenAIIntegration()], @@ -864,22 +676,24 @@ def test_all_configuration_parameters( ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - with start_transaction(name="google_genai"): - config = create_test_config( - temperature=0.8, - top_p=0.95, - top_k=40, - max_output_tokens=2048, - presence_penalty=0.1, - frequency_penalty=0.2, - seed=12345, - ) - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Test all params", config=config - ) + mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON) + + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + config = create_test_config( + temperature=0.8, + top_p=0.95, + top_k=40, + max_output_tokens=2048, + presence_penalty=0.1, + frequency_penalty=0.2, + seed=12345, + ) + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Test all params", config=config + ) (event,) = events invoke_span = event["spans"][0] @@ -894,59 +708,37 @@ def test_all_configuration_parameters( assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_SEED] == 12345 -def test_model_with_name_attribute(sentry_init, capture_events, mock_models_instance): - """Test when model is an object with name attribute""" - sentry_init( - integrations=[GoogleGenAIIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - # Create a model object with name attribute - class ModelWithName: - name = "gemini-ultra" - - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) - - with start_transaction(name="google_genai"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - ModelWithName(), "Test prompt", config=create_test_config() - ) - - (event,) = events - invoke_span = event["spans"][0] - - assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "gemini-ultra" - assert invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "gemini-ultra" - - -def test_empty_response(sentry_init, capture_events, mock_models_instance): - """Test handling of empty or None response""" +def test_empty_response(sentry_init, capture_events, mock_genai_client): + """Test handling of minimal response with no content""" sentry_init( integrations=[GoogleGenAIIntegration()], traces_sample_rate=1.0, ) events = capture_events() - # Return None response - setup_mock_generate_content(mock_models_instance, return_value=None) + # Minimal response with empty candidates array + minimal_response_json = {"candidates": []} + mock_http_response = create_mock_http_response(minimal_response_json) - with start_transaction(name="google_genai"): - # Use the mock instance from the fixture - response = mock_models_instance.generate_content( - "gemini-1.5-flash", "Test", config=create_test_config() - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + response = mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Test", config=create_test_config() + ) - assert response is None + # Response will have an empty candidates list + assert response is not None + assert len(response.candidates) == 0 (event,) = events - # Should still create spans even with None response + # Should still create spans even with empty candidates assert len(event["spans"]) == 2 def test_response_with_different_id_fields( - sentry_init, capture_events, mock_models_instance + sentry_init, capture_events, mock_genai_client ): """Test handling of different response ID field names""" sentry_init( @@ -955,27 +747,30 @@ def test_response_with_different_id_fields( ) events = capture_events() - # Test with response_id instead of id - response_with_response_id = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content(parts=[create_test_part("Test")]), - finish_reason=genai_types.FinishReason.STOP, - ) + # Response with response_id and model_version + response_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [{"text": "Test"}], + }, + "finishReason": "STOP", + } ], - ) - response_with_response_id.response_id = "resp-456" - response_with_response_id.model_version = "gemini-1.5-flash-001" + "responseId": "resp-456", + "modelVersion": "gemini-1.5-flash-001", + } - setup_mock_generate_content( - mock_models_instance, return_value=response_with_response_id - ) + mock_http_response = create_mock_http_response(response_json) - with start_transaction(name="google_genai"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", "Test", config=create_test_config() - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents="Test", config=create_test_config() + ) (event,) = events chat_span = event["spans"][1] @@ -984,25 +779,6 @@ def test_response_with_different_id_fields( assert chat_span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == "gemini-1.5-flash-001" -def test_integration_not_enabled(sentry_init, mock_models_instance): - """Test that integration doesn't interfere when not enabled""" - sentry_init( - integrations=[], # No GoogleGenAIIntegration - traces_sample_rate=1.0, - ) - - # Mock the method without wrapping (since integration is not enabled) - mock_models_instance.generate_content = mock.Mock(return_value=EXAMPLE_RESPONSE) - - # Should work without creating spans - # Use the mock instance from the fixture - response = mock_models_instance.generate_content( - "gemini-1.5-flash", "Test", config=create_test_config() - ) - - assert response == EXAMPLE_RESPONSE - - def test_tool_with_async_function(sentry_init, capture_events): """Test that async tool functions are properly wrapped""" sentry_init( @@ -1025,7 +801,7 @@ async def async_tool(param: str) -> str: assert hasattr(wrapped_async_tool, "__wrapped__") # Should preserve original -def test_contents_as_none(sentry_init, capture_events, mock_models_instance): +def test_contents_as_none(sentry_init, capture_events, mock_genai_client): """Test handling when contents parameter is None""" sentry_init( integrations=[GoogleGenAIIntegration(include_prompts=True)], @@ -1034,13 +810,15 @@ def test_contents_as_none(sentry_init, capture_events, mock_models_instance): ) events = capture_events() - setup_mock_generate_content(mock_models_instance, return_value=EXAMPLE_RESPONSE) + mock_http_response = create_mock_http_response(EXAMPLE_API_RESPONSE_JSON) - with start_transaction(name="google_genai"): - # Use the mock instance from the fixture - mock_models_instance.generate_content( - "gemini-1.5-flash", None, config=create_test_config() - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", contents=None, config=create_test_config() + ) (event,) = events invoke_span = event["spans"][0] @@ -1051,7 +829,7 @@ def test_contents_as_none(sentry_init, capture_events, mock_models_instance): assert all(msg["role"] != "user" or msg["content"] is not None for msg in messages) -def test_tool_calls_extraction(sentry_init, capture_events, mock_models_instance): +def test_tool_calls_extraction(sentry_init, capture_events, mock_genai_client): """Test extraction of tool/function calls from response""" sentry_init( integrations=[GoogleGenAIIntegration()], @@ -1059,45 +837,52 @@ def test_tool_calls_extraction(sentry_init, capture_events, mock_models_instance ) events = capture_events() - # Create a response with function calls - function_call_response = create_test_response( - candidates=[ - create_test_candidate( - content=genai_types.Content( - parts=[ - genai_types.Part(text="I'll help you with that."), - genai_types.Part( - function_call=genai_types.FunctionCall( - name="get_weather", - args={"location": "San Francisco", "unit": "celsius"}, - ) - ), - genai_types.Part( - function_call=genai_types.FunctionCall( - name="get_time", args={"timezone": "PST"} - ) - ), + # Response with function calls + function_call_response_json = { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"text": "I'll help you with that."}, + { + "functionCall": { + "name": "get_weather", + "args": { + "location": "San Francisco", + "unit": "celsius", + }, + } + }, + { + "functionCall": { + "name": "get_time", + "args": {"timezone": "PST"}, + } + }, ], - role="model", - ), - finish_reason=genai_types.FinishReason.STOP, - ) + }, + "finishReason": "STOP", + } ], - usage_metadata=create_test_usage_metadata( - prompt_token_count=20, candidates_token_count=30, total_token_count=50 - ), - ) + "usageMetadata": { + "promptTokenCount": 20, + "candidatesTokenCount": 30, + "totalTokenCount": 50, + }, + } - setup_mock_generate_content( - mock_models_instance, return_value=function_call_response - ) + mock_http_response = create_mock_http_response(function_call_response_json) - with start_transaction(name="google_genai"): - mock_models_instance.generate_content( - "gemini-1.5-flash", - "What's the weather and time?", - config=create_test_config(), - ) + with mock.patch.object( + mock_genai_client._api_client, "request", return_value=mock_http_response + ): + with start_transaction(name="google_genai"): + mock_genai_client.models.generate_content( + model="gemini-1.5-flash", + contents="What's the weather and time?", + config=create_test_config(), + ) (event,) = events chat_span = event["spans"][1] # The chat span @@ -1124,77 +909,3 @@ def test_tool_calls_extraction(sentry_init, capture_events, mock_models_instance assert tool_calls[1]["type"] == "function_call" # Arguments are serialized as JSON strings assert json.loads(tool_calls[1]["arguments"]) == {"timezone": "PST"} - - -def test_tool_calls_with_automatic_function_calling( - sentry_init, capture_events, mock_models_instance -): - """Test extraction of tool calls from automatic_function_calling_history""" - sentry_init( - integrations=[GoogleGenAIIntegration()], - traces_sample_rate=1.0, - ) - events = capture_events() - - # Create a response with automatic function calling history - response_with_auto_calls = create_test_response( - candidates=[ - create_test_candidate( - content=create_test_content( - parts=[create_test_part("Here's the information you requested.")] - ), - finish_reason=genai_types.FinishReason.STOP, - ) - ], - ) - - # Add automatic_function_calling_history - response_with_auto_calls.automatic_function_calling_history = [ - genai_types.Content( - parts=[ - genai_types.Part( - function_call=genai_types.FunctionCall( - name="search_database", - args={"query": "user stats", "limit": 10}, - ) - ) - ], - role="model", - ), - genai_types.Content( - parts=[ - genai_types.Part( - function_response=genai_types.FunctionResponse( - name="search_database", response={"results": ["item1", "item2"]} - ) - ) - ], - role="function", - ), - ] - - setup_mock_generate_content( - mock_models_instance, return_value=response_with_auto_calls - ) - - with start_transaction(name="google_genai"): - mock_models_instance.generate_content( - "gemini-1.5-flash", "Get user statistics", config=create_test_config() - ) - - (event,) = events - chat_span = event["spans"][1] # The chat span - - # Check that tool calls from automatic history are extracted - assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in chat_span["data"] - - tool_calls = json.loads(chat_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]) - - assert len(tool_calls) == 1 - assert tool_calls[0]["name"] == "search_database" - assert tool_calls[0]["type"] == "function_call" - # Arguments are serialized as JSON strings - assert json.loads(tool_calls[0]["arguments"]) == { - "query": "user stats", - "limit": 10, - } From 3d165f3e41e2c1c121e8205ecb1a3b553ac6b41f Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 8 Oct 2025 16:41:26 +0200 Subject: [PATCH 05/16] feedback --- scripts/populate_tox/releases.jsonl | 11 +++--- sentry_sdk/integrations/__init__.py | 1 - .../integrations/google_genai/__init__.py | 2 +- .../integrations/google_genai/streaming.py | 16 --------- sentry_sdk/integrations/google_genai/utils.py | 16 +++++++++ setup.py | 2 +- tox.ini | 34 ++++++++++++------- 7 files changed, 47 insertions(+), 35 deletions(-) diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index 9f937e5e77..d76c9c2304 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -66,9 +66,12 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": ">=3.5", "version": "3.1.3", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed"], "name": "falcon", "requires_python": ">=3.8", "version": "4.1.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.105.0", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.0", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.6.1", "version": "0.79.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.7", "version": "0.92.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.0.1", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.8.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.41.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.2.0b0", "yanked": false}} @@ -95,7 +98,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.28.1", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.32.6", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.8.0", "version": "0.35.3", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.9.0", "version": "1.0.0rc2", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Scientific/Engineering :: Artificial Intelligence"], "name": "huggingface-hub", "requires_python": ">=3.9.0", "version": "1.0.0rc4", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "langchain", "requires_python": "<4.0,>=3.8.1", "version": "0.1.20", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9"], "name": "langchain", "requires_python": "<4.0,>=3.8.1", "version": "0.2.17", "yanked": false}} {"info": {"classifiers": [], "name": "langchain", "requires_python": "<4.0,>=3.9", "version": "0.3.27", "yanked": false}} @@ -128,7 +131,7 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.5.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": "", "version": "3.6.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database"], "name": "pymongo", "requires_python": ">=3.6", "version": "4.0.2", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database", "Typing :: Typed"], "name": "pymongo", "requires_python": ">=3.9", "version": "4.15.2", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Database", "Typing :: Typed"], "name": "pymongo", "requires_python": ">=3.9", "version": "4.15.3", "yanked": false}} {"info": {"classifiers": ["Framework :: Pylons", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": null, "version": "1.0.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*", "version": "1.10.8", "yanked": false}} {"info": {"classifiers": ["Development Status :: 6 - Mature", "Framework :: Pyramid", "Intended Audience :: Developers", "License :: Repoze Public License", "Programming Language :: Python", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: WSGI"], "name": "pyramid", "requires_python": "", "version": "1.6.5", "yanked": false}} @@ -155,7 +158,7 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.7", "version": "4.6.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.8", "version": "5.3.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.9", "version": "6.4.0", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.9", "version": "7.0.0b2", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "redis", "requires_python": ">=3.9", "version": "7.0.0b3", "yanked": false}} {"info": {"classifiers": ["Development Status :: 3 - Alpha", "Environment :: Web Environment", "Operating System :: POSIX", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4"], "name": "redis-py-cluster", "requires_python": null, "version": "0.1.0", "yanked": false}} {"info": {"classifiers": [], "name": "redis-py-cluster", "requires_python": null, "version": "1.1.0", "yanked": false}} {"info": {"classifiers": [], "name": "redis-py-cluster", "requires_python": null, "version": "1.2.0", "yanked": false}} diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 43585023eb..d734cd3858 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -91,7 +91,6 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.flask.FlaskIntegration", "sentry_sdk.integrations.gql.GQLIntegration", "sentry_sdk.integrations.graphene.GrapheneIntegration", - "sentry_sdk.integrations.google_genai.GoogleGenAIIntegration", "sentry_sdk.integrations.httpx.HttpxIntegration", "sentry_sdk.integrations.huey.HueyIntegration", "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", diff --git a/sentry_sdk/integrations/google_genai/__init__.py b/sentry_sdk/integrations/google_genai/__init__.py index 34d97efbc2..f453e4f37d 100644 --- a/sentry_sdk/integrations/google_genai/__init__.py +++ b/sentry_sdk/integrations/google_genai/__init__.py @@ -24,11 +24,11 @@ set_span_data_for_request, set_span_data_for_response, capture_exception, + prepare_generate_content_args, ) from .streaming import ( set_span_data_for_streaming_response, accumulate_streaming_response, - prepare_generate_content_args, ) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index f70908d405..57f15ad304 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -24,22 +24,6 @@ from google.genai.types import GenerateContentResponse -def prepare_generate_content_args(args, kwargs): - # type: (tuple[Any, ...], dict[str, Any]) -> tuple[Any, Any, str] - """Extract and prepare common arguments for generate_content methods.""" - model = args[0] if args else kwargs.get("model", "unknown") - contents = args[1] if len(args) > 1 else kwargs.get("contents") - model_name = get_model_name(model) - - # Wrap config with tools - config = kwargs.get("config") - wrapped_config = wrapped_config_with_tools(config) - if wrapped_config is not config: - kwargs["config"] = wrapped_config - - return model, contents, model_name - - def accumulate_streaming_response(chunks): # type: (List[GenerateContentResponse]) -> dict[str, Any] """Accumulate streaming chunks into a single response-like object.""" diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 21af75a334..c538830f3d 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -561,3 +561,19 @@ def set_span_data_for_response(span, integration, response): # Set total token count if available if usage_data["total_tokens"]: span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) + + +def prepare_generate_content_args(args, kwargs): + # type: (tuple[Any, ...], dict[str, Any]) -> tuple[Any, Any, str] + """Extract and prepare common arguments for generate_content methods.""" + model = args[0] if args else kwargs.get("model", "unknown") + contents = args[1] if len(args) > 1 else kwargs.get("contents") + model_name = get_model_name(model) + + # Wrap config with tools + config = kwargs.get("config") + wrapped_config = wrapped_config_with_tools(config) + if wrapped_config is not config: + kwargs["config"] = wrapped_config + + return model, contents, model_name diff --git a/setup.py b/setup.py index e874a182e4..8b4c09490d 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ def get_file_text(file_name): "statsig": ["statsig>=0.55.3"], "tornado": ["tornado>=6"], "unleash": ["UnleashClient>=6.0.1"], - "google-genai": ["google-genai"], + "google-genai": ["google-genai>=1.0.0"], }, entry_points={ "opentelemetry_propagator": [ diff --git a/tox.ini b/tox.ini index 2c77edd07c..ac571ac9cc 100644 --- a/tox.ini +++ b/tox.ini @@ -78,6 +78,10 @@ envlist = {py3.9,py3.12,py3.13}-langgraph-v0.6.8 {py3.10,py3.12,py3.13}-langgraph-v1.0.0a4 + {py3.9,py3.11,py3.12}-google-genai-v0.0.1 + {py3.9,py3.12,py3.13}-google-genai-v0.8.0 + {py3.9,py3.12,py3.13}-google-genai-v1.41.0 + {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 {py3.10,py3.12,py3.13}-openai_agents-v0.2.11 @@ -87,14 +91,14 @@ envlist = {py3.8,py3.12,py3.13}-huggingface_hub-v0.28.1 {py3.8,py3.12,py3.13}-huggingface_hub-v0.32.6 {py3.8,py3.12,py3.13}-huggingface_hub-v0.35.3 - {py3.9,py3.12,py3.13}-huggingface_hub-v1.0.0rc2 + {py3.9,py3.12,py3.13}-huggingface_hub-v1.0.0rc4 # ~~~ Cloud ~~~ {py3.6,py3.7}-boto3-v1.12.49 {py3.6,py3.9,py3.10}-boto3-v1.20.54 {py3.7,py3.11,py3.12}-boto3-v1.28.85 - {py3.9,py3.12,py3.13}-boto3-v1.40.46 + {py3.9,py3.12,py3.13}-boto3-v1.40.47 {py3.6,py3.7,py3.8}-chalice-v1.16.0 {py3.9,py3.12,py3.13}-chalice-v1.32.0 @@ -110,14 +114,14 @@ envlist = {py3.6}-pymongo-v3.5.1 {py3.6,py3.10,py3.11}-pymongo-v3.13.0 - {py3.9,py3.12,py3.13}-pymongo-v4.15.2 + {py3.9,py3.12,py3.13}-pymongo-v4.15.3 {py3.6}-redis-v2.10.6 {py3.6,py3.7,py3.8}-redis-v3.5.3 {py3.7,py3.10,py3.11}-redis-v4.6.0 {py3.8,py3.11,py3.12}-redis-v5.3.1 {py3.9,py3.12,py3.13}-redis-v6.4.0 - {py3.9,py3.12,py3.13}-redis-v7.0.0b2 + {py3.9,py3.12,py3.13}-redis-v7.0.0b3 {py3.6}-redis_py_cluster_legacy-v1.3.6 {py3.6,py3.7,py3.8}-redis_py_cluster_legacy-v2.1.3 @@ -153,7 +157,7 @@ envlist = {py3.8,py3.12,py3.13}-graphene-v3.4.3 {py3.8,py3.10,py3.11}-strawberry-v0.209.8 - {py3.9,py3.12,py3.13}-strawberry-v0.283.1 + {py3.9,py3.12,py3.13}-strawberry-v0.283.2 # ~~~ Network ~~~ @@ -222,7 +226,7 @@ envlist = {py3.6,py3.9,py3.10}-fastapi-v0.79.1 {py3.7,py3.10,py3.11}-fastapi-v0.92.0 {py3.8,py3.10,py3.11}-fastapi-v0.105.0 - {py3.8,py3.12,py3.13}-fastapi-v0.118.0 + {py3.8,py3.12,py3.13}-fastapi-v0.118.1 # ~~~ Web 2 ~~~ @@ -381,6 +385,11 @@ deps = langgraph-v0.6.8: langgraph==0.6.8 langgraph-v1.0.0a4: langgraph==1.0.0a4 + google-genai-v0.0.1: google-genai==0.0.1 + google-genai-v0.8.0: google-genai==0.8.0 + google-genai-v1.41.0: google-genai==1.41.0 + google-genai: pytest-asyncio + openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 openai_agents-v0.2.11: openai-agents==0.2.11 @@ -391,7 +400,7 @@ deps = huggingface_hub-v0.28.1: huggingface_hub==0.28.1 huggingface_hub-v0.32.6: huggingface_hub==0.32.6 huggingface_hub-v0.35.3: huggingface_hub==0.35.3 - huggingface_hub-v1.0.0rc2: huggingface_hub==1.0.0rc2 + huggingface_hub-v1.0.0rc4: huggingface_hub==1.0.0rc4 huggingface_hub: responses huggingface_hub: pytest-httpx @@ -400,7 +409,7 @@ deps = boto3-v1.12.49: boto3==1.12.49 boto3-v1.20.54: boto3==1.20.54 boto3-v1.28.85: boto3==1.28.85 - boto3-v1.40.46: boto3==1.40.46 + boto3-v1.40.47: boto3==1.40.47 {py3.7,py3.8}-boto3: urllib3<2.0.0 chalice-v1.16.0: chalice==1.16.0 @@ -419,7 +428,7 @@ deps = pymongo-v3.5.1: pymongo==3.5.1 pymongo-v3.13.0: pymongo==3.13.0 - pymongo-v4.15.2: pymongo==4.15.2 + pymongo-v4.15.3: pymongo==4.15.3 pymongo: mockupdb redis-v2.10.6: redis==2.10.6 @@ -427,7 +436,7 @@ deps = redis-v4.6.0: redis==4.6.0 redis-v5.3.1: redis==5.3.1 redis-v6.4.0: redis==6.4.0 - redis-v7.0.0b2: redis==7.0.0b2 + redis-v7.0.0b3: redis==7.0.0b3 redis: fakeredis!=1.7.4 redis: pytest<8.0.0 redis-v4.6.0: fakeredis<2.31.0 @@ -477,7 +486,7 @@ deps = {py3.6}-graphene: aiocontextvars strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 - strawberry-v0.283.1: strawberry-graphql[fastapi,flask]==0.283.1 + strawberry-v0.283.2: strawberry-graphql[fastapi,flask]==0.283.2 strawberry: httpx strawberry-v0.209.8: pydantic<2.11 @@ -604,7 +613,7 @@ deps = fastapi-v0.79.1: fastapi==0.79.1 fastapi-v0.92.0: fastapi==0.92.0 fastapi-v0.105.0: fastapi==0.105.0 - fastapi-v0.118.0: fastapi==0.118.0 + fastapi-v0.118.1: fastapi==0.118.1 fastapi: httpx fastapi: pytest-asyncio fastapi: python-multipart @@ -747,6 +756,7 @@ setenv = falcon: TESTPATH=tests/integrations/falcon fastapi: TESTPATH=tests/integrations/fastapi flask: TESTPATH=tests/integrations/flask + google-genai: TESTPATH=tests/integrations/google-genai gql: TESTPATH=tests/integrations/gql graphene: TESTPATH=tests/integrations/graphene grpc: TESTPATH=tests/integrations/grpc From 9e03a6433cea2b65db60c0d1c021a610c9a1ecf7 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 8 Oct 2025 17:36:53 +0200 Subject: [PATCH 06/16] apply all the feedback --- .../integrations/google_genai/__init__.py | 117 ++++++++++-------- .../integrations/google_genai/streaming.py | 38 ++---- sentry_sdk/integrations/google_genai/utils.py | 29 ++--- tests/integrations/google_genai/__init__.py | 4 + .../google_genai/test_google_genai.py | 8 +- 5 files changed, 92 insertions(+), 104 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/__init__.py b/sentry_sdk/integrations/google_genai/__init__.py index f453e4f37d..657e2ffd05 100644 --- a/sentry_sdk/integrations/google_genai/__init__.py +++ b/sentry_sdk/integrations/google_genai/__init__.py @@ -8,8 +8,10 @@ ) import sentry_sdk +from sentry_sdk.ai.utils import get_start_span_function from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.tracing import SPANSTATUS try: @@ -23,7 +25,7 @@ from .utils import ( set_span_data_for_request, set_span_data_for_response, - capture_exception, + _capture_exception, prepare_generate_content_args, ) from .streaming import ( @@ -69,11 +71,12 @@ def new_generate_content_stream(self, *args, **kwargs): _model, contents, model_name = prepare_generate_content_args(args, kwargs) - span = sentry_sdk.start_span( + span = get_start_span_function()( op=OP.GEN_AI_INVOKE_AGENT, name="invoke_agent", origin=ORIGIN, ) + span.__enter__() span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") set_span_data_for_request(span, integration, model_name, contents, kwargs) @@ -84,6 +87,7 @@ def new_generate_content_stream(self, *args, **kwargs): name=f"chat {model_name}", origin=ORIGIN, ) + chat_span.__enter__() chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) @@ -102,7 +106,8 @@ def new_iterator(): chunks.append(chunk) yield chunk except Exception as exc: - capture_exception(exc) + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.ERROR) raise finally: # Accumulate all chunks and set final response data on spans @@ -114,15 +119,15 @@ def new_iterator(): set_span_data_for_streaming_response( span, integration, accumulated_response ) - chat_span.finish() - span.finish() + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) return new_iterator() except Exception as exc: - capture_exception(exc) - chat_span.finish() - span.finish() + _capture_exception(exc) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) raise return new_generate_content_stream @@ -139,11 +144,12 @@ async def new_async_generate_content_stream(self, *args, **kwargs): _model, contents, model_name = prepare_generate_content_args(args, kwargs) - span = sentry_sdk.start_span( + span = get_start_span_function()( op=OP.GEN_AI_INVOKE_AGENT, name="invoke_agent", origin=ORIGIN, ) + span.__enter__() span.set_data(SPANDATA.GEN_AI_AGENT_NAME, model_name) span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") set_span_data_for_request(span, integration, model_name, contents, kwargs) @@ -154,6 +160,7 @@ async def new_async_generate_content_stream(self, *args, **kwargs): name=f"chat {model_name}", origin=ORIGIN, ) + chat_span.__enter__() chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) @@ -172,7 +179,8 @@ async def new_async_iterator(): chunks.append(chunk) yield chunk except Exception as exc: - capture_exception(exc) + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.ERROR) raise finally: # Accumulate all chunks and set final response data on spans @@ -184,15 +192,15 @@ async def new_async_iterator(): set_span_data_for_streaming_response( span, integration, accumulated_response ) - chat_span.finish() - span.finish() + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) return new_async_iterator() except Exception as exc: - capture_exception(exc) - chat_span.finish() - span.finish() + _capture_exception(exc) + chat_span.__exit__(None, None, None) + span.__exit__(None, None, None) raise return new_async_generate_content_stream @@ -209,7 +217,7 @@ def new_generate_content(self, *args, **kwargs): model, contents, model_name = prepare_generate_content_args(args, kwargs) - with sentry_sdk.start_span( + with get_start_span_function()( op=OP.GEN_AI_INVOKE_AGENT, name="invoke_agent", origin=ORIGIN, @@ -218,28 +226,29 @@ def new_generate_content(self, *args, **kwargs): span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") set_span_data_for_request(span, integration, model_name, contents, kwargs) - try: - with sentry_sdk.start_span( - op=OP.GEN_AI_CHAT, - name=f"chat {model_name}", - origin=ORIGIN, - ) as chat_span: - chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") - chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) - chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) - set_span_data_for_request( - chat_span, integration, model_name, contents, kwargs - ) + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + try: response = f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.ERROR) + raise - set_span_data_for_response(chat_span, integration, response) - set_span_data_for_response(span, integration, response) + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) - return response - except Exception as exc: - capture_exception(exc) - raise + return response return new_generate_content @@ -255,7 +264,7 @@ async def new_async_generate_content(self, *args, **kwargs): model, contents, model_name = prepare_generate_content_args(args, kwargs) - with sentry_sdk.start_span( + with get_start_span_function()( op=OP.GEN_AI_INVOKE_AGENT, name="invoke_agent", origin=ORIGIN, @@ -264,27 +273,27 @@ async def new_async_generate_content(self, *args, **kwargs): span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") set_span_data_for_request(span, integration, model_name, contents, kwargs) - try: - with sentry_sdk.start_span( - op=OP.GEN_AI_CHAT, - name=f"chat {model_name}", - origin=ORIGIN, - ) as chat_span: - chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") - chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) - chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) - set_span_data_for_request( - chat_span, integration, model_name, contents, kwargs - ) - + with sentry_sdk.start_span( + op=OP.GEN_AI_CHAT, + name=f"chat {model_name}", + origin=ORIGIN, + ) as chat_span: + chat_span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "chat") + chat_span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) + chat_span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model_name) + set_span_data_for_request( + chat_span, integration, model_name, contents, kwargs + ) + try: response = await f(self, *args, **kwargs) + except Exception as exc: + _capture_exception(exc) + chat_span.set_status(SPANSTATUS.ERROR) + raise - set_span_data_for_response(chat_span, integration, response) - set_span_data_for_response(span, integration, response) + set_span_data_for_response(chat_span, integration, response) + set_span_data_for_response(span, integration, response) - return response - except Exception as exc: - capture_exception(exc) - raise + return response return new_async_generate_content diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 57f15ad304..0363b5d7ec 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -41,10 +41,10 @@ def accumulate_streaming_response(chunks): for chunk in chunks: # Extract text and tool calls - if hasattr(chunk, "candidates") and chunk.candidates: + if getattr(chunk, "candidates", None): for candidate in chunk.candidates: - if hasattr(candidate, "content") and hasattr( - candidate.content, "parts" + if hasattr(candidate, "content") and getattr( + candidate.content, "parts", [] ): extracted_text = extract_contents_text(candidate.content) if extracted_text: @@ -59,41 +59,23 @@ def accumulate_streaming_response(chunks): tool_calls.extend(extracted_tool_calls) # Accumulate token usage - if hasattr(chunk, "usage_metadata") and chunk.usage_metadata: + if getattr(chunk, "usage_metadata", None): usage = chunk.usage_metadata - if ( - hasattr(usage, "prompt_token_count") - and usage.prompt_token_count is not None - ): + if getattr(usage, "prompt_token_count", None): total_prompt_tokens = max(total_prompt_tokens, usage.prompt_token_count) - if ( - hasattr(usage, "tool_use_prompt_token_count") - and usage.tool_use_prompt_token_count is not None - ): + if getattr(usage, "tool_use_prompt_token_count", None): total_tool_use_prompt_tokens = max( total_tool_use_prompt_tokens, usage.tool_use_prompt_token_count ) - if ( - hasattr(usage, "candidates_token_count") - and usage.candidates_token_count is not None - ): + if getattr(usage, "candidates_token_count", None): total_output_tokens += usage.candidates_token_count - if ( - hasattr(usage, "cached_content_token_count") - and usage.cached_content_token_count is not None - ): + if getattr(usage, "cached_content_token_count", None): total_cached_tokens = max( total_cached_tokens, usage.cached_content_token_count ) - if ( - hasattr(usage, "thoughts_token_count") - and usage.thoughts_token_count is not None - ): + if getattr(usage, "thoughts_token_count", None): total_reasoning_tokens += usage.thoughts_token_count - if ( - hasattr(usage, "total_token_count") - and usage.total_token_count is not None - ): + if getattr(usage, "total_token_count", None): # Only use the final total_token_count from the last chunk total_tokens = usage.total_token_count diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index c538830f3d..6c48be2c87 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -119,7 +119,7 @@ def extract_usage_data(response): return usage_data -def capture_exception(exc): +def _capture_exception(exc): # type: (Any) -> None """Capture exception with Google GenAI mechanism.""" event, hint = event_from_exception( @@ -170,7 +170,7 @@ def extract_contents_text(contents): return extract_contents_text(contents["parts"]) # Content object with parts - recurse into parts - if hasattr(contents, "parts") and contents.parts: + if getattr(contents, "parts", None): return extract_contents_text(contents.parts) # Direct text attribute @@ -236,7 +236,7 @@ def extract_tool_calls(response): continue for part in candidate.content.parts: - if hasattr(part, "function_call") and part.function_call: + if getattr(part, "function_call", None): function_call = part.function_call tool_call = { "name": getattr(function_call, "name", None), @@ -251,16 +251,13 @@ def extract_tool_calls(response): # Extract from automatic_function_calling_history # This is the history of tool calls made by the model - if ( - hasattr(response, "automatic_function_calling_history") - and response.automatic_function_calling_history - ): + if getattr(response, "automatic_function_calling_history", None): for content in response.automatic_function_calling_history: - if not hasattr(content, "parts") or not content.parts: + if not getattr(content, "parts", None): continue for part in content.parts: - if hasattr(part, "function_call") and part.function_call: + if getattr(part, "function_call", None): function_call = part.function_call tool_call = { "name": getattr(function_call, "name", None), @@ -345,7 +342,7 @@ async def async_wrapped(*args, **kwargs): return result except Exception as exc: - capture_exception(exc) + _capture_exception(exc) raise return async_wrapped @@ -373,7 +370,7 @@ def sync_wrapped(*args, **kwargs): return result except Exception as exc: - capture_exception(exc) + _capture_exception(exc) raise return sync_wrapped @@ -384,7 +381,7 @@ def wrapped_config_with_tools(config): """Wrap tools in config to emit execute_tool spans. Tools are sometimes passed directly as callable functions as a part of the config object.""" - if not config or not hasattr(config, "tools") or not config.tools: + if not config or not getattr(config, "tools", None): return config result = copy.copy(config) @@ -406,7 +403,7 @@ def _extract_response_text(response): continue for part in candidate.content.parts: - if hasattr(part, "text") and part.text: + if getattr(part, "text", None): texts.append(part.text) return texts if texts else None @@ -420,7 +417,7 @@ def extract_finish_reasons(response): finish_reasons = [] for candidate in response.candidates: - if hasattr(candidate, "finish_reason") and candidate.finish_reason: + if getattr(candidate, "finish_reason", None): # Convert enum value to string if necessary reason = str(candidate.finish_reason) # Remove enum prefix if present (e.g., "FinishReason.STOP" -> "STOP") @@ -530,11 +527,11 @@ def set_span_data_for_response(span, integration, response): ) # Set response ID if available - if hasattr(response, "response_id") and response.response_id: + if getattr(response, "response_id", None): span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, response.response_id) # Set response model if available - if hasattr(response, "model_version") and response.model_version: + if getattr(response, "model_version", None): span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_version) # Set token usage if available diff --git a/tests/integrations/google_genai/__init__.py b/tests/integrations/google_genai/__init__.py index e69de29bb2..5143bf4536 100644 --- a/tests/integrations/google_genai/__init__.py +++ b/tests/integrations/google_genai/__init__.py @@ -0,0 +1,4 @@ +import pytest + +pytest.importorskip("google") +pytest.importorskip("google.genai") diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index fb32705f3a..c9d26f1a2b 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -2,12 +2,8 @@ import pytest from unittest import mock -try: - from google import genai - from google.genai import types as genai_types -except ImportError: - # If google.genai is not installed, skip the tests - pytest.skip("google-genai not installed", allow_module_level=True) +from google import genai +from google.genai import types as genai_types from sentry_sdk import start_transaction from sentry_sdk.consts import OP, SPANDATA From a34799c6b3228763029e6a91df478673a96f1d46 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:06:47 +0200 Subject: [PATCH 07/16] refactor streaming --- .../integrations/google_genai/streaming.py | 122 +++++++----------- .../google_genai/test_google_genai.py | 16 +-- 2 files changed, 58 insertions(+), 80 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 0363b5d7ec..90b1cddfea 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -2,6 +2,8 @@ TYPE_CHECKING, Any, List, + TypedDict, + Optional, ) from sentry_sdk.ai.utils import set_data_normalized @@ -11,12 +13,11 @@ safe_serialize, ) from .utils import ( - get_model_name, - wrapped_config_with_tools, extract_tool_calls, extract_finish_reasons, extract_contents_text, extract_usage_data, + UsageData, ) if TYPE_CHECKING: @@ -24,14 +25,22 @@ from google.genai.types import GenerateContentResponse +class AccumulatedResponse(TypedDict): + id: Optional[str] + model: Optional[str] + text: str + finish_reasons: List[str] + tool_calls: List[str] + usage_metadata: UsageData + + def accumulate_streaming_response(chunks): # type: (List[GenerateContentResponse]) -> dict[str, Any] """Accumulate streaming chunks into a single response-like object.""" accumulated_text = [] finish_reasons = [] tool_calls = [] - total_prompt_tokens = 0 - total_tool_use_prompt_tokens = 0 + total_input_tokens = 0 total_output_tokens = 0 total_tokens = 0 total_cached_tokens = 0 @@ -59,63 +68,26 @@ def accumulate_streaming_response(chunks): tool_calls.extend(extracted_tool_calls) # Accumulate token usage - if getattr(chunk, "usage_metadata", None): - usage = chunk.usage_metadata - if getattr(usage, "prompt_token_count", None): - total_prompt_tokens = max(total_prompt_tokens, usage.prompt_token_count) - if getattr(usage, "tool_use_prompt_token_count", None): - total_tool_use_prompt_tokens = max( - total_tool_use_prompt_tokens, usage.tool_use_prompt_token_count - ) - if getattr(usage, "candidates_token_count", None): - total_output_tokens += usage.candidates_token_count - if getattr(usage, "cached_content_token_count", None): - total_cached_tokens = max( - total_cached_tokens, usage.cached_content_token_count - ) - if getattr(usage, "thoughts_token_count", None): - total_reasoning_tokens += usage.thoughts_token_count - if getattr(usage, "total_token_count", None): - # Only use the final total_token_count from the last chunk - total_tokens = usage.total_token_count + extracted_usage_data = extract_usage_data(chunk) + total_input_tokens += extracted_usage_data["input_tokens"] + total_output_tokens += extracted_usage_data["output_tokens"] + total_cached_tokens += extracted_usage_data["input_tokens_cached"] + total_reasoning_tokens += extracted_usage_data["output_tokens_reasoning"] + total_tokens += extracted_usage_data["total_tokens"] # Create a synthetic response object with accumulated data - accumulated_response = { - "text": "".join(accumulated_text), - "finish_reasons": finish_reasons, - "tool_calls": tool_calls, - "usage_metadata": { - "prompt_token_count": total_prompt_tokens, - "candidates_token_count": total_output_tokens, # Keep original output tokens - "cached_content_token_count": total_cached_tokens, - "thoughts_token_count": total_reasoning_tokens, - "total_token_count": ( - total_tokens - if total_tokens > 0 - else ( - total_prompt_tokens - + total_tool_use_prompt_tokens - + total_output_tokens - + total_reasoning_tokens - + total_cached_tokens - ) - ), - }, - } - - # Add optional token counts if present - if total_tool_use_prompt_tokens > 0: - accumulated_response["usage_metadata"][ - "tool_use_prompt_token_count" - ] = total_tool_use_prompt_tokens - if total_cached_tokens > 0: - accumulated_response["usage_metadata"][ - "cached_content_token_count" - ] = total_cached_tokens - if total_reasoning_tokens > 0: - accumulated_response["usage_metadata"][ - "thoughts_token_count" - ] = total_reasoning_tokens + accumulated_response = AccumulatedResponse( + text="".join(accumulated_text), + finish_reasons=finish_reasons, + tool_calls=tool_calls, + usage_metadata=UsageData( + input_tokens=total_input_tokens, + output_tokens=total_output_tokens, + input_tokens_cached=total_cached_tokens, + output_tokens_reasoning=total_reasoning_tokens, + total_tokens=total_tokens, + ), + ) if response_id: accumulated_response["id"] = response_id @@ -160,28 +132,34 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response if accumulated_response.get("model"): span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, accumulated_response["model"]) - # Set token usage - usage_data = extract_usage_data(accumulated_response) - - if usage_data["input_tokens"]: - span.set_data(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, usage_data["input_tokens"]) + if accumulated_response["usage_metadata"]["input_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + accumulated_response["usage_metadata"]["input_tokens"], + ) - if usage_data["input_tokens_cached"]: + if accumulated_response["usage_metadata"]["input_tokens_cached"]: span.set_data( SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, - usage_data["input_tokens_cached"], + accumulated_response["usage_metadata"]["input_tokens_cached"], ) # Output tokens already include reasoning tokens from extract_usage_data - if usage_data["output_tokens"]: - span.set_data(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage_data["output_tokens"]) + if accumulated_response["usage_metadata"]["output_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, + accumulated_response["usage_metadata"]["output_tokens"], + ) - if usage_data["output_tokens_reasoning"]: + if accumulated_response["usage_metadata"]["output_tokens_reasoning"]: span.set_data( SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING, - usage_data["output_tokens_reasoning"], + accumulated_response["usage_metadata"]["output_tokens_reasoning"], ) # Set total token count if available - if usage_data["total_tokens"]: - span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) + if accumulated_response["usage_metadata"]["total_tokens"]: + span.set_data( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, + accumulated_response["usage_metadata"]["total_tokens"], + ) diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index c9d26f1a2b..bf58f76e03 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -410,7 +410,7 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_genai_clie "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 2, - "totalTokenCount": 0, # Not set in intermediate chunks + "totalTokenCount": 12, # Not set in intermediate chunks }, "responseId": "response-id-stream-123", "modelVersion": "gemini-1.5-flash", @@ -429,7 +429,7 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_genai_clie "usageMetadata": { "promptTokenCount": 10, "candidatesTokenCount": 3, - "totalTokenCount": 0, + "totalTokenCount": 13, }, } @@ -446,8 +446,8 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_genai_clie ], "usageMetadata": { "promptTokenCount": 10, - "candidatesTokenCount": 7, # Total output tokens across all chunks - "totalTokenCount": 22, # Final total from last chunk + "candidatesTokenCount": 7, + "totalTokenCount": 25, "cachedContentTokenCount": 5, "thoughtsTokenCount": 3, }, @@ -505,8 +505,8 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_genai_clie # Verify token counts - should reflect accumulated values # Input tokens: max of all chunks = 10 - assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 - assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 30 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 30 # Output tokens: candidates (2 + 3 + 7 = 12) + reasoning (3) = 15 # Note: output_tokens includes both candidates and reasoning tokens @@ -514,8 +514,8 @@ def test_streaming_generate_content(sentry_init, capture_events, mock_genai_clie assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 15 # Total tokens: from the last chunk - assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 22 - assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 22 + assert chat_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 50 + assert invoke_span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 50 # Cached tokens: max of all chunks = 5 assert chat_span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED] == 5 From e003535be8b785de8a1fbfb683ce94c61a6dd4b2 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:12:47 +0200 Subject: [PATCH 08/16] regenerate test files --- scripts/populate_tox/config.py | 2 +- scripts/populate_tox/releases.jsonl | 8 ++++---- tests/integrations/google_genai/test_google_genai.py | 2 +- tox.ini | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 0f688c12a7..be3594a1e2 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -147,7 +147,7 @@ "deps": { "*": ["pytest-asyncio"], }, - "python": ">=3.8", + "python": ">=3.9", }, "graphene": { "package": "graphene", diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index d76c9c2304..b7bed7c07d 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -46,7 +46,7 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7"], "name": "boto3", "requires_python": "", "version": "1.12.49", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.6", "version": "1.20.54", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">= 3.7", "version": "1.28.85", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.9", "version": "1.40.46", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9"], "name": "boto3", "requires_python": ">=3.9", "version": "1.40.48", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 2.5", "Programming Language :: Python :: 2.6", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.2", "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": "", "version": "0.12.25", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet :: WWW/HTTP :: Dynamic Content :: CGI Tools/Libraries", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "bottle", "requires_python": null, "version": "0.13.4", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Object Brokering", "Topic :: System :: Distributed Computing"], "name": "celery", "requires_python": ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", "version": "4.4.7", "yanked": false}} @@ -66,12 +66,12 @@ {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "falcon", "requires_python": ">=3.5", "version": "3.1.3", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX", "Programming Language :: Cython", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP :: WSGI", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed"], "name": "falcon", "requires_python": ">=3.8", "version": "4.1.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.105.0", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.1", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.6.1", "version": "0.79.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.7", "version": "0.92.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.0.1", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.8.0", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.41.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.42.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.2.0b0", "yanked": false}} @@ -194,7 +194,7 @@ {"info": {"classifiers": ["Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries"], "name": "statsig", "requires_python": ">=3.7", "version": "0.55.3", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries"], "name": "statsig", "requires_python": ">=3.7", "version": "0.65.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "strawberry-graphql", "requires_python": ">=3.8,<4.0", "version": "0.209.8", "yanked": false}} -{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "strawberry-graphql", "requires_python": "<4.0,>=3.9", "version": "0.283.1", "yanked": false}} +{"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "strawberry-graphql", "requires_python": "<4.0,>=3.9", "version": "0.283.2", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "tornado", "requires_python": ">= 3.5", "version": "6.0.4", "yanked": false}} {"info": {"classifiers": ["License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy"], "name": "tornado", "requires_python": ">=3.9", "version": "6.5.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Environment :: No Input/Output (Daemon)", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License (GPL)", "Natural Language :: English", "Natural Language :: French", "Natural Language :: German", "Natural Language :: Spanish", "Operating System :: OS Independent", "Programming Language :: Python", "Topic :: Software Development :: Libraries :: Application Frameworks"], "name": "trytond", "requires_python": null, "version": "1.2.10", "yanked": false}} diff --git a/tests/integrations/google_genai/test_google_genai.py b/tests/integrations/google_genai/test_google_genai.py index bf58f76e03..470be31944 100644 --- a/tests/integrations/google_genai/test_google_genai.py +++ b/tests/integrations/google_genai/test_google_genai.py @@ -145,7 +145,7 @@ def test_nonstreaming_generate_content( ): with start_transaction(name="google_genai"): config = create_test_config(temperature=0.7, max_output_tokens=100) - response = mock_genai_client.models.generate_content( + mock_genai_client.models.generate_content( model="gemini-1.5-flash", contents="Tell me a joke", config=config ) assert len(events) == 1 diff --git a/tox.ini b/tox.ini index ac571ac9cc..a25da08751 100644 --- a/tox.ini +++ b/tox.ini @@ -80,7 +80,7 @@ envlist = {py3.9,py3.11,py3.12}-google-genai-v0.0.1 {py3.9,py3.12,py3.13}-google-genai-v0.8.0 - {py3.9,py3.12,py3.13}-google-genai-v1.41.0 + {py3.9,py3.12,py3.13}-google-genai-v1.42.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -98,7 +98,7 @@ envlist = {py3.6,py3.7}-boto3-v1.12.49 {py3.6,py3.9,py3.10}-boto3-v1.20.54 {py3.7,py3.11,py3.12}-boto3-v1.28.85 - {py3.9,py3.12,py3.13}-boto3-v1.40.47 + {py3.9,py3.12,py3.13}-boto3-v1.40.48 {py3.6,py3.7,py3.8}-chalice-v1.16.0 {py3.9,py3.12,py3.13}-chalice-v1.32.0 @@ -226,7 +226,7 @@ envlist = {py3.6,py3.9,py3.10}-fastapi-v0.79.1 {py3.7,py3.10,py3.11}-fastapi-v0.92.0 {py3.8,py3.10,py3.11}-fastapi-v0.105.0 - {py3.8,py3.12,py3.13}-fastapi-v0.118.1 + {py3.8,py3.12,py3.13}-fastapi-v0.118.2 # ~~~ Web 2 ~~~ @@ -387,7 +387,7 @@ deps = google-genai-v0.0.1: google-genai==0.0.1 google-genai-v0.8.0: google-genai==0.8.0 - google-genai-v1.41.0: google-genai==1.41.0 + google-genai-v1.42.0: google-genai==1.42.0 google-genai: pytest-asyncio openai_agents-v0.0.19: openai-agents==0.0.19 @@ -409,7 +409,7 @@ deps = boto3-v1.12.49: boto3==1.12.49 boto3-v1.20.54: boto3==1.20.54 boto3-v1.28.85: boto3==1.28.85 - boto3-v1.40.47: boto3==1.40.47 + boto3-v1.40.48: boto3==1.40.48 {py3.7,py3.8}-boto3: urllib3<2.0.0 chalice-v1.16.0: chalice==1.16.0 @@ -613,7 +613,7 @@ deps = fastapi-v0.79.1: fastapi==0.79.1 fastapi-v0.92.0: fastapi==0.92.0 fastapi-v0.105.0: fastapi==0.105.0 - fastapi-v0.118.1: fastapi==0.118.1 + fastapi-v0.118.2: fastapi==0.118.2 fastapi: httpx fastapi: pytest-asyncio fastapi: python-multipart From dc0a14763a77d9031fab32eae4aefe3d9caf9bf6 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:27:42 +0200 Subject: [PATCH 09/16] mypy fixes --- .../integrations/google_genai/streaming.py | 7 ++----- sentry_sdk/integrations/google_genai/utils.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 90b1cddfea..2291967d31 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -87,13 +87,10 @@ def accumulate_streaming_response(chunks): output_tokens_reasoning=total_reasoning_tokens, total_tokens=total_tokens, ), + id=response_id, + model=model, ) - if response_id: - accumulated_response["id"] = response_id - if model: - accumulated_response["model"] = model - return accumulated_response diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 6c48be2c87..e29218995e 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -171,7 +171,7 @@ def extract_contents_text(contents): # Content object with parts - recurse into parts if getattr(contents, "parts", None): - return extract_contents_text(contents.parts) + return extract_contents_text(contents.parts) # type: ignore # Direct text attribute if hasattr(contents, "text"): @@ -228,14 +228,14 @@ def extract_tool_calls(response): tool_calls = [] # Extract from candidates, sometimes tool calls are nested under the content.parts object - if hasattr(response, "candidates"): - for candidate in response.candidates: - if not hasattr(candidate, "content") or not hasattr( - candidate.content, "parts" + if getattr(response, "candidates", []): + for candidate in response.candidates: # type: ignore + if not hasattr(candidate, "content") or not getattr( + candidate.content, "parts", [] ): continue - for part in candidate.content.parts: + for part in candidate.content.parts: # type: ignore if getattr(part, "function_call", None): function_call = part.function_call tool_call = { @@ -244,8 +244,8 @@ def extract_tool_calls(response): } # Extract arguments if available - if hasattr(function_call, "args"): - tool_call["arguments"] = safe_serialize(function_call.args) + if getattr(function_call, "args", None): + tool_call["arguments"] = safe_serialize(function_call.args) # type: ignore tool_calls.append(tool_call) From 2010a6c6b092ba64e444eadf202d3538e89db8ca Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:43:34 +0200 Subject: [PATCH 10/16] fix linter --- sentry_sdk/integrations/google_genai/__init__.py | 1 - sentry_sdk/integrations/google_genai/streaming.py | 4 ++-- sentry_sdk/integrations/google_genai/utils.py | 8 ++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/__init__.py b/sentry_sdk/integrations/google_genai/__init__.py index 657e2ffd05..7175b64340 100644 --- a/sentry_sdk/integrations/google_genai/__init__.py +++ b/sentry_sdk/integrations/google_genai/__init__.py @@ -15,7 +15,6 @@ try: - from google import genai from google.genai.models import Models, AsyncModels except ImportError: raise DidNotEnable("google-genai not installed") diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 2291967d31..7e8fd9fc06 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -30,12 +30,12 @@ class AccumulatedResponse(TypedDict): model: Optional[str] text: str finish_reasons: List[str] - tool_calls: List[str] + tool_calls: List[dict[str, Any]] usage_metadata: UsageData def accumulate_streaming_response(chunks): - # type: (List[GenerateContentResponse]) -> dict[str, Any] + # type: (List[GenerateContentResponse]) -> AccumulatedResponse """Accumulate streaming chunks into a single response-like object.""" accumulated_text = [] finish_reasons = [] diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index e29218995e..c86d81667a 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -171,7 +171,7 @@ def extract_contents_text(contents): # Content object with parts - recurse into parts if getattr(contents, "parts", None): - return extract_contents_text(contents.parts) # type: ignore + return extract_contents_text(contents.parts) # Direct text attribute if hasattr(contents, "text"): @@ -229,13 +229,13 @@ def extract_tool_calls(response): # Extract from candidates, sometimes tool calls are nested under the content.parts object if getattr(response, "candidates", []): - for candidate in response.candidates: # type: ignore + for candidate in response.candidates: if not hasattr(candidate, "content") or not getattr( candidate.content, "parts", [] ): continue - for part in candidate.content.parts: # type: ignore + for part in candidate.content.parts: if getattr(part, "function_call", None): function_call = part.function_call tool_call = { @@ -245,7 +245,7 @@ def extract_tool_calls(response): # Extract arguments if available if getattr(function_call, "args", None): - tool_call["arguments"] = safe_serialize(function_call.args) # type: ignore + tool_call["arguments"] = safe_serialize(function_call.args) tool_calls.append(tool_call) From bc2a62fc01037ae517a920759fc20b955ba5e9be Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:48:10 +0200 Subject: [PATCH 11/16] more fixes --- sentry_sdk/integrations/google_genai/streaming.py | 2 +- sentry_sdk/integrations/google_genai/utils.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 7e8fd9fc06..6222f9307d 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -95,7 +95,7 @@ def accumulate_streaming_response(chunks): def set_span_data_for_streaming_response(span, integration, accumulated_response): - # type: (Span, Any, dict[str, Any]) -> None + # type: (Span, Any, AccumulatedResponse) -> None """Set span data for accumulated streaming response.""" # Set response text if ( diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index c86d81667a..145b0fd7b1 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -256,7 +256,7 @@ def extract_tool_calls(response): if not getattr(content, "parts", None): continue - for part in content.parts: + for part in getattr(content, "parts", []): if getattr(part, "function_call", None): function_call = part.function_call tool_call = { @@ -416,7 +416,7 @@ def extract_finish_reasons(response): return None finish_reasons = [] - for candidate in response.candidates: + for candidate in getattr(response, "candidates", []): if getattr(candidate, "finish_reason", None): # Convert enum value to string if necessary reason = str(candidate.finish_reason) From bbcb2a01ee38cc834d7e47653b19ad2eca100e99 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 11:50:37 +0200 Subject: [PATCH 12/16] more fixes --- sentry_sdk/integrations/google_genai/streaming.py | 2 +- sentry_sdk/integrations/google_genai/utils.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index 6222f9307d..ca3c26753f 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -51,7 +51,7 @@ def accumulate_streaming_response(chunks): for chunk in chunks: # Extract text and tool calls if getattr(chunk, "candidates", None): - for candidate in chunk.candidates: + for candidate in getattr(chunk, "candidates", []): if hasattr(candidate, "content") and getattr( candidate.content, "parts", [] ): diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 145b0fd7b1..68b8034c0c 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -394,7 +394,7 @@ def _extract_response_text(response): # type: (GenerateContentResponse) -> Optional[List[str]] """Extract text from response candidates.""" - if not response or not hasattr(response, "candidates"): + if not response or not getattr(response, "candidates", []): return None texts = [] @@ -412,11 +412,11 @@ def _extract_response_text(response): def extract_finish_reasons(response): # type: (GenerateContentResponse) -> Optional[List[str]] """Extract finish reasons from response candidates.""" - if not response or not hasattr(response, "candidates"): + if not response or not getattr(response, "candidates", []): return None finish_reasons = [] - for candidate in getattr(response, "candidates", []): + for candidate in response.candidates: if getattr(candidate, "finish_reason", None): # Convert enum value to string if necessary reason = str(candidate.finish_reason) From 174198d44da2507463a872e15729d11a956b0569 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 12:16:54 +0200 Subject: [PATCH 13/16] comment clean up --- sentry_sdk/integrations/google_genai/streaming.py | 7 ------- sentry_sdk/integrations/google_genai/utils.py | 14 ++------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/integrations/google_genai/streaming.py b/sentry_sdk/integrations/google_genai/streaming.py index ca3c26753f..03d09aadf6 100644 --- a/sentry_sdk/integrations/google_genai/streaming.py +++ b/sentry_sdk/integrations/google_genai/streaming.py @@ -75,7 +75,6 @@ def accumulate_streaming_response(chunks): total_reasoning_tokens += extracted_usage_data["output_tokens_reasoning"] total_tokens += extracted_usage_data["total_tokens"] - # Create a synthetic response object with accumulated data accumulated_response = AccumulatedResponse( text="".join(accumulated_text), finish_reasons=finish_reasons, @@ -97,7 +96,6 @@ def accumulate_streaming_response(chunks): def set_span_data_for_streaming_response(span, integration, accumulated_response): # type: (Span, Any, AccumulatedResponse) -> None """Set span data for accumulated streaming response.""" - # Set response text if ( should_send_default_pii() and integration.include_prompts @@ -108,7 +106,6 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response safe_serialize([accumulated_response["text"]]), ) - # Set finish reasons if accumulated_response.get("finish_reasons"): set_data_normalized( span, @@ -116,14 +113,12 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response accumulated_response["finish_reasons"], ) - # Set tool calls if accumulated_response.get("tool_calls"): span.set_data( SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(accumulated_response["tool_calls"]), ) - # Set response ID and model if accumulated_response.get("id"): span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, accumulated_response["id"]) if accumulated_response.get("model"): @@ -141,7 +136,6 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response accumulated_response["usage_metadata"]["input_tokens_cached"], ) - # Output tokens already include reasoning tokens from extract_usage_data if accumulated_response["usage_metadata"]["output_tokens"]: span.set_data( SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, @@ -154,7 +148,6 @@ def set_span_data_for_streaming_response(span, integration, accumulated_response accumulated_response["usage_metadata"]["output_tokens_reasoning"], ) - # Set total token count if available if accumulated_response["usage_metadata"]["total_tokens"]: span.set_data( SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, diff --git a/sentry_sdk/integrations/google_genai/utils.py b/sentry_sdk/integrations/google_genai/utils.py index 68b8034c0c..ff973b02d9 100644 --- a/sentry_sdk/integrations/google_genai/utils.py +++ b/sentry_sdk/integrations/google_genai/utils.py @@ -82,6 +82,7 @@ def extract_usage_data(response): candidates_tokens = usage.get("candidates_token_count", 0) or 0 # python-genai reports output and reasoning tokens separately + # reasoning should be sub-category of output tokens usage_data["output_tokens"] = candidates_tokens + reasoning_tokens total_tokens = usage.get("total_token_count", 0) or 0 @@ -89,7 +90,6 @@ def extract_usage_data(response): return usage_data - # Handle response object if not hasattr(response, "usage_metadata"): return usage_data @@ -209,7 +209,7 @@ def _format_tools_for_span(tools): # Check for predefined tool attributes - each of these tools # is an attribute of the tool object, by default set to None for attr_name, description in TOOL_ATTRIBUTES_MAP.items(): - if hasattr(tool, attr_name) and getattr(tool, attr_name) is not None: + if getattr(tool, attr_name, None): formatted_tools.append( { "name": attr_name, @@ -434,11 +434,9 @@ def set_span_data_for_request(span, integration, model, contents, kwargs): span.set_data(SPANDATA.GEN_AI_SYSTEM, GEN_AI_SYSTEM) span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, model) - # Set streaming flag if kwargs.get("stream", False): span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) - # Set model configuration parameters config = kwargs.get("config") if config is None: @@ -506,35 +504,29 @@ def set_span_data_for_response(span, integration, response): if not response: return - # Extract and set response text if should_send_default_pii() and integration.include_prompts: response_texts = _extract_response_text(response) if response_texts: # Format as JSON string array as per documentation span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(response_texts)) - # Extract and set tool calls tool_calls = extract_tool_calls(response) if tool_calls: # Tool calls should be JSON serialized span.set_data(SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, safe_serialize(tool_calls)) - # Extract and set finish reasons finish_reasons = extract_finish_reasons(response) if finish_reasons: set_data_normalized( span, SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons ) - # Set response ID if available if getattr(response, "response_id", None): span.set_data(SPANDATA.GEN_AI_RESPONSE_ID, response.response_id) - # Set response model if available if getattr(response, "model_version", None): span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, response.model_version) - # Set token usage if available usage_data = extract_usage_data(response) if usage_data["input_tokens"]: @@ -555,7 +547,6 @@ def set_span_data_for_response(span, integration, response): usage_data["output_tokens_reasoning"], ) - # Set total token count if available if usage_data["total_tokens"]: span.set_data(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage_data["total_tokens"]) @@ -567,7 +558,6 @@ def prepare_generate_content_args(args, kwargs): contents = args[1] if len(args) > 1 else kwargs.get("contents") model_name = get_model_name(model) - # Wrap config with tools config = kwargs.get("config") wrapped_config = wrapped_config_with_tools(config) if wrapped_config is not config: From 8270e0d79f5929d35f68a026c731d743f1d8bf7a Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 12:27:40 +0200 Subject: [PATCH 14/16] fix test execution --- .github/workflows/test-integrations-ai.yml | 4 ++-- scripts/populate_tox/config.py | 2 +- scripts/populate_tox/releases.jsonl | 5 +++-- .../split_tox_gh_actions.py | 2 +- tox.ini | 18 ++++++++++-------- 5 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index cced9aa40c..65cc636cd9 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -82,10 +82,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-langgraph" - - name: Test google-genai + - name: Test google_genai run: | set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-google-genai" + ./scripts/runtox.sh "py${{ matrix.python-version }}-google_genai" - name: Test openai_agents run: | set -x # print commands that are executed diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index be3594a1e2..85988ac3cf 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -142,7 +142,7 @@ "package": "gql[all]", "num_versions": 2, }, - "google-genai": { + "google_genai": { "package": "google-genai", "deps": { "*": ["pytest-asyncio"], diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index b7bed7c07d..b3408eb91b 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -69,8 +69,9 @@ {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.6.1", "version": "0.79.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.7", "version": "0.92.0", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.0.1", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "0.8.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.0.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.15.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.29.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.42.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}} diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index 9d59ec548b..abfa1b63cc 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -78,7 +78,7 @@ "openai-base", "openai-notiktoken", "langgraph", - "google-genai", + "google_genai", "openai_agents", "huggingface_hub", ], diff --git a/tox.ini b/tox.ini index a25da08751..58ae7c735d 100644 --- a/tox.ini +++ b/tox.ini @@ -78,9 +78,10 @@ envlist = {py3.9,py3.12,py3.13}-langgraph-v0.6.8 {py3.10,py3.12,py3.13}-langgraph-v1.0.0a4 - {py3.9,py3.11,py3.12}-google-genai-v0.0.1 - {py3.9,py3.12,py3.13}-google-genai-v0.8.0 - {py3.9,py3.12,py3.13}-google-genai-v1.42.0 + {py3.9,py3.12,py3.13}-google_genai-v1.0.0 + {py3.9,py3.12,py3.13}-google_genai-v1.15.0 + {py3.9,py3.12,py3.13}-google_genai-v1.29.0 + {py3.9,py3.12,py3.13}-google_genai-v1.42.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -385,10 +386,11 @@ deps = langgraph-v0.6.8: langgraph==0.6.8 langgraph-v1.0.0a4: langgraph==1.0.0a4 - google-genai-v0.0.1: google-genai==0.0.1 - google-genai-v0.8.0: google-genai==0.8.0 - google-genai-v1.42.0: google-genai==1.42.0 - google-genai: pytest-asyncio + google_genai-v1.0.0: google-genai==1.0.0 + google_genai-v1.15.0: google-genai==1.15.0 + google_genai-v1.29.0: google-genai==1.29.0 + google_genai-v1.42.0: google-genai==1.42.0 + google_genai: pytest-asyncio openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 @@ -756,7 +758,7 @@ setenv = falcon: TESTPATH=tests/integrations/falcon fastapi: TESTPATH=tests/integrations/fastapi flask: TESTPATH=tests/integrations/flask - google-genai: TESTPATH=tests/integrations/google-genai + google_genai: TESTPATH=tests/integrations/google_genai gql: TESTPATH=tests/integrations/gql graphene: TESTPATH=tests/integrations/graphene grpc: TESTPATH=tests/integrations/grpc From f33afb2789f67d28b61a42dbc15b4ce4a9bea3e9 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 13:30:56 +0200 Subject: [PATCH 15/16] fix ci --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8b4c09490d..c0b0e42629 100644 --- a/setup.py +++ b/setup.py @@ -84,7 +84,7 @@ def get_file_text(file_name): "statsig": ["statsig>=0.55.3"], "tornado": ["tornado>=6"], "unleash": ["UnleashClient>=6.0.1"], - "google-genai": ["google-genai>=1.0.0"], + "google-genai": ["google-genai>=1.29.0"], }, entry_points={ "opentelemetry_propagator": [ From 1c5aa1c08955f7271a7d8b5397874e616b2f3819 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 9 Oct 2025 13:39:15 +0200 Subject: [PATCH 16/16] fix ci attempt 2 --- scripts/populate_tox/releases.jsonl | 4 ++-- sentry_sdk/integrations/__init__.py | 2 +- tox.ini | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/populate_tox/releases.jsonl b/scripts/populate_tox/releases.jsonl index b3408eb91b..5ee83b65bc 100644 --- a/scripts/populate_tox/releases.jsonl +++ b/scripts/populate_tox/releases.jsonl @@ -69,9 +69,9 @@ {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Framework :: Pydantic", "Framework :: Pydantic :: 1", "Framework :: Pydantic :: 2", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.8", "version": "0.118.2", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.6.1", "version": "0.79.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: AsyncIO", "Framework :: FastAPI", "Intended Audience :: Developers", "Intended Audience :: Information Technology", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: HTTP Servers", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed"], "name": "fastapi", "requires_python": ">=3.7", "version": "0.92.0", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.0.0", "yanked": false}} -{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.15.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.29.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.33.0", "yanked": false}} +{"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.37.0", "yanked": false}} {"info": {"classifiers": ["Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules"], "name": "google-genai", "requires_python": ">=3.9", "version": "1.42.0", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": "", "version": "3.4.1", "yanked": false}} {"info": {"classifiers": ["Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Software Development :: Libraries"], "name": "gql", "requires_python": ">=3.8.1", "version": "4.0.0", "yanked": false}} diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index d734cd3858..9e279b8345 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -140,7 +140,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "flask": (1, 1, 4), "gql": (3, 4, 1), "graphene": (3, 3), - "google_genai": (1, 0, 0), # google-genai + "google_genai": (1, 29, 0), # google-genai "grpc": (1, 32, 0), # grpcio "httpx": (0, 16, 0), "huggingface_hub": (0, 24, 7), diff --git a/tox.ini b/tox.ini index 58ae7c735d..1a2f8cb571 100644 --- a/tox.ini +++ b/tox.ini @@ -78,9 +78,9 @@ envlist = {py3.9,py3.12,py3.13}-langgraph-v0.6.8 {py3.10,py3.12,py3.13}-langgraph-v1.0.0a4 - {py3.9,py3.12,py3.13}-google_genai-v1.0.0 - {py3.9,py3.12,py3.13}-google_genai-v1.15.0 {py3.9,py3.12,py3.13}-google_genai-v1.29.0 + {py3.9,py3.12,py3.13}-google_genai-v1.33.0 + {py3.9,py3.12,py3.13}-google_genai-v1.37.0 {py3.9,py3.12,py3.13}-google_genai-v1.42.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 @@ -386,9 +386,9 @@ deps = langgraph-v0.6.8: langgraph==0.6.8 langgraph-v1.0.0a4: langgraph==1.0.0a4 - google_genai-v1.0.0: google-genai==1.0.0 - google_genai-v1.15.0: google-genai==1.15.0 google_genai-v1.29.0: google-genai==1.29.0 + google_genai-v1.33.0: google-genai==1.33.0 + google_genai-v1.37.0: google-genai==1.37.0 google_genai-v1.42.0: google-genai==1.42.0 google_genai: pytest-asyncio