diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/165.added b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/165.added new file mode 100644 index 00000000..0c51affb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/165.added @@ -0,0 +1 @@ +Add instrumentation for InteractionsResource.create and AsyncInteractionsResource.create. diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst index 1839d30b..52da0565 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst +++ b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst @@ -44,10 +44,11 @@ Check out the `manual example `_ for more details. Instrumenting all clients ************************* -When using the instrumentor, all clients will automatically trace GenAI ``generate_content`` operations. + +When using the instrumentor, all clients will automatically trace GenAI ``generate_content`` operations and ``interactions.create`` operations. You can also optionally capture prompts and responses as log events. -Make sure to configure OpenTelemetry tracing, logging, metrics, and events to capture all telemetry emitted by the instrumentation. +Make sure to configure OpenTelemetry tracing, logging, and metrics to capture all telemetry emitted by the instrumentation. .. code-block:: python diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/dev/call_instrumentation_lib_and_save_telemetry.py b/instrumentation/opentelemetry-instrumentation-google-genai/dev/call_instrumentation_lib_and_save_telemetry.py index 6cb70469..95615040 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/dev/call_instrumentation_lib_and_save_telemetry.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/dev/call_instrumentation_lib_and_save_telemetry.py @@ -62,19 +62,31 @@ def add(a: int, b: int) -> int: def main(): GoogleGenAiSdkInstrumentor().instrument() + # set vertex ai to False to get the interactions API. client = genai.Client( - vertexai=True, + # vertexai=False, project=os.environ["PROJECT_ID"], location=os.environ["LOCATION"], ) + response = client.models.generate_content( model=os.environ["MODEL"], contents=os.environ["PROMPT"], config=types.GenerateContentConfig(tools=[add]), ) - write_spans_to_file("test_span") - write_logs_to_file("test_log") - print(response.text) + # Example interactions API call.. + # response = client.interactions.create( + # model=os.environ["MODEL"], + # input={ + # "type": "text", + # "text": "What is the current weather in Tokyo?", + # }, + # tools=[{"type": "google_search"}], + # ) + + write_spans_to_file("span_google_search") + write_logs_to_file("log_google_search") + print(response) if __name__ == "__main__": diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/dev/main.env b/instrumentation/opentelemetry-instrumentation-google-genai/dev/main.env index f77946df..65fbe0b7 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/dev/main.env +++ b/instrumentation/opentelemetry-instrumentation-google-genai/dev/main.env @@ -2,4 +2,6 @@ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT = "SPAN_AND_EVENT" PROJECT_ID = "PROJECT_ID" LOCATION = "us-central1" MODEL = "gemini-2.5-flash" -PROMPT = "Can you add 2 and 8" \ No newline at end of file +PROMPT = "Can you add 2 and 8" +# GEMINI_API_KEY env var only needed if checking the interactions API.. don't commit it to the actual repo. +# GEMINI_API_KEY="[GEMINI_API_KEY]" \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml index db975c4e..ebb69b01 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" requires-python = ">=3.13" dependencies = [ "opentelemetry-instrumentation-google-genai", - "google-genai", + "google-genai>=2.0.0", "pyOpenSSL", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-grpc", diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py index a3985b56..8d863dd9 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py @@ -15,6 +15,10 @@ instrument_generate_content, uninstrument_generate_content, ) +from .interactions import ( + instrument_interactions, + uninstrument_interactions, +) class GoogleGenAiSdkInstrumentor(BaseInstrumentor): @@ -22,6 +26,7 @@ def __init__( self, generate_content_config_key_allowlist: Optional[AllowList] = None ): self._generate_content_snapshot = None + self._interactions_snapshot = None self._generate_content_config_key_allowlist = ( generate_content_config_key_allowlist or AllowList.from_env( @@ -56,6 +61,10 @@ def _instrument(self, **kwargs: Any): telemetry_handler, generate_content_config_key_allowlist=self._generate_content_config_key_allowlist, ) + self._interactions_snapshot = instrument_interactions( + telemetry_handler, + ) def _uninstrument(self, **kwargs: Any): uninstrument_generate_content(self._generate_content_snapshot) + uninstrument_interactions(self._interactions_snapshot) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py new file mode 100644 index 00000000..58ad448f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py @@ -0,0 +1,421 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import AsyncIterable, Callable, Iterable, Sequence +from typing import Any, cast + +try: + # Google GenAI < 2.9.0 + from google.genai._interactions._streaming import Stream + from google.genai._interactions.resources.interactions import ( + AsyncInteractionsResource, + InteractionsResource, + ) + from google.genai._interactions.types.interaction import Interaction, Usage + from google.genai._interactions.types.interaction_create_params import ( + Input, + ) + from google.genai._interactions.types.interaction_sse_event import ( + InteractionSSEEvent, + ) +except ImportError: + # Google GenAI >= 2.9.0 + from google.genai._gaos.interactions import ( + AsyncInteractions as AsyncInteractionsResource, + ) + from google.genai._gaos.interactions import ( + Interactions as InteractionsResource, + ) + from google.genai._gaos.interactions import ( + Stream, + ) + from google.genai._gaos.types.interactions import ( + Interaction, + InteractionSSEEvent, + Usage, + ) + from google.genai._gaos.types.interactions import ( + InteractionsInput as Input, + ) +from wrapt import wrap_function_wrapper + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + InferenceInvocation, +) +from opentelemetry.util.genai.stream import ( + AsyncStreamWrapper, + SyncStreamWrapper, +) +from opentelemetry.util.genai.types import ( + GenericPart, + InputMessage, + OutputMessage, + Text, + ToolCallRequest, + ToolCallResponse, + Uri, +) + + +class _InteractionsMethodsSnapshot: + def __init__(self) -> None: + self._original_create = InteractionsResource.create + self._original_async_create = AsyncInteractionsResource.create + + def restore(self) -> None: + InteractionsResource.create = self._original_create + AsyncInteractionsResource.create = self._original_async_create + + +def _apply_interaction_response_attributes( + response: Interaction, + invocation: InferenceInvocation, + telemetry_handler: TelemetryHandler, +) -> None: + invocation.response_model_name = response.model + + usage = response.usage or Usage() + + invocation.input_tokens = usage.total_input_tokens + invocation.output_tokens = usage.total_output_tokens + invocation.thinking_tokens = usage.total_thought_tokens + invocation.cache_read_input_tokens = usage.total_cached_tokens + + if telemetry_handler.should_capture_content(): + invocation.output_messages = _interactions_response_to_messages( + response + ) + + +def _get_client_info(instance: Any) -> tuple[bool, str | None]: + is_vertex = False + server_address = None + # This attribute does not exist past v2.9 of google-genai, instead sdk_configuration is used.. + if hasattr(instance, "_client"): + client = instance._client + is_vertex = getattr(client, "_is_vertex", False) + server_address = getattr(client, "server", None) + elif hasattr(instance, "sdk_configuration"): + config = instance.sdk_configuration + server_url = getattr(config, "server_url", "") + if server_url: + server_address = server_url + if "aiplatform.googleapis.com" in server_url: + is_vertex = True + + return is_vertex, server_address + + +def _get_field(obj: Any, name: str) -> Any: + if isinstance(obj, dict): + return obj.get(name) + return getattr(obj, name, None) + + +# Logic for parsing Input is tricky: +# https://github.com/open-telemetry/donation-openinference/blob/6cdd644d79fccf50aedcb614187f924ddfcafb7b/python/instrumentation/openinference-instrumentation-google-genai/src/openinference/instrumentation/google_genai/interactions_attributes.py#L103 +# It doesn't make sense for this to be a List[InputMessage] (per semconv), +# because this API doesn't take conversation history as input (unlike the generate_content API). +# Conversation history is stored server-side and referenced via a interaction ID parameter. +def _interactions_input_to_messages( + input_data: Input | None, +) -> list[InputMessage]: + # None will end up raising an exception by the SDK + if input_data is None: + return [] + if isinstance(input_data, str): + return [InputMessage(role="user", parts=[Text(content=input_data)])] + + if not isinstance(input_data, Sequence): + input_data = [input_data] + + parts = [] + for item in input_data: + item_type = _get_field(item, "type") + if item_type == "function_call": + call_id = _get_field(item, "id") + name = _get_field(item, "name") + arguments = _get_field(item, "arguments") + part = ToolCallRequest( + id=call_id, name=name or "", arguments=arguments + ) + parts.append(part) + elif item_type == "function_result": + call_id = _get_field(item, "call_id") + result = _get_field(item, "result") + part = ToolCallResponse(id=call_id, response=result) + parts.append(part) + elif isinstance(item, str): + parts.append(Text(content=item)) + elif item_type == "text": + part = Text(content=_get_field(item, "text") or "") + parts.append(part) + elif item_type == "document": + part = Uri( + mime_type=_get_field(item, "mime_type"), + modality="document", + uri=_get_field(item, "uri") or "", + ) + parts.append(part) + elif item_type is not None: + part = GenericPart(value=type(item).__name__) + parts.append(part) + + return [InputMessage(role="user", parts=parts)] + + +def _get_interaction_output_text(interaction: Interaction) -> str: + if getattr(interaction, "output_text", None): + return interaction.output_text + + texts = [] + if interaction.steps: + for step in interaction.steps: + if getattr(step, "type", None) == "model_output": + content = getattr(step, "content", None) + if content: + for item in content: + if getattr(item, "type", None) == "text" and hasattr( + item, "text" + ): + texts.append(item.text) + return "".join(texts) + + +# It doesn't make sense for this to be a list of OutputMessage (per semconv), +# because this API doesn't return conversation history as output (unlike the generate_content API). +# Model's response is returned as a list of steps: +# https://ai.google.dev/gemini-api/docs/migrate-to-interactions#basic-input-output +# https://ai.google.dev/api/interactions-api#Resource:Step +def _interactions_response_to_messages( + interaction: Interaction, +) -> list[OutputMessage]: + output_text = _get_interaction_output_text(interaction) + return [ + OutputMessage( + role="assistant", + parts=[Text(content=output_text)], + finish_reason="stop", + ) + ] + + +class InteractionsStreamWrapper(SyncStreamWrapper[InteractionSSEEvent]): + def __init__( + self, + stream: Iterable[InteractionSSEEvent], + invocation: InferenceInvocation, + telemetry_handler: TelemetryHandler, + ) -> None: + super().__init__(stream) + self._self_invocation = invocation + self._self_telemetry_handler = telemetry_handler + self._self_last_interaction: Interaction | None = None + + def _process_chunk(self, chunk: InteractionSSEEvent) -> None: + event_type = _get_field(chunk, "event_type") + if event_type == "interaction_completed": + interaction = _get_field(chunk, "interaction") + if interaction: + self._self_last_interaction = interaction + + def _on_stream_end(self) -> None: + if self._self_last_interaction: + _apply_interaction_response_attributes( + self._self_last_interaction, + self._self_invocation, + self._self_telemetry_handler, + ) + self._self_invocation.stop() + + def _on_stream_error(self, error: Exception) -> None: + self._self_invocation.fail(error) + + +class AsyncInteractionsStreamWrapper(AsyncStreamWrapper[InteractionSSEEvent]): + def __init__( + self, + stream: AsyncIterable[InteractionSSEEvent], + invocation: InferenceInvocation, + telemetry_handler: TelemetryHandler, + ) -> None: + super().__init__(stream) + self._self_invocation = invocation + self._self_telemetry_handler = telemetry_handler + self._self_last_interaction: Interaction | None = None + + def _process_chunk(self, chunk: InteractionSSEEvent) -> None: + event_type = _get_field(chunk, "event_type") + if event_type == "interaction_completed": + interaction = _get_field(chunk, "interaction") + if interaction: + self._self_last_interaction = interaction + + def _on_stream_end(self) -> None: + if self._self_last_interaction: + _apply_interaction_response_attributes( + self._self_last_interaction, + self._self_invocation, + self._self_telemetry_handler, + ) + self._self_invocation.stop() + + def _on_stream_error(self, error: Exception) -> None: + self._self_invocation.fail(error) + + +def _create_instrumented_interactions_create( + telemetry_handler: TelemetryHandler, +) -> Callable[ + [ + Callable[..., Interaction | Stream[InteractionSSEEvent]], + InteractionsResource, + tuple[Any, ...], + dict[str, Any], + ], + Interaction | InteractionsStreamWrapper, +]: + def instrumented_interactions_create( + wrapped: Callable[..., Interaction | Stream[InteractionSSEEvent]], + instance: InteractionsResource, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Interaction | InteractionsStreamWrapper: + # Vertex AI does not support the interactions API yet, but eventually will. + # SDK will raise an exception if model or agent is not passed or if input data is not passed. + is_vertex, server_address = _get_client_info(instance) + invocation = telemetry_handler.inference( + provider=( + GenAIAttributes.GenAiSystemValues.VERTEX_AI.value + if is_vertex + else GenAIAttributes.GenAiSystemValues.GEMINI.value + ), + request_model=kwargs.get("model") or kwargs.get("agent"), + operation_name="interactions.create", + server_address=server_address, + ) + + if telemetry_handler.should_capture_content(): + invocation.input_messages = _interactions_input_to_messages( + kwargs.get("input") + ) + if system_instruction := kwargs.get("system_instruction"): + invocation.system_instruction = [ + Text(content=system_instruction) + ] + + if kwargs.get("stream", False): + return InteractionsStreamWrapper( + wrapped(*args, **kwargs), invocation, telemetry_handler + ) + try: + response = wrapped(*args, **kwargs) + _apply_interaction_response_attributes( + response, invocation, telemetry_handler + ) + invocation.stop() + return response + except Exception as exc: + invocation.fail(exc) + raise + + return instrumented_interactions_create + + +def _create_instrumented_async_interactions_create( + telemetry_handler: TelemetryHandler, +) -> Callable[ + [ + Callable[..., Any], + AsyncInteractionsResource, + tuple[Any, ...], + dict[str, Any], + ], + Any, +]: + async def instrumented_interactions_create( + wrapped: Callable[..., Any], + instance: AsyncInteractionsResource, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> Interaction | AsyncInteractionsStreamWrapper: + is_vertex, server_address = _get_client_info(instance) + invocation = telemetry_handler.inference( + provider=( + GenAIAttributes.GenAiSystemValues.VERTEX_AI.value + if is_vertex + else GenAIAttributes.GenAiSystemValues.GEMINI.value + ), + request_model=kwargs.get("model") or kwargs.get("agent"), + operation_name="interactions.create", + server_address=server_address, + ) + + if telemetry_handler.should_capture_content(): + invocation.input_messages = _interactions_input_to_messages( + kwargs.get("input") + ) + if system_instruction := kwargs.get("system_instruction"): + invocation.system_instruction = [ + Text(content=system_instruction) + ] + + if kwargs.get("stream", False): + return AsyncInteractionsStreamWrapper( + await wrapped(*args, **kwargs), + invocation, + telemetry_handler, + ) + try: + response = cast(Interaction, await wrapped(*args, **kwargs)) + _apply_interaction_response_attributes( + response, invocation, telemetry_handler + ) + invocation.stop() + return response + except Exception as exc: + invocation.fail(exc) + raise + + return instrumented_interactions_create + + +def uninstrument_interactions(snapshot: object) -> None: + assert isinstance(snapshot, _InteractionsMethodsSnapshot) + snapshot.restore() + + +def instrument_interactions( + telemetry_handler: TelemetryHandler, +) -> object: + snapshot = _InteractionsMethodsSnapshot() + + try: + import google.genai._interactions.resources.interactions # noqa: F401, PLC0415 + + module_path = "google.genai._interactions.resources.interactions" + sync_class = "InteractionsResource" + async_class = "AsyncInteractionsResource" + except ImportError: + # In version 2.9 of google-genai these were moved. + module_path = "google.genai._gaos.interactions" + sync_class = "Interactions" + async_class = "AsyncInteractions" + + wrap_function_wrapper( + module_path, + f"{sync_class}.create", + _create_instrumented_interactions_create(telemetry_handler), + ) + wrap_function_wrapper( + module_path, + f"{async_class}.create", + _create_instrumented_async_interactions_create(telemetry_handler), + ) + return snapshot diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/__init__.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/__init__.py new file mode 100644 index 00000000..e57cf4ab --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/__init__.py @@ -0,0 +1,2 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py new file mode 100644 index 00000000..322db65b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -0,0 +1,255 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import unittest +import unittest.mock +from typing import Any +from unittest.mock import patch + +try: + from google.genai._interactions.resources.interactions import ( + AsyncInteractionsResource, + InteractionsResource, + ) +except ImportError: + # In version 2.9 of google-genai these were moved. + from google.genai._gaos.interactions import ( + AsyncInteractions as AsyncInteractionsResource, + ) + from google.genai._gaos.interactions import ( + Interactions as InteractionsResource, + ) + + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +from ..common.base import TestCase as CommonTestCaseBase +from .util import create_mock_completed_event, create_mock_interaction + + +class TestCase(CommonTestCaseBase): + def setUp(self) -> None: + super().setUp() + if self.__class__ == TestCase: + raise unittest.SkipTest("Skipping testcase base.") + self._create_mock = None + self._original_create = InteractionsResource.create + self._original_async_create = AsyncInteractionsResource.create + self._interactions: list[Any] = [] + self._interaction_index = 0 + + @property + def mock_create(self) -> unittest.mock.MagicMock: + if self._create_mock is None: + self._create_and_install_mocks() + assert self._create_mock is not None + return self._create_mock + + def configure_valid_interaction(self, **kwargs: Any) -> None: + self._create_and_install_mocks() + interaction = create_mock_interaction(**kwargs) + self._interactions.append(interaction) + + def configure_exception(self, e: Exception) -> None: + self._create_and_install_mocks(e) + + def _create_and_install_mocks(self, e: Exception | None = None) -> None: + if self._create_mock is not None: + return + self.reset_client() + self.reset_instrumentation() + self._create_mock = self._create_mock_impl(e) + self._install_mocks() + + def _create_mock_impl( + self, e: Exception | None = None + ) -> unittest.mock.MagicMock: + mock = unittest.mock.MagicMock() + + def _default_impl(*args: Any, **kwargs: Any) -> Any: + if not self._interactions: + result = create_mock_interaction() + else: + index = self._interaction_index % len(self._interactions) + result = self._interactions[index] + self._interaction_index += 1 + + if kwargs.get("stream"): + completed_event = create_mock_completed_event(result) + return [completed_event] + return result + + mock.side_effect = e or _default_impl + return mock + + def _install_mocks(self) -> None: + def _sync_create_wrapped(*args: Any, **kwargs: Any) -> Any: + assert self._create_mock is not None + return self._create_mock(*args, **kwargs) + + async def _async_create_wrapped(*args: Any, **kwargs: Any) -> Any: + assert self._create_mock is not None + res = self._create_mock(*args, **kwargs) + if kwargs.get("stream"): + + async def _async_generator() -> Any: + for item in res: + yield item + + return _async_generator() + return res + + InteractionsResource.create = _sync_create_wrapped + AsyncInteractionsResource.create = _async_create_wrapped + + def tearDown(self) -> None: + super().tearDown() + InteractionsResource.create = self._original_create + AsyncInteractionsResource.create = self._original_async_create + + # Abstract methods to be overridden by subclasses + def run_interaction(self, *args: Any, **kwargs: Any) -> Any: + raise NotImplementedError() + + def run_streaming_interaction( + self, *args: Any, **kwargs: Any + ) -> list[Any]: + raise NotImplementedError() + + # The actual collapsed test cases: + def test_instrumentation_does_not_break_core_functionality(self) -> None: + self.configure_valid_interaction( + interaction_id="test-id", + output_text="Yep, it works!", + ) + response = self.run_interaction( + model="gemini-2.5-flash", input="Does this work?" + ) + self.assertEqual(response.id, "test-id") + self.assertEqual(response.steps[1].content[0].text, "Yep, it works!") + + def test_generates_span(self) -> None: + self.configure_valid_interaction() + self.run_interaction(model="gemini-2.5-flash", input="Does this work?") + self.otel.assert_has_span_named("interactions.create gemini-2.5-flash") + + def test_model_reflected_into_span_name(self) -> None: + self.configure_valid_interaction() + self.run_interaction(model="gemini-1.5-flash", input="Does this work?") + self.otel.assert_has_span_named("interactions.create gemini-1.5-flash") + + def test_generated_span_has_minimal_genai_attributes(self) -> None: + self.configure_valid_interaction() + self.run_interaction(model="gemini-2.5-flash", input="Does this work?") + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.assertEqual(span.attributes["gen_ai.provider.name"], "gemini") + self.assertEqual( + span.attributes["gen_ai.operation.name"], "interactions.create" + ) + + def test_span_and_event_still_written_when_response_is_exception( + self, + ) -> None: + self.configure_exception(ValueError("Uh oh!")) + with self.assertRaises(ValueError): + self.run_interaction( + model="gemini-2.5-flash", input="Does this work?" + ) + self.otel.assert_has_span_named("interactions.create gemini-2.5-flash") + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.otel.assert_has_event_named( + "gen_ai.client.inference.operation.details" + ) + event = self.otel.get_event_named( + "gen_ai.client.inference.operation.details" + ) + self.assertEqual(span.attributes["error.type"], "ValueError") + self.assertEqual(event.attributes["error.type"], "ValueError") + + def test_generated_span_has_vertex_ai_system_when_configured(self) -> None: + self.set_use_vertex(True) + self.configure_valid_interaction() + self.run_interaction(model="gemini-2.5-flash", input="Does this work?") + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.assertEqual(span.attributes["gen_ai.provider.name"], "vertex_ai") + self.assertEqual( + span.attributes["gen_ai.operation.name"], "interactions.create" + ) + + def test_generated_span_counts_tokens(self) -> None: + self.configure_valid_interaction( + input_tokens=15, + output_tokens=25, + ) + self.run_interaction(model="gemini-2.5-flash", input="Some input") + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.assertEqual(span.attributes["gen_ai.usage.input_tokens"], 15) + self.assertEqual(span.attributes["gen_ai.usage.output_tokens"], 25) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_span_attributes_with_content_capture(self) -> None: + self.configure_valid_interaction( + input_text="Hello interactions!", + output_text="Response from interactions!", + ) + self.run_interaction( + model="gemini-2.5-flash", + input="Hello interactions!", + ) + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.assertEqual( + span.attributes[GenAIAttributes.GEN_AI_INPUT_MESSAGES], + '[{"role":"user","parts":[{"content":"Hello interactions!","type":"text"}]}]', + ) + self.assertEqual( + span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES], + '[{"role":"assistant","parts":[{"content":"Response from interactions!","type":"text"}],"finish_reason":"stop"}]', + ) + + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "NO_CONTENT"}, + ) + def test_span_attributes_no_content_capture(self) -> None: + self.configure_valid_interaction( + input_text="Hello interactions!", + output_text="Response from interactions!", + ) + self.run_interaction( + model="gemini-2.5-flash", + input="Hello interactions!", + ) + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + for attribute in ( + GenAIAttributes.GEN_AI_INPUT_MESSAGES, + GenAIAttributes.GEN_AI_OUTPUT_MESSAGES, + ): + self.assertNotIn(attribute, span.attributes) + + def test_streaming_generates_span(self) -> None: + self.configure_valid_interaction( + interaction_id="stream-id-1", + model_name="gemini-2.5-flash", + output_text="Streaming response!", + input_tokens=5, + output_tokens=8, + ) + events = self.run_streaming_interaction( + model="gemini-2.5-flash", + input="Streaming test", + stream=True, + ) + self.assertEqual(len(events), 1) + self.assertEqual(events[0].interaction.id, "stream-id-1") + + self.otel.assert_has_span_named("interactions.create gemini-2.5-flash") + span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + self.assertEqual(span.attributes["gen_ai.usage.input_tokens"], 5) + self.assertEqual(span.attributes["gen_ai.usage.output_tokens"], 8) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_async_interactions_create.yaml b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_async_interactions_create.yaml new file mode 100644 index 00000000..92e0a8c0 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_async_interactions_create.yaml @@ -0,0 +1,96 @@ +interactions: +- request: + body: |- + { + "model": "gemini-2.5-flash", + "input": "Hello, how can you help me today?" + } + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + api-revision: + - '2026-05-20' + connection: + - keep-alive + content-length: + - '72' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + user-agent: + - speakeasy-sdk/python 2.4.1-preview.5 2.911.0 v1beta google-genai + x-goog-api-client: + - google-genai-sdk/2.9.0 gl-python/3.12.9 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/interactions + response: + body: + string: |- + { + "id": "v1_ChdOQ2M4YXBYc0NyNnkxTWtQazctZDhRYxIXTkNjOGFwWHNDcjZ5MU1rUGs3LWQ4UWM", + "status": "completed", + "usage": { + "total_tokens": 978, + "total_input_tokens": 10, + "input_tokens_by_modality": [ + { + "modality": "text", + "tokens": 10 + } + ], + "total_cached_tokens": 0, + "total_output_tokens": 133, + "total_tool_use_tokens": 0, + "total_thought_tokens": 835 + }, + "created": "2026-06-24T18:51:32Z", + "updated": "2026-06-24T18:51:32Z", + "service_tier": "standard", + "steps": [ + { + "signature": "CsEaAQw51scwu0d45oqk7uzkDxUv01sOFgeRSK7ZeFSH7vQ+bsw+oVIMSYtwqAU0BcOHXkUCh4XrGhLng5zC5SBxMR/uXswOJKewD3rU7H7F4RPFJqwB6KdNRlqmRZOY38EOuEfpKv2r+uI+EwfgXQ5vQhKqlMCGNLcdiJHU4F6IIW/HqYaxOnLwKZ4hrFADRKPWkWY4LMQOCbZM4H3F2C8v5EAKxJ5E6cOKimc2YojFxZWRM78Lhi6bI2zDxEpsrqNcN0wgVbA0tAV05Eoq/nkjnNd748YdHa+xDxYiLOyVXIQ8/owdl3yaNRPo8ZMcr82qkD+IWitASKQVXBOpjzcN2XFrmrtw28wZwSTnjNn601wNWGe8B11IMhujgXv683t1hkNv1MH4djjdMDggzBMhrr3g9AxCzeR9tORGdyENg8eoB+VzuIPxs8iOWOsZkyiwn5VURLwLmF5l+6U6VB3YGP/og4STajehUq+y/fXNR+Od0jd/DZlR78gOrdidjYGD24263cWlTzVSSH7WBb61s6BZr0f7/FEW5T4Q3vs4VZ3TxTBIVccXRzPRQHqG6FTrNRYBsYxOl0Y4kwCZ+PvMYKuvp+WoLcqwQsTSdFdPI7axk3nGNgfga8J1wFaX59jTHSCsHzWXZTrnAcKQH50hwwvyZUrtoYk3fTHzDzOsfP+QULdP7zaO/QW8uCLHTCdCv95BKUIphDzgw0eKOZSYoDYhwOKcn+ORmQhpGmi3+n63xmusJK2YHbvNnJk2wzQ/VrHKjhaTkC5ECb6qddINPDNczRwRDGdlQPKCEON56pEnoiSfZUsXo/m8Jd6KyjLsVxTYUo/+BWkhuB4e9lALm3/euyI1j2UXj8zBaZy51oLmRmH2UsP8b1X9TU/8IEJ6VcfUVKxwUAdXH8AMD7M9JpcOg8U45yQExtBMYE27dP1Tu9IlE11ATO1UU1KBBmUlAYUeyzsFTajgQT6KrtSDcyHk+9UrjdQfY+wRcMmfM31gYupZ5ZEEzKrMSmEqnk18pSMmOiWmOOueCmVVZkg+odXuGLmgkeqTVlnAhxQx7Olpl+zLfMPqfrlHeIVrzN8iPjCmD2p0Y0iW1UUfIYVT/C8IZ66X10r+aJL10HcMGqNlx75jtuC9sWK6RtB/ZFhVkzqSJ2JTCI8W3dGmEtuWRWRYyTC2NSayzUVY039v42C2XoIvZ3y4IfdCJSp4+8CwMnW3wwdnfde9KLOk+ysFIYyetckbklAnNispS+PG7ti2ipbnS/RoZkVPFX3vA4mOGceCGNq7oTodiJNvLjYePbIH0G3v8TbbI6MkG2MI9S6lio4nkzIvXXFCXVHByYO1c0ASN6OQPlh31ku/Xaq78PoeUtvr8JjpiYxlDCi/9Vc9rzFhwFduZrdedNRctdPcTlAPr/IQZwF9tX1HYWk85sZS7QvocNsPE49tRLJ41irPXBQOjOnZ8FsiYAsxyGfaSt3FOVbeZvo42a4dzwSi5YidN6g36XmPkrfHT7J7fHgV1OIeaNL+i30tipkxjABW4ds2wuh8i0rnaeznBZYLTbbaEpGP1V+ZAWkG/Vf9ufE9yOBlduoAM+4K7Zkeoj56LVUdGyUkODZtf5recrLrZzohKmm36Vljd6vQKfPlKi2WCtFHVskeuDwJ+qbZb7Pd2ZUBmGSoqPVX0d0vkQnLgHb7Z0b8f2nnVPpDbX5TNrE4p1SzI32UJtdcMnMkGVwH/RLjy9/poxy64j7UIAeVaCfwSU24Xd1f3aH4Zsfuk0y4X7/V1svY33/TyQhYExW4jGSzDeAX+RRJ+rdIHGNRp9rPVVSJeXrG/4ah30+LWGZUzAQqvZg3fw17P1pL85iEJs3+VdVvaNST9vELM5sVyuekpD0ag7YkJYWqRBdqGRFfBck6qzPAm56tW6PuAd9AEzhYiT4ca0Md6En7cOEEoS91SL6B8FpiesbmCzAOlTRo02cANnTk/o+xI2NN7OLInFYsOpOyhZrxDd9JraJWBZxnmiDzr1wh0LsnaZ2l0YI+af4tqzfP1FoumctPhBTw73CarQ7/QILAlOHlgwOok+67fgHm/kF/Gu6eVvwWyd96s6RJMD9qKUS/bezQYFoJnvZ3s6IvaMOb7/sbcxmZzIdtOMfpQHZ/ajRDRXPNfh7BIda3098Oj2z4oVtan8xRN4clLT4rIqzswnVdbxOUEOQDTrWTtexoCoBsiw63Q5KSA01WrD1UrSm4cnCS6PglnpyfL+dvteuXTg+Gs9N16M/yey6nvNzg62Z+aIJHvaCjVE5fcjcd2rH2DOYnGWUZv3ToS7+z+l1raez/01bTXB39V9Q5raBE2wFcSKJctDRLllRJ8v8Vd1ZhdSz+4shyBKaoSyjsy71N0eUSlLgAay0wDf8fJq0/jWLcE6bTStIiMlxxwAqQ4P2tFIJf1ZCQlm1wiOHueTR8hdT5fa2KIPdpjP4ua8cYBdUJNWw19RLiiCVN4PBuXAbRmOGmU39lE+RPpfuszjcup6CIM/jZzya5IB/YIBNGzOggE3Si2wc8wRqxn4RNQw92gy0iRE6aPlwWQBj9CiFfrMkeAeyIScrfV3HZdY+iDPCIo4g2f17R7NZigjhAZZk6cRIA8Rr0C99Z+D7RwrVHCFmbr93nan6ruHsl2yLMFVVv9gNxqNMPUko5fvlsbTQnkmrVBrN9ceb0X6vL0swRmhcX7vjJHNFUHJYXK4ALRM2VEqXnEDjqXDVWFO7Kmnp7FZ56tYtEGJe+Gm8Nm8QWOo0N77oXFLLdaqrQz/T/Ygqmhy8EQiPN49KYW8kafapCjpsaeU3sgkH8xq8CISTHqkOsZN1eoZiM2WGGVLM+I/wATSDflIvvtJTp3FIdupdKiBSVqlVmKsOBCjb4O+NbP4OIGdw2ZgMZB0TFkAR/fdNB+QXse0AmId8yTPQw/SlRB70FnicUovR2Z9uqCWA/t0ABg/JM5X7570Aad2sVnIFFf+gLtL3y5I6zioTCxyOC5sJN7rS1TOGqUiRRccIhtrYi20Z0Qpgzp3O3E9D7dC7GQKQyU4fy9vHervbI60QJHBWmrUn9IaLMUqJK+yyP37SORcpyVZrKYuMENcCCRoGhDPdFgHeqqqDJ8GrYjUoFHB7WSkSmUGCGTJzTi4KIbd1gdJ4Gb1sdjnIa7hC1F4arB4XixuzAcVopGNqnY/nbKLteETeWF9fortTRz0XUpLpaKvCfvSKw416HBPXAdOKsB1cTb6svsZ3T8CvXoxcE0hQMtR9pBrxze9b6z6Ib3dVUK0ztCOH+FZrZLIJjA5jmmRDZwd6ZEmCjbWGkw9mlGN+RbTdHG9DwXzP5SaMPmU5ndw9Uq6bsVvkH+IptbSzj4QZxkDHx7tL7ermAflhr4lU1LgspnUqYJCZPmfsP9wYEYWyW6L5JLyGOLRTYRpN/br4GLRFn6/VW+Zs8qtJtHOBSxmsZvnjApooxQrEkyV944CdYgnJF3iDRadEplNkxFv5UqbMRTtwX7BviSftl3RMPbksL/HrDUI3pTzzJQic9OBbqqerVNsibtMVZ03fy2Nrn2NAXMgvuXmNxEWcfPhtzBz+Dbtrt6FDEVbG7a36w17HNCmMUyYoj1kdh5XNg7OXvrim/KIP3B8CV2jWfrANcxpkf96LQNULa3f8AmS5hR+GE3OFNS+E3kJypFG8P7V19TkXODQ3tus90e5gQYjIFDTV9TJC9Ex0FQ52/IZkegESlPBmBhk8SAeIQsmzRmkiiSmkvC1wW5mjMSMqYzxtDe4FcskQvv8e0XALoKRnCJXLdkH5q4l3KfVUhmi3YuyjNw5ghIMn4AcyvJU8BargvXnp4DIEwkvc1dZFRccwAdmmHvkfknF5Li3Oo7DcB8pzAMIMRF/svxDMAYj93WpOjBWzZawv1YKDwN1En50ZrKcKFtOSq9uygKSGMXBOGHFV+v1TdFPJA21ea9u4A55jZYSKn/gxHoNy1cEp/FUujAPmAfLzoqauikoDqglKH72R4pVu4c41jwtAcOZAebhxWme1QOalByA+r1Jymdi6anXmBh8cqdg0uscEsAYtWbXxCUDvX+vvh+azhVeRcpcvR82hu77lh67P5j/Y7HenfTm30Le732zhQvHT6S23sE79+jlRvxZP4B4KPOkwqRGAm+TmptkrrZ0PnZnBmPRDSN+gyPM2fLtDfyJM3G5LS2/6Nnj7uBN3aSwA5aJ71VKI+yuFIZu48/ntLlqa9p36uNnZKGrxAPSu85VAct0e8CLScGDwf7ajyjYzBVpR/zW/SW0CZsb05OHku82vVT2lsR0c/E+8LRadNLPiFevMqK5v5CnGt41ZSJzt5tDIHTi8pI+KAU340f6Zj44TUx23uoT/5d+KGkpBFwoLstx4NprvHPbzgZi3tYDpLALhxawXWfLM6hTkPNpxnSKVwg87M1XdNwYvPklagzDjpWYwZMje2jYgVjTy7AGRdsef6/eSEdQl3HAvmxRe9SWxPPjCsJFGrRoph30Hv5fQjlFqbhxYoGpAT", + "type": "thought" + }, + { + "content": [ + { + "text": "Hello! I'm an AI assistant, and I'm here to help you with a wide range of tasks.\n\nFor example, I can:\n* **Answer your questions** on various topics.\n* **Generate creative text formats** (like poems, code, scripts, musical pieces, email, letters, etc.).\n* **Summarize** complex information.\n* **Help you brainstorm** ideas.\n* **Provide explanations** or definitions.\n* **Translate languages.**\n* And much more!\n\nPlease tell me what you have in mind or what you'd like to work on today!", + "type": "text" + } + ], + "type": "model_output" + } + ], + "object": "interaction", + "model": "gemini-2.5-flash" + } + headers: + content-length: + - '5602' + content-type: + - application/json + date: + - Wed, 24 Jun 2026 18:51:32 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=5925 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_sync_interactions_create.yaml b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_sync_interactions_create.yaml new file mode 100644 index 00000000..a624ca3e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_sync_interactions_create.yaml @@ -0,0 +1,96 @@ +interactions: +- request: + body: |- + { + "model": "gemini-2.5-flash", + "input": "Hello, how can you help me today?" + } + headers: + accept: + - application/json + accept-encoding: + - gzip, deflate + api-revision: + - '2026-05-20' + connection: + - keep-alive + content-length: + - '72' + content-type: + - application/json + host: + - generativelanguage.googleapis.com + user-agent: + - speakeasy-sdk/python 2.4.1-preview.5 2.911.0 v1beta google-genai + x-goog-api-client: + - google-genai-sdk/2.9.0 gl-python/3.12.9 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/interactions + response: + body: + string: |- + { + "id": "v1_ChdMaWM4YXF2ekNMQ1k5TW9QLUpHZ3dBYxIXTGljOGFxdnpDTENZOU1vUC1KR2d3QWM", + "status": "completed", + "usage": { + "total_tokens": 977, + "total_input_tokens": 10, + "input_tokens_by_modality": [ + { + "modality": "text", + "tokens": 10 + } + ], + "total_cached_tokens": 0, + "total_output_tokens": 175, + "total_tool_use_tokens": 0, + "total_thought_tokens": 792 + }, + "created": "2026-06-24T18:51:26Z", + "updated": "2026-06-24T18:51:26Z", + "service_tier": "standard", + "steps": [ + { + "signature": "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", + "type": "thought" + }, + { + "content": [ + { + "text": "Hello! I'm an AI assistant designed to help with a wide variety of tasks. Here are some of the things I can do:\n\n* **Answer questions:** From general knowledge to specific facts.\n* **Generate text:** Write stories, emails, code, poems, scripts, summaries, or creative content.\n* **Explain concepts:** Break down complex topics into understandable parts.\n* **Brainstorm ideas:** Help you come up with solutions or creative concepts.\n* **Translate languages:** Between various languages.\n* **Summarize information:** Condense long articles or documents.\n* **Help with writing:** Improve grammar, style, or structure.\n\n**Just tell me what you're looking for, or what you'd like to achieve today, and I'll do my best to assist you!**", + "type": "text" + } + ], + "type": "model_output" + } + ], + "object": "interaction", + "model": "gemini-2.5-flash" + } + headers: + content-length: + - '5730' + content-type: + - application/json + date: + - Wed, 24 Jun 2026 18:51:26 GMT + server: + - scaffolding on HTTPServer2 + server-timing: + - gfet4t7; dur=5880 + transfer-encoding: + - chunked + vary: + - Origin + - X-Origin + - Referer + x-content-type-options: + - nosniff + x-frame-options: + - SAMEORIGIN + x-xss-protection: + - '0' + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py new file mode 100644 index 00000000..46a163b6 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py @@ -0,0 +1,215 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import json +import os + +import pytest +import yaml +from google.genai import Client +from google.genai.types import HttpOptions + +from opentelemetry.instrumentation.google_genai import ( + GoogleGenAiSdkInstrumentor, +) +from opentelemetry.util.genai.environment_variables import ( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, +) + +from ..common.otel_mocker import OTelMocker + +# Switch to a real key when running the VCR tests against the real API. +_FAKE_API_KEY = "GEMINI_API_KEY" + + +class _LiteralBlockScalar(str): + """Formats the string as a literal block scalar, preserving whitespace and + without interpreting escape characters""" + + +def _literal_block_scalar_presenter(dumper, data): + """Represents a scalar string as a literal block, via '|' syntax""" + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + + +@pytest.fixture( + name="internal_setup_yaml_pretty_formatting", scope="module", autouse=True +) +def fixture_setup_yaml_pretty_formatting(): + yaml.add_representer(_LiteralBlockScalar, _literal_block_scalar_presenter) + + +def _process_string_value(string_value): + """Pretty-prints JSON or returns long strings as a LiteralBlockScalar""" + try: + json_data = json.loads(string_value) + return _LiteralBlockScalar(json.dumps(json_data, indent=2)) + except (ValueError, TypeError): + if len(string_value) > 80: + return _LiteralBlockScalar(string_value) + return string_value + + +def _convert_body_to_literal(data): + """Searches the data for body strings, attempting to pretty-print JSON""" + if isinstance(data, dict): + for key, value in data.items(): + # Handle response body case (e.g., response.body.string) + if key == "body" and isinstance(value, dict) and "string" in value: + string_val = value["string"] + if isinstance(string_val, bytes): + try: + string_val = string_val.decode("utf-8") + except UnicodeDecodeError: + pass + if isinstance(string_val, str): + value["string"] = _process_string_value(string_val) + + # Handle request body case (e.g., request.body) + elif key == "body" and isinstance(value, str): + data[key] = _process_string_value(value) + elif key == "body" and isinstance(value, bytes): + try: + data[key] = _process_string_value(value.decode("utf-8")) + except UnicodeDecodeError: + pass + + else: + _convert_body_to_literal(value) + + elif isinstance(data, list): + for idx, choice in enumerate(data): + data[idx] = _convert_body_to_literal(choice) + + return data + + +class _PrettyPrintJSONBody: + """This makes request and response body recordings more readable.""" + + @staticmethod + def serialize(cassette_dict): + cassette_dict = _convert_body_to_literal(cassette_dict) + return yaml.dump( + cassette_dict, default_flow_style=False, allow_unicode=True + ) + + @staticmethod + def deserialize(cassette_string): + return yaml.load(cassette_string, Loader=yaml.Loader) + + +@pytest.fixture(name="fully_initialized_vcr", scope="module", autouse=True) +def setup_vcr(vcr): + vcr.register_serializer("yaml", _PrettyPrintJSONBody) + vcr.serializer = "yaml" + return vcr + + +@pytest.fixture(name="vcr_config", scope="module") +def fixture_vcr_config(): + return { + "filter_query_parameters": [ + "key", + "apiKey", + "quotaUser", + "userProject", + "token", + "access_token", + "accessToken", + "refesh_token", + "refreshToken", + "authuser", + "bearer", + "bearer_token", + "bearerToken", + "userIp", + ], + "filter_headers": [ + "x-goog-api-key", + "authorization", + "server", + "Server", + "Server-Timing", + "Date", + ], + "ignore_hosts": [ + "oauth2.googleapis.com", + "iam.googleapis.com", + ], + "decode_compressed_response": True, + } + + +@pytest.fixture(name="instrumentor") +def fixture_instrumentor(): + return GoogleGenAiSdkInstrumentor() + + +@pytest.fixture(name="setup_instrumentation", autouse=True) +def fixture_setup_instrumentation(instrumentor): + instrumentor.instrument() + yield + instrumentor.uninstrument() + + +@pytest.fixture(name="otel_mocker", autouse=True) +def fixture_otel_mocker(): + result = OTelMocker() + result.install() + yield result + result.uninstall() + + +@pytest.fixture(name="client") +def fixture_client(): + return Client( + api_key=_FAKE_API_KEY, + vertexai=False, + http_options=HttpOptions(headers={"accept-encoding": "identity"}), + ) + + +@pytest.mark.vcr +def test_sync_interactions_create(client, otel_mocker: OTelMocker): + os.environ[OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT] = ( + "SPAN_AND_EVENT" + ) + + response = client.interactions.create( + model="gemini-2.5-flash", + input="Hello, how can you help me today?", + ) + + assert response is not None + assert response.id is not None + + span = otel_mocker.get_span_named("interactions.create gemini-2.5-flash") + assert span is not None + assert span.attributes["gen_ai.provider.name"] == "gemini" + assert span.attributes["gen_ai.request.model"] == "gemini-2.5-flash" + assert span.attributes["gen_ai.response.model"] == "gemini-2.5-flash" + assert span.attributes["gen_ai.operation.name"] == "interactions.create" + + +@pytest.mark.vcr +@pytest.mark.asyncio +async def test_async_interactions_create(client, otel_mocker: OTelMocker): + os.environ[OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT] = ( + "SPAN_AND_EVENT" + ) + + response = await client.aio.interactions.create( + model="gemini-2.5-flash", + input="Hello, how can you help me today?", + ) + + assert response is not None + assert response.id is not None + + span = otel_mocker.get_span_named("interactions.create gemini-2.5-flash") + assert span is not None + assert span.attributes["gen_ai.provider.name"] == "gemini" + assert span.attributes["gen_ai.request.model"] == "gemini-2.5-flash" + assert span.attributes["gen_ai.response.model"] == "gemini-2.5-flash" + assert span.attributes["gen_ai.operation.name"] == "interactions.create" diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py new file mode 100644 index 00000000..39344cbe --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py @@ -0,0 +1,28 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from typing import Any + +from .base import TestCase + + +class TestInteractionsAsync(TestCase): + def run_interaction(self, *args: Any, **kwargs: Any) -> Any: + return asyncio.run( + self.client.aio.interactions.create(*args, **kwargs) + ) + + def run_streaming_interaction( + self, *args: Any, **kwargs: Any + ) -> list[Any]: + async def _run() -> list[Any]: + stream = await self.client.aio.interactions.create(*args, **kwargs) + events = [] + async for event in stream: + events.append(event) + return events + + return asyncio.run(_run()) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py new file mode 100644 index 00000000..19241f54 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py @@ -0,0 +1,19 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +from .base import TestCase + + +class TestInteractionsSync(TestCase): + def run_interaction(self, *args: Any, **kwargs: Any) -> Any: + return self.client.interactions.create(*args, **kwargs) + + def run_streaming_interaction( + self, *args: Any, **kwargs: Any + ) -> list[Any]: + stream = self.client.interactions.create(*args, **kwargs) + return list(stream) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py new file mode 100644 index 00000000..67d047ab --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -0,0 +1,124 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import unittest +import unittest.mock + +from opentelemetry.instrumentation.google_genai.interactions import ( + _interactions_input_to_messages, + _interactions_response_to_messages, +) +from opentelemetry.util.genai.types import ( + GenericPart, + Text, + ToolCallRequest, + ToolCallResponse, + Uri, +) + + +class TestInteractionsParser(unittest.TestCase): + def test_input_to_messages_none(self) -> None: + self.assertEqual(_interactions_input_to_messages(None), []) + + def test_input_to_messages_str(self) -> None: + messages = _interactions_input_to_messages("Hello world") + self.assertEqual(messages[0].role, "user") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello world") + + def test_input_to_messages_list_of_strings(self) -> None: + messages = _interactions_input_to_messages(["Hello", "world"]) + self.assertEqual(len(messages[0].parts), 2) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello") + self.assertIsInstance(messages[0].parts[1], Text) + self.assertEqual(messages[0].parts[1].content, "world") + + def test_input_to_messages_text_step(self) -> None: + steps = [{"type": "text", "text": "Hello text step"}] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello text step") + + def test_input_to_messages_document_step(self) -> None: + steps = [ + { + "type": "document", + "mime_type": "application/pdf", + "uri": "https://example.com/doc.pdf", + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Uri) + self.assertEqual(messages[0].parts[0].mime_type, "application/pdf") + self.assertEqual(messages[0].parts[0].modality, "document") + self.assertEqual( + messages[0].parts[0].uri, "https://example.com/doc.pdf" + ) + + def test_input_to_messages_tool_call_step(self) -> None: + steps = [ + { + "type": "function_call", + "id": "call-123", + "name": "calc", + "arguments": {"x": 5}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], ToolCallRequest) + self.assertEqual(messages[0].parts[0].id, "call-123") + self.assertEqual(messages[0].parts[0].name, "calc") + self.assertEqual(messages[0].parts[0].arguments, {"x": 5}) + + def test_input_to_messages_tool_result_step(self) -> None: + steps = [ + { + "type": "function_result", + "call_id": "call-123", + "result": {"val": 10}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], ToolCallResponse) + self.assertEqual(messages[0].parts[0].id, "call-123") + self.assertEqual(messages[0].parts[0].response, {"val": 10}) + + def test_input_to_messages_generic_fallback(self) -> None: + steps = [{"type": "some_unsupported_type"}] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], GenericPart) + self.assertEqual(messages[0].parts[0].value, "dict") + + def test_input_to_messages_single_non_sequence_step(self) -> None: + step = {"type": "text", "text": "Hello single step"} + messages = _interactions_input_to_messages(step) + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello single step") + + def test_input_to_messages_none_type_fall_through(self) -> None: + step = {"other_field": "no type specified"} + messages = _interactions_input_to_messages(step) + self.assertEqual(len(messages[0].parts), 0) + + def test_response_to_messages(self) -> None: + mock_interaction = unittest.mock.MagicMock() + mock_interaction.output_text = "Model response text" + + messages = _interactions_response_to_messages(mock_interaction) + + self.assertEqual(messages[0].role, "assistant") + self.assertEqual(messages[0].finish_reason, "stop") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Model response text") diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py new file mode 100644 index 00000000..3889e501 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py @@ -0,0 +1,50 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import unittest.mock +from typing import Any + + +def create_mock_interaction( + interaction_id: str = "test-id", + model_name: str = "test-model", + input_text: str = "user input", + output_text: str = "model output", + input_tokens: int = 10, + output_tokens: int = 20, +) -> Any: + mock_usage = unittest.mock.MagicMock() + mock_usage.total_input_tokens = input_tokens + mock_usage.total_output_tokens = output_tokens + mock_usage.total_thought_tokens = 0 + mock_usage.total_cached_tokens = 0 + + mock_user_step = unittest.mock.MagicMock() + mock_user_step.type = "user_input" + mock_user_part = unittest.mock.MagicMock() + mock_user_part.text = input_text + mock_user_step.content = [mock_user_part] + + mock_model_step = unittest.mock.MagicMock() + mock_model_step.type = "model_output" + mock_model_part = unittest.mock.MagicMock() + mock_model_part.text = output_text + mock_model_step.content = [mock_model_part] + + mock_interaction = unittest.mock.MagicMock() + mock_interaction.id = interaction_id + mock_interaction.model = model_name + mock_interaction.usage = mock_usage + mock_interaction.steps = [mock_user_step, mock_model_step] + mock_interaction.output_text = output_text + + return mock_interaction + + +def create_mock_completed_event(interaction: Any) -> Any: + event = unittest.mock.MagicMock() + event.event_type = "interaction_completed" + event.interaction = interaction + return event diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 80756744..e52a5b55 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -15,8 +15,8 @@ # This variant of the requirements aims to test the system using # the oldest supported version of external dependencies. -google-auth==2.15.0 -google-genai==1.32.0 +google-auth==2.50.0 +google-genai==2.0.0 opentelemetry-api==1.40.0 opentelemetry-sdk==1.40.0 opentelemetry-semantic-conventions==0.61b0