From 56d55ed4152458324e8eb1e528ac12f261bede0f Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 23 Jun 2026 18:01:12 +0000 Subject: [PATCH 01/10] Initial Commit to moneky patch interactions API --- .../.changelog/151.added | 1 + ..._instrumentation_lib_and_save_telemetry.py | 1 - .../google_genai/instrumentor.py | 9 + .../google_genai/interactions.py | 464 ++++++++++++++++++ .../tests/interactions/__init__.py | 2 + .../tests/interactions/base.py | 276 +++++++++++ .../interactions/test_interactions_async.py | 26 + .../interactions/test_interactions_sync.py | 17 + .../tests/interactions/test_parser.py | 177 +++++++ .../tests/interactions/util.py | 49 ++ 10 files changed, 1021 insertions(+), 1 deletion(-) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/__init__.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added new file mode 100644 index 00000000..0c51affb --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added @@ -0,0 +1 @@ +Add instrumentation for InteractionsResource.create and AsyncInteractionsResource.create. 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..96bc3122 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 @@ -63,7 +63,6 @@ def add(a: int, b: int) -> int: def main(): GoogleGenAiSdkInstrumentor().instrument() client = genai.Client( - vertexai=True, project=os.environ["PROJECT_ID"], location=os.environ["LOCATION"], ) 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..770a2ea7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/interactions.py @@ -0,0 +1,464 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import base64 +from collections.abc import AsyncIterable, Callable, Iterable +from typing import Any, cast + +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, +) +from google.genai.types import Content +from wrapt import wrap_function_wrapper + +from opentelemetry import context as context_api +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 ( + Blob, + GenericPart, + InputMessage, + MessagePart, + OutputMessage, + Reasoning, + ServerToolCall, + ServerToolCallResponse, + Text, + ToolCallRequest, + ToolCallResponse, + Uri, +) + +from .generate_content import GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY + + +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_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 +def _content_param_to_part(part: Any) -> MessagePart: + part_type = _get_field(part, "type") + + if part_type == "text": + return Text(content=_get_field(part, "text") or "") + + part_text = _get_field(part, "text") + if part_text is not None: + return Text(content=part_text) + + inline_data = _get_field(part, "inline_data") + if inline_data: + return Blob( + mime_type=_get_field(inline_data, "mime_type"), + modality="image", + content=_get_field(inline_data, "data") or b"", + ) + + file_data = _get_field(part, "file_data") + if file_data: + return Uri( + mime_type=_get_field(file_data, "mime_type"), + modality="image", + uri=_get_field(file_data, "file_uri") or "", + ) + + fn_call = _get_field(part, "function_call") + if fn_call: + return ToolCallRequest( + id=_get_field(fn_call, "id"), + name=_get_field(fn_call, "name") or "", + arguments=_get_field(fn_call, "args") or {}, + ) + + fn_resp = _get_field(part, "function_response") + if fn_resp: + return ToolCallResponse( + id=_get_field(fn_resp, "id"), + response=_get_field(fn_resp, "response") or {}, + ) + + mime_type = _get_field(part, "mime_type") + uri = _get_field(part, "uri") + data = _get_field(part, "data") + + if uri: + return Uri( + mime_type=mime_type, + modality=part_type or "image", + uri=uri, + ) + elif data: + content_bytes = data + if isinstance(data, str): + try: + content_bytes = base64.b64decode(data) + except Exception: + content_bytes = data.encode("utf-8") + return Blob( + mime_type=mime_type, + modality=part_type or "image", + content=content_bytes, + ) + + return GenericPart(value=type(part).__name__) + + +def _get_thought_text(summary_list: Iterable[Any]) -> str: + texts = [] + for s in summary_list: + text = _get_field(s, "text") + if text: + texts.append(text) + return "\n".join(texts) + + +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)])] + + # Content is iterable over key/value pairs, but is not a list.. + if not isinstance(input_data, Iterable) or isinstance(input_data, Content): + input_data = [input_data] + + messages = [] + for item in input_data: + if isinstance(item, Content): + item = {"type": "user_input", "content": item.parts} + + item_type = _get_field(item, "type") + if item_type == "user_input": + parts = [] + content = _get_field(item, "content") + for part in content: + parts.append(_content_param_to_part(part)) + messages.append(InputMessage(role="user", parts=parts)) + elif item_type == "model_output": + parts = [] + content = _get_field(item, "content") + for part in content: + parts.append(_content_param_to_part(part)) + messages.append(InputMessage(role="assistant", parts=parts)) + elif item_type == "thought": + summary = _get_field(item, "summary") + text = _get_thought_text(summary) + messages.append(InputMessage(role="assistant", parts=[Reasoning(content=text)])) + elif 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) + messages.append(InputMessage(role="assistant", parts=[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) + messages.append(InputMessage(role="tool", parts=[part])) + elif item_type in ( + "code_execution_call", + "google_search_call", + "google_maps_call", + "file_search_call", + "mcp_server_tool_call", + "url_context_call", + ): + call_id = _get_field(item, "id") + arguments = _get_field(item, "arguments") + part = ServerToolCall(name=str(item_type), server_tool_call=arguments, id=call_id) + messages.append(InputMessage(role="assistant", parts=[part])) + elif item_type in ( + "code_execution_result", + "google_search_result", + "google_maps_result", + "file_search_result", + "mcp_server_tool_result", + "url_context_result", + ): + call_id = _get_field(item, "call_id") + result = _get_field(item, "result") + part = ServerToolCallResponse(server_tool_call_response=result, id=call_id) + messages.append(InputMessage(role="tool", parts=[part])) + elif isinstance(item, str): + messages.append( + InputMessage(role="user", parts=[Text(content=item)]) + ) + elif item_type is not None: + part = GenericPart(value=type(item).__name__) + messages.append(InputMessage(role="user", parts=[part])) + + return messages + + +def _interactions_response_to_messages(interaction: Interaction) -> list[OutputMessage]: + messages = [] + for step in _get_field(interaction, "steps") or []: + if _get_field(step, "type") == "model_output": + parts = [] + for part in _get_field(step, "content") or []: + part_text = _get_field(part, "text") + if part_text is not None: + parts.append(Text(content=part_text)) + messages.append( + OutputMessage( + role="assistant", + parts=parts, + finish_reason="stop", + ) + ) + break + return messages + + +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: + # Verrex 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. + invocation = telemetry_handler.inference( + provider=( + GenAIAttributes.GenAiSystemValues.VERTEX_AI.value + if getattr(instance._client, "_is_vertex", False) + else GenAIAttributes.GenAiSystemValues.GEMINI.value + ), + request_model=kwargs.get("model") or kwargs.get("agent") or "unknown", + operation_name="interactions.create", + server_address=getattr(instance._client, "server", None), + ) + + attrs = context_api.get_value( + GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY + ) + if attrs: + invocation.attributes.update(dict(attrs)) + + 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: + invocation = telemetry_handler.inference( + provider=( + GenAIAttributes.GenAiSystemValues.VERTEX_AI.value + if getattr(instance._client, "_is_vertex", False) + else GenAIAttributes.GenAiSystemValues.GEMINI.value + ), + request_model=kwargs.get("model") or kwargs.get("agent") or "unknown", + operation_name="interactions.create", + server_address=getattr(instance._client, "server", None), + ) + + attrs = context_api.get_value( + GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY + ) + if attrs: + invocation.attributes.update(dict(attrs)) + + 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() + wrap_function_wrapper( + "google.genai._interactions.resources.interactions", + "InteractionsResource.create", + _create_instrumented_interactions_create(telemetry_handler), + ) + wrap_function_wrapper( + "google.genai._interactions.resources.interactions", + "AsyncInteractionsResource.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..a56f46f3 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -0,0 +1,276 @@ +# 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 + +from google.genai._interactions.resources.interactions import ( + AsyncInteractionsResource, + InteractionsResource, +) + +from opentelemetry import context as context_api +from opentelemetry.instrumentation.google_genai import ( + GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY, +) +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_generated_span_has_extra_genai_attributes(self) -> None: + self.configure_valid_interaction() + tok = context_api.attach( + context_api.set_value( + GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY, + {"extra_attribute_key": "extra_attribute_value"}, + ) + ) + try: + 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["extra_attribute_key"], "extra_attribute_value" + ) + finally: + context_api.detach(tok) + + 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/test_interactions_async.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py new file mode 100644 index 00000000..80a5b2c8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py @@ -0,0 +1,26 @@ +# 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..831539ab --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py @@ -0,0 +1,17 @@ +# 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..3d2de75d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -0,0 +1,177 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import unittest +import unittest.mock + +from google.genai.types import Content, Part + +from opentelemetry.instrumentation.google_genai.interactions import ( + _interactions_input_to_messages, + _interactions_response_to_messages, +) +from opentelemetry.util.genai.types import ( + Reasoning, + ServerToolCall, + ServerToolCallResponse, + Text, + ToolCallRequest, + ToolCallResponse, +) + + +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(len(messages), 1) + 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_content_object(self) -> None: + content = Content(parts=[Part(text="Hello content")]) + messages = _interactions_input_to_messages(content) + self.assertEqual(len(messages), 1) + 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 content") + + def test_input_to_messages_steps_list_user_input(self) -> None: + steps = [ + { + "type": "user_input", + "content": [{"type": "text", "text": "Hello step"}], + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + 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 step") + + def test_input_to_messages_steps_list_model_output(self) -> None: + steps = [ + { + "type": "model_output", + "content": [{"type": "text", "text": "Hello response"}], + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "assistant") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello response") + + def test_input_to_messages_steps_list_thought(self) -> None: + steps = [ + { + "type": "thought", + "summary": [{"text": "First thought"}, {"text": "Second thought"}], + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "assistant") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], Reasoning) + self.assertEqual(messages[0].parts[0].content, "First thought\nSecond thought") + + def test_input_to_messages_steps_list_tool_call_request(self) -> None: + steps = [ + { + "type": "function_call", + "id": "call-123", + "name": "calc", + "arguments": {"x": 5}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "assistant") + 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_steps_list_tool_call_response(self) -> None: + steps = [ + { + "type": "function_result", + "call_id": "call-123", + "result": {"val": 10}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "tool") + 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_steps_list_server_tool_call(self) -> None: + steps = [ + { + "type": "code_execution_call", + "id": "code-123", + "arguments": {"code": "print(1)"}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "assistant") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], ServerToolCall) + self.assertEqual(messages[0].parts[0].id, "code-123") + self.assertEqual(messages[0].parts[0].name, "code_execution_call") + self.assertEqual(messages[0].parts[0].server_tool_call, {"code": "print(1)"}) + + def test_input_to_messages_steps_list_server_tool_call_response(self) -> None: + steps = [ + { + "type": "code_execution_result", + "call_id": "code-123", + "result": {"output": "1"}, + } + ] + messages = _interactions_input_to_messages(steps) + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0].role, "tool") + self.assertEqual(len(messages[0].parts), 1) + self.assertIsInstance(messages[0].parts[0], ServerToolCallResponse) + self.assertEqual(messages[0].parts[0].id, "code-123") + self.assertEqual(messages[0].parts[0].server_tool_call_response, {"output": "1"}) + + def test_response_to_messages(self) -> None: + mock_step_1 = unittest.mock.MagicMock() + mock_step_1.type = "model_output" + + mock_part = unittest.mock.MagicMock() + mock_part.text = "Model response text" + mock_step_1.content = [mock_part] + + mock_step_2 = unittest.mock.MagicMock() + mock_step_2.type = "model_output" + mock_step_2.content = [unittest.mock.MagicMock(text="Second response")] + + mock_interaction = unittest.mock.MagicMock() + mock_interaction.steps = [mock_step_1, mock_step_2] + + messages = _interactions_response_to_messages(mock_interaction) + + self.assertEqual(len(messages), 1) + 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..e950a829 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py @@ -0,0 +1,49 @@ +# 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] + + 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 From f0d8767106e52160073ce50cfbe444d90909a103 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 23 Jun 2026 20:43:29 +0000 Subject: [PATCH 02/10] Bunch of changes --- .../README.rst | 4 +- ..._instrumentation_lib_and_save_telemetry.py | 20 +- .../dev/main.env | 4 +- .../dev/pyproject.toml | 6 +- .../google_genai/interactions.py | 235 +++++------------- .../tests/interactions/base.py | 48 ++-- .../interactions/test_interactions_async.py | 4 +- .../interactions/test_interactions_sync.py | 4 +- .../tests/interactions/test_parser.py | 129 +++------- .../tests/interactions/util.py | 1 + 10 files changed, 153 insertions(+), 302 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst index 1839d30b..7c44f71e 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst +++ b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst @@ -44,10 +44,10 @@ 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. 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 96bc3122..5635a270 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 @@ -4,7 +4,6 @@ import os from google import genai -from google.genai import types from google.protobuf import text_format from opentelemetry import _logs as logs @@ -62,18 +61,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=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..e7076011 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", @@ -24,8 +24,8 @@ no-sources = false # NOT USED unless no-sources is false [tool.uv.sources] -opentelemetry-util-genai = { git = "https://github.com/open-telemetry/opentelemetry-python-genai.git", branch = "main", subdirectory="util/opentelemetry-util-genai" } -opentelemetry-instrumentation-google-genai = { git = "https://github.com/open-telemetry/opentelemetry-python-genai.git", branch = "main", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } +opentelemetry-util-genai = { git = "https://github.com/dylanrussell/opentelemetry-python-genai.git", branch = "interactions_api", subdirectory="util/opentelemetry-util-genai" } +opentelemetry-instrumentation-google-genai = { git = "https://github.com/dylanrussell/opentelemetry-python-genai.git", branch = "interactions_api", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } opentelemetry-sdk = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-sdk" } opentelemetry-exporter-otlp-proto-grpc = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="exporter/opentelemetry-exporter-otlp-proto-grpc" } opentelemetry-semantic-conventions = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-semantic-conventions" } 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 index 770a2ea7..1efe0689 100644 --- 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 @@ -3,8 +3,7 @@ from __future__ import annotations -import base64 -from collections.abc import AsyncIterable, Callable, Iterable +from collections.abc import AsyncIterable, Callable, Iterable, Sequence from typing import Any, cast from google.genai._interactions._streaming import Stream @@ -17,7 +16,6 @@ from google.genai._interactions.types.interaction_sse_event import ( InteractionSSEEvent, ) -from google.genai.types import Content from wrapt import wrap_function_wrapper from opentelemetry import context as context_api @@ -33,14 +31,9 @@ SyncStreamWrapper, ) from opentelemetry.util.genai.types import ( - Blob, GenericPart, InputMessage, - MessagePart, OutputMessage, - Reasoning, - ServerToolCall, - ServerToolCallResponse, Text, ToolCallRequest, ToolCallResponse, @@ -75,7 +68,9 @@ def _apply_interaction_response_attributes( invocation.cache_read_input_tokens = usage.total_cached_tokens if telemetry_handler.should_capture_content(): - invocation.output_messages = _interactions_response_to_messages(response) + invocation.output_messages = _interactions_response_to_messages( + response + ) def _get_field(obj: Any, name: str) -> Any: @@ -83,181 +78,73 @@ def _get_field(obj: Any, name: str) -> Any: 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 -def _content_param_to_part(part: Any) -> MessagePart: - part_type = _get_field(part, "type") - - if part_type == "text": - return Text(content=_get_field(part, "text") or "") - - part_text = _get_field(part, "text") - if part_text is not None: - return Text(content=part_text) - - inline_data = _get_field(part, "inline_data") - if inline_data: - return Blob( - mime_type=_get_field(inline_data, "mime_type"), - modality="image", - content=_get_field(inline_data, "data") or b"", - ) - - file_data = _get_field(part, "file_data") - if file_data: - return Uri( - mime_type=_get_field(file_data, "mime_type"), - modality="image", - uri=_get_field(file_data, "file_uri") or "", - ) - - fn_call = _get_field(part, "function_call") - if fn_call: - return ToolCallRequest( - id=_get_field(fn_call, "id"), - name=_get_field(fn_call, "name") or "", - arguments=_get_field(fn_call, "args") or {}, - ) - - fn_resp = _get_field(part, "function_response") - if fn_resp: - return ToolCallResponse( - id=_get_field(fn_resp, "id"), - response=_get_field(fn_resp, "response") or {}, - ) - - mime_type = _get_field(part, "mime_type") - uri = _get_field(part, "uri") - data = _get_field(part, "data") - - if uri: - return Uri( - mime_type=mime_type, - modality=part_type or "image", - uri=uri, - ) - elif data: - content_bytes = data - if isinstance(data, str): - try: - content_bytes = base64.b64decode(data) - except Exception: - content_bytes = data.encode("utf-8") - return Blob( - mime_type=mime_type, - modality=part_type or "image", - content=content_bytes, - ) - - return GenericPart(value=type(part).__name__) - - -def _get_thought_text(summary_list: Iterable[Any]) -> str: - texts = [] - for s in summary_list: - text = _get_field(s, "text") - if text: - texts.append(text) - return "\n".join(texts) - - -def _interactions_input_to_messages(input_data: Input | None) -> list[InputMessage]: +# IT doesn't make sense this be a List[InputMessage] as per the sem-conv +# as this API doesn't take the conversation histroy as inputs, unlike the generate_content API. +# This API stores conversation history server side in a conversation ID param. +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)])] - # Content is iterable over key/value pairs, but is not a list.. - if not isinstance(input_data, Iterable) or isinstance(input_data, Content): + if not isinstance(input_data, Sequence): input_data = [input_data] - messages = [] + parts = [] for item in input_data: - if isinstance(item, Content): - item = {"type": "user_input", "content": item.parts} - item_type = _get_field(item, "type") - if item_type == "user_input": - parts = [] - content = _get_field(item, "content") - for part in content: - parts.append(_content_param_to_part(part)) - messages.append(InputMessage(role="user", parts=parts)) - elif item_type == "model_output": - parts = [] - content = _get_field(item, "content") - for part in content: - parts.append(_content_param_to_part(part)) - messages.append(InputMessage(role="assistant", parts=parts)) - elif item_type == "thought": - summary = _get_field(item, "summary") - text = _get_thought_text(summary) - messages.append(InputMessage(role="assistant", parts=[Reasoning(content=text)])) - elif item_type == "function_call": + 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) - messages.append(InputMessage(role="assistant", parts=[part])) + 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) - messages.append(InputMessage(role="tool", parts=[part])) - elif item_type in ( - "code_execution_call", - "google_search_call", - "google_maps_call", - "file_search_call", - "mcp_server_tool_call", - "url_context_call", - ): - call_id = _get_field(item, "id") - arguments = _get_field(item, "arguments") - part = ServerToolCall(name=str(item_type), server_tool_call=arguments, id=call_id) - messages.append(InputMessage(role="assistant", parts=[part])) - elif item_type in ( - "code_execution_result", - "google_search_result", - "google_maps_result", - "file_search_result", - "mcp_server_tool_result", - "url_context_result", - ): - call_id = _get_field(item, "call_id") - result = _get_field(item, "result") - part = ServerToolCallResponse(server_tool_call_response=result, id=call_id) - messages.append(InputMessage(role="tool", parts=[part])) + parts.append(part) elif isinstance(item, str): - messages.append( - InputMessage(role="user", parts=[Text(content=item)]) + 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__) - messages.append(InputMessage(role="user", parts=[part])) - - return messages - - -def _interactions_response_to_messages(interaction: Interaction) -> list[OutputMessage]: - messages = [] - for step in _get_field(interaction, "steps") or []: - if _get_field(step, "type") == "model_output": - parts = [] - for part in _get_field(step, "content") or []: - part_text = _get_field(part, "text") - if part_text is not None: - parts.append(Text(content=part_text)) - messages.append( - OutputMessage( - role="assistant", - parts=parts, - finish_reason="stop", - ) - ) - break - return messages + parts.append(part) + + return [InputMessage(role="user", parts=parts)] + + +# again a list of output messages doesn't quite make sense. +# https://ai.google.dev/gemini-api/docs/migrate-to-interactions#basic-input-output -- there is now just +# a list of steps returned which explains the steps the model took. +# There are a large number of step types: https://ai.google.dev/api/interactions-api#Resource:Step +def _interactions_response_to_messages( + interaction: Interaction, +) -> list[OutputMessage]: + return [ + OutputMessage( + role="assistant", + parts=[Text(content=interaction.output_text)], + finish_reason="stop", + ) + ] class InteractionsStreamWrapper(SyncStreamWrapper[InteractionSSEEvent]): @@ -349,7 +236,9 @@ def instrumented_interactions_create( if getattr(instance._client, "_is_vertex", False) else GenAIAttributes.GenAiSystemValues.GEMINI.value ), - request_model=kwargs.get("model") or kwargs.get("agent") or "unknown", + request_model=kwargs.get("model") + or kwargs.get("agent") + or "unknown", operation_name="interactions.create", server_address=getattr(instance._client, "server", None), ) @@ -361,14 +250,18 @@ def instrumented_interactions_create( invocation.attributes.update(dict(attrs)) if telemetry_handler.should_capture_content(): - invocation.input_messages = _interactions_input_to_messages(kwargs.get("input")) + invocation.input_messages = _interactions_input_to_messages( + kwargs.get("input") + ) if system_instruction := kwargs.get("system_instruction"): - invocation.system_instruction = [Text(content=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( @@ -406,7 +299,9 @@ async def instrumented_interactions_create( if getattr(instance._client, "_is_vertex", False) else GenAIAttributes.GenAiSystemValues.GEMINI.value ), - request_model=kwargs.get("model") or kwargs.get("agent") or "unknown", + request_model=kwargs.get("model") + or kwargs.get("agent") + or "unknown", operation_name="interactions.create", server_address=getattr(instance._client, "server", None), ) @@ -418,9 +313,13 @@ async def instrumented_interactions_create( invocation.attributes.update(dict(attrs)) if telemetry_handler.should_capture_content(): - invocation.input_messages = _interactions_input_to_messages(kwargs.get("input")) + invocation.input_messages = _interactions_input_to_messages( + kwargs.get("input") + ) if system_instruction := kwargs.get("system_instruction"): - invocation.system_instruction = [Text(content=system_instruction)] + invocation.system_instruction = [ + Text(content=system_instruction) + ] if kwargs.get("stream", False): return AsyncInteractionsStreamWrapper( diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py index a56f46f3..5d262aa9 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -59,7 +59,9 @@ def _create_and_install_mocks(self, e: Exception | None = None) -> None: self._create_mock = self._create_mock_impl(e) self._install_mocks() - def _create_mock_impl(self, e: Exception | None = None) -> unittest.mock.MagicMock: + def _create_mock_impl( + self, e: Exception | None = None + ) -> unittest.mock.MagicMock: mock = unittest.mock.MagicMock() def _default_impl(*args: Any, **kwargs: Any) -> Any: @@ -87,9 +89,11 @@ 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 @@ -105,7 +109,9 @@ def tearDown(self) -> None: def run_interaction(self, *args: Any, **kwargs: Any) -> Any: raise NotImplementedError() - def run_streaming_interaction(self, *args: Any, **kwargs: Any) -> list[Any]: + def run_streaming_interaction( + self, *args: Any, **kwargs: Any + ) -> list[Any]: raise NotImplementedError() # The actual collapsed test cases: @@ -122,23 +128,17 @@ def test_instrumentation_does_not_break_core_functionality(self) -> None: def test_generates_span(self) -> None: self.configure_valid_interaction() - self.run_interaction( - model="gemini-2.5-flash", input="Does this work?" - ) + 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.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?" - ) + 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( @@ -157,14 +157,18 @@ def test_generated_span_has_extra_genai_attributes(self) -> None: self.run_interaction( model="gemini-2.5-flash", input="Does this work?" ) - span = self.otel.get_span_named("interactions.create gemini-2.5-flash") + span = self.otel.get_span_named( + "interactions.create gemini-2.5-flash" + ) self.assertEqual( span.attributes["extra_attribute_key"], "extra_attribute_value" ) finally: context_api.detach(tok) - def test_span_and_event_still_written_when_response_is_exception(self) -> None: + 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( @@ -178,21 +182,13 @@ def test_span_and_event_still_written_when_response_is_exception(self) -> None: 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" - ) + 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?" - ) + 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( @@ -204,9 +200,7 @@ def test_generated_span_counts_tokens(self) -> None: input_tokens=15, output_tokens=25, ) - self.run_interaction( - model="gemini-2.5-flash", input="Some input" - ) + 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) 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 index 80a5b2c8..39344cbe 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_async.py @@ -15,7 +15,9 @@ def run_interaction(self, *args: Any, **kwargs: Any) -> Any: self.client.aio.interactions.create(*args, **kwargs) ) - def run_streaming_interaction(self, *args: Any, **kwargs: Any) -> list[Any]: + 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 = [] 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 index 831539ab..19241f54 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_interactions_sync.py @@ -12,6 +12,8 @@ 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]: + 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 index 3d2de75d..229abb0b 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -6,19 +6,16 @@ import unittest import unittest.mock -from google.genai.types import Content, Part - from opentelemetry.instrumentation.google_genai.interactions import ( _interactions_input_to_messages, _interactions_response_to_messages, ) from opentelemetry.util.genai.types import ( - Reasoning, - ServerToolCall, - ServerToolCallResponse, + GenericPart, Text, ToolCallRequest, ToolCallResponse, + Uri, ) @@ -28,64 +25,36 @@ def test_input_to_messages_none(self) -> None: def test_input_to_messages_str(self) -> None: messages = _interactions_input_to_messages("Hello world") - self.assertEqual(len(messages), 1) 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_content_object(self) -> None: - content = Content(parts=[Part(text="Hello content")]) - messages = _interactions_input_to_messages(content) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "user") - self.assertEqual(len(messages[0].parts), 1) + 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 content") + 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_steps_list_user_input(self) -> None: - steps = [ - { - "type": "user_input", - "content": [{"type": "text", "text": "Hello step"}], - } - ] + 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), 1) - 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 step") + self.assertEqual(messages[0].parts[0].content, "Hello text step") - def test_input_to_messages_steps_list_model_output(self) -> None: - steps = [ - { - "type": "model_output", - "content": [{"type": "text", "text": "Hello response"}], - } - ] - messages = _interactions_input_to_messages(steps) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "assistant") - self.assertEqual(len(messages[0].parts), 1) - self.assertIsInstance(messages[0].parts[0], Text) - self.assertEqual(messages[0].parts[0].content, "Hello response") - - def test_input_to_messages_steps_list_thought(self) -> None: - steps = [ - { - "type": "thought", - "summary": [{"text": "First thought"}, {"text": "Second thought"}], - } - ] + 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), 1) - self.assertEqual(messages[0].role, "assistant") self.assertEqual(len(messages[0].parts), 1) - self.assertIsInstance(messages[0].parts[0], Reasoning) - self.assertEqual(messages[0].parts[0].content, "First thought\nSecond thought") + 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_steps_list_tool_call_request(self) -> None: + def test_input_to_messages_tool_call_step(self) -> None: steps = [ { "type": "function_call", @@ -95,15 +64,13 @@ def test_input_to_messages_steps_list_tool_call_request(self) -> None: } ] messages = _interactions_input_to_messages(steps) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "assistant") 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_steps_list_tool_call_response(self) -> None: + def test_input_to_messages_tool_result_step(self) -> None: steps = [ { "type": "function_result", @@ -112,64 +79,36 @@ def test_input_to_messages_steps_list_tool_call_response(self) -> None: } ] messages = _interactions_input_to_messages(steps) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "tool") 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_steps_list_server_tool_call(self) -> None: - steps = [ - { - "type": "code_execution_call", - "id": "code-123", - "arguments": {"code": "print(1)"}, - } - ] + def test_input_to_messages_generic_fallback(self) -> None: + steps = [{"type": "some_unsupported_type"}] messages = _interactions_input_to_messages(steps) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "assistant") self.assertEqual(len(messages[0].parts), 1) - self.assertIsInstance(messages[0].parts[0], ServerToolCall) - self.assertEqual(messages[0].parts[0].id, "code-123") - self.assertEqual(messages[0].parts[0].name, "code_execution_call") - self.assertEqual(messages[0].parts[0].server_tool_call, {"code": "print(1)"}) + self.assertIsInstance(messages[0].parts[0], GenericPart) + self.assertEqual(messages[0].parts[0].value, "dict") - def test_input_to_messages_steps_list_server_tool_call_response(self) -> None: - steps = [ - { - "type": "code_execution_result", - "call_id": "code-123", - "result": {"output": "1"}, - } - ] - messages = _interactions_input_to_messages(steps) - self.assertEqual(len(messages), 1) - self.assertEqual(messages[0].role, "tool") + 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], ServerToolCallResponse) - self.assertEqual(messages[0].parts[0].id, "code-123") - self.assertEqual(messages[0].parts[0].server_tool_call_response, {"output": "1"}) - - def test_response_to_messages(self) -> None: - mock_step_1 = unittest.mock.MagicMock() - mock_step_1.type = "model_output" - - mock_part = unittest.mock.MagicMock() - mock_part.text = "Model response text" - mock_step_1.content = [mock_part] + self.assertIsInstance(messages[0].parts[0], Text) + self.assertEqual(messages[0].parts[0].content, "Hello single step") - mock_step_2 = unittest.mock.MagicMock() - mock_step_2.type = "model_output" - mock_step_2.content = [unittest.mock.MagicMock(text="Second response")] + 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.steps = [mock_step_1, mock_step_2] + mock_interaction.output_text = "Model response text" messages = _interactions_response_to_messages(mock_interaction) - self.assertEqual(len(messages), 1) self.assertEqual(messages[0].role, "assistant") self.assertEqual(messages[0].finish_reason, "stop") self.assertEqual(len(messages[0].parts), 1) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py index e950a829..3889e501 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/util.py @@ -38,6 +38,7 @@ def create_mock_interaction( 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 From f862ce30f3a194f58a7b233343dfaa153689c596 Mon Sep 17 00:00:00 2001 From: DylanRussell Date: Tue, 23 Jun 2026 17:00:52 -0400 Subject: [PATCH 03/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../opentelemetry/instrumentation/google_genai/interactions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1efe0689..4098277d 100644 --- 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 @@ -228,7 +228,7 @@ def instrumented_interactions_create( args: tuple[Any, ...], kwargs: dict[str, Any], ) -> Interaction | InteractionsStreamWrapper: - # Verrex ai does not support the interactions API yet, but eventually will. + # 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. invocation = telemetry_handler.inference( provider=( From f6be65ce9a0fe1a3c783fc8b6d78e86f0d0fa84e Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Tue, 23 Jun 2026 21:07:18 +0000 Subject: [PATCH 04/10] Address copilot comments --- .../README.rst | 3 ++- ...call_instrumentation_lib_and_save_telemetry.py | 1 + .../dev/pyproject.toml | 4 ++-- .../instrumentation/google_genai/interactions.py | 15 ++++++++------- .../tests/interactions/test_parser.py | 12 ++++++++++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst index 7c44f71e..52da0565 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst +++ b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst @@ -44,7 +44,8 @@ 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, and metrics to capture all telemetry emitted by the instrumentation. 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 5635a270..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 @@ -4,6 +4,7 @@ import os from google import genai +from google.genai import types from google.protobuf import text_format from opentelemetry import _logs as logs diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml index e7076011..faf63db3 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml @@ -24,8 +24,8 @@ no-sources = false # NOT USED unless no-sources is false [tool.uv.sources] -opentelemetry-util-genai = { git = "https://github.com/dylanrussell/opentelemetry-python-genai.git", branch = "interactions_api", subdirectory="util/opentelemetry-util-genai" } -opentelemetry-instrumentation-google-genai = { git = "https://github.com/dylanrussell/opentelemetry-python-genai.git", branch = "interactions_api", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } +opentelemetry-util-genai = { git = "https://github.com/opentelemetry-python/opentelemetry-python-genai.git", branch = "main", subdirectory="util/opentelemetry-util-genai" } +opentelemetry-instrumentation-google-genai = { git = "https://github.com/opentelemetry-python/opentelemetry-python-genai.git", branch = "main", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } opentelemetry-sdk = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-sdk" } opentelemetry-exporter-otlp-proto-grpc = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="exporter/opentelemetry-exporter-otlp-proto-grpc" } opentelemetry-semantic-conventions = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-semantic-conventions" } 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 index 1efe0689..38d0982e 100644 --- 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 @@ -81,9 +81,9 @@ def _get_field(obj: Any, name: str) -> Any: # 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 this be a List[InputMessage] as per the sem-conv -# as this API doesn't take the conversation histroy as inputs, unlike the generate_content API. -# This API stores conversation history server side in a conversation ID param. +# 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]: @@ -131,10 +131,11 @@ def _interactions_input_to_messages( return [InputMessage(role="user", parts=parts)] -# again a list of output messages doesn't quite make sense. -# https://ai.google.dev/gemini-api/docs/migrate-to-interactions#basic-input-output -- there is now just -# a list of steps returned which explains the steps the model took. -# There are a large number of step types: https://ai.google.dev/api/interactions-api#Resource:Step +# 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]: diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py index 229abb0b..67d047ab 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -46,13 +46,21 @@ def test_input_to_messages_text_step(self) -> None: 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"}] + 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") + self.assertEqual( + messages[0].parts[0].uri, "https://example.com/doc.pdf" + ) def test_input_to_messages_tool_call_step(self) -> None: steps = [ From 4156cd94c3bdc0def8dd27491af0715c18cc0d9a Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 24 Jun 2026 14:36:55 +0000 Subject: [PATCH 05/10] Respond to comments --- .../.changelog/{151.added => 165.added} | 0 .../dev/pyproject.toml | 4 ++-- .../instrumentation/google_genai/interactions.py | 8 ++------ .../tests/requirements.oldest.txt | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) rename instrumentation/opentelemetry-instrumentation-google-genai/.changelog/{151.added => 165.added} (100%) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/165.added similarity index 100% rename from instrumentation/opentelemetry-instrumentation-google-genai/.changelog/151.added rename to instrumentation/opentelemetry-instrumentation-google-genai/.changelog/165.added diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml index faf63db3..ebb69b01 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-google-genai/dev/pyproject.toml @@ -24,8 +24,8 @@ no-sources = false # NOT USED unless no-sources is false [tool.uv.sources] -opentelemetry-util-genai = { git = "https://github.com/opentelemetry-python/opentelemetry-python-genai.git", branch = "main", subdirectory="util/opentelemetry-util-genai" } -opentelemetry-instrumentation-google-genai = { git = "https://github.com/opentelemetry-python/opentelemetry-python-genai.git", branch = "main", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } +opentelemetry-util-genai = { git = "https://github.com/open-telemetry/opentelemetry-python-genai.git", branch = "main", subdirectory="util/opentelemetry-util-genai" } +opentelemetry-instrumentation-google-genai = { git = "https://github.com/open-telemetry/opentelemetry-python-genai.git", branch = "main", subdirectory="instrumentation/opentelemetry-instrumentation-google-genai" } opentelemetry-sdk = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-sdk" } opentelemetry-exporter-otlp-proto-grpc = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="exporter/opentelemetry-exporter-otlp-proto-grpc" } opentelemetry-semantic-conventions = { git = "https://github.com/open-telemetry/opentelemetry-python.git", branch = "main", subdirectory="opentelemetry-semantic-conventions" } 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 index da062caa..569c13c3 100644 --- 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 @@ -237,9 +237,7 @@ def instrumented_interactions_create( if getattr(instance._client, "_is_vertex", False) else GenAIAttributes.GenAiSystemValues.GEMINI.value ), - request_model=kwargs.get("model") - or kwargs.get("agent") - or "unknown", + request_model=kwargs.get("model") or kwargs.get("agent"), operation_name="interactions.create", server_address=getattr(instance._client, "server", None), ) @@ -300,9 +298,7 @@ async def instrumented_interactions_create( if getattr(instance._client, "_is_vertex", False) else GenAIAttributes.GenAiSystemValues.GEMINI.value ), - request_model=kwargs.get("model") - or kwargs.get("agent") - or "unknown", + request_model=kwargs.get("model") or kwargs.get("agent"), operation_name="interactions.create", server_address=getattr(instance._client, "server", None), ) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 80756744..83ff7d47 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -16,7 +16,7 @@ # the oldest supported version of external dependencies. google-auth==2.15.0 -google-genai==1.32.0 +google-genai==2.0.0 opentelemetry-api==1.40.0 opentelemetry-sdk==1.40.0 opentelemetry-semantic-conventions==0.61b0 From 921fc46e0daaadaa3dbcc9c0c26f20202d375a42 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 24 Jun 2026 14:44:05 +0000 Subject: [PATCH 06/10] Fix reqs --- .../tests/requirements.oldest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 83ff7d47..e52a5b55 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -15,7 +15,7 @@ # This variant of the requirements aims to test the system using # the oldest supported version of external dependencies. -google-auth==2.15.0 +google-auth==2.50.0 google-genai==2.0.0 opentelemetry-api==1.40.0 opentelemetry-sdk==1.40.0 From d79c3066a7a0b683951cec080e7eade526cd6b93 Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 24 Jun 2026 15:27:05 +0000 Subject: [PATCH 07/10] Fix broken import --- .../google_genai/interactions.py | 93 +++++++++++++++---- .../tests/interactions/base.py | 17 +++- 2 files changed, 88 insertions(+), 22 deletions(-) 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 index 569c13c3..02ae97d6 100644 --- 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 @@ -6,16 +6,39 @@ from collections.abc import AsyncIterable, Callable, Iterable, Sequence from typing import Any, cast -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, -) +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 import context as context_api @@ -73,6 +96,25 @@ def _apply_interaction_response_attributes( ) +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) @@ -231,15 +273,16 @@ def instrumented_interactions_create( ) -> 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 getattr(instance._client, "_is_vertex", False) + if is_vertex else GenAIAttributes.GenAiSystemValues.GEMINI.value ), request_model=kwargs.get("model") or kwargs.get("agent"), operation_name="interactions.create", - server_address=getattr(instance._client, "server", None), + server_address=server_address, ) attrs = context_api.get_value( @@ -292,15 +335,16 @@ async def instrumented_interactions_create( 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 getattr(instance._client, "_is_vertex", False) + if is_vertex else GenAIAttributes.GenAiSystemValues.GEMINI.value ), request_model=kwargs.get("model") or kwargs.get("agent"), operation_name="interactions.create", - server_address=getattr(instance._client, "server", None), + server_address=server_address, ) attrs = context_api.get_value( @@ -347,14 +391,27 @@ 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( - "google.genai._interactions.resources.interactions", - "InteractionsResource.create", + module_path, + f"{sync_class}.create", _create_instrumented_interactions_create(telemetry_handler), ) wrap_function_wrapper( - "google.genai._interactions.resources.interactions", - "AsyncInteractionsResource.create", + 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/base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py index 5d262aa9..d19a2629 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -8,10 +8,19 @@ from typing import Any from unittest.mock import patch -from google.genai._interactions.resources.interactions import ( - AsyncInteractionsResource, - InteractionsResource, -) +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 import context as context_api from opentelemetry.instrumentation.google_genai import ( From d36eb6f39834bb5381abdeb6e9ccc6eb634f08bc Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 24 Jun 2026 18:53:26 +0000 Subject: [PATCH 08/10] Add VCR tests --- .../test_async_interactions_create.yaml | 96 ++++++++ .../test_sync_interactions_create.yaml | 96 ++++++++ .../tests/interactions/test_e2e.py | 212 ++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_async_interactions_create.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/cassettes/test_sync_interactions_create.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py 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": "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", + "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": "CugZAQw51sf+uxlXUT7nmIMjVO2TVXnJcAHr9qOtzLSZ33k70oYLikpW7v7KCLNV149Oj96qbCcX3X4yG0rQhXuhN4uhLBGaIUJ+HO0qlhAijZf2MhwfR4zg+j77xGKxHR/jLPt61LtUEZ2o7ZcjGL62dElyQGhmB6zPyZYke0QOwDpK9eiC0P0voc/r0b2m0mRdfRR+k2iNBxzBR1dI8JJXvhxgDc9oV4TLwsMzkQi0JarBEoKYm/fR8XJG/bgR8qkLgL6mdiH3KsWhCklaI/mCtolPxG3x6dQ+YQd9AFshEKQq8/0O3vqFx/nSlFypCkiUgJb1yPdPLodZruLtdyTlAlcHQgVYGG9oILLNU0VIS2z6YJnoWR7HJ0Xc+MFRwOhSdVov4198PRfxZCs0Ypj0Dx+HH1HHlmKstoFCBympTE3eKoQUfOCHuHvYOsCeuBg/3FrRBgM5DKaKUOEdpX5dxJBHBUam8gi0Len2L3ueaQcNvAnCwexZHlyYpcZHwUxIp+qwsAne8aTVjqFt+I4OMSQOhY/KnZ83lY2PT0bXef+j1jNAk017J9RJohWXQ/JKZHVqJ+U+aq2R7WZ9QKOPscVj8Ow4I5vquRh62IjGdU6kF4U/LbRYwRMVttBNe/vpjASWw63/H+mQy7fez03EflHO5XYn/N7EPu0195JjRn+02vz97O8BSOvIMTjRJ5+0dUYZkqMWdgsPCuMdDrzFx5Sfoq5Nvp6qItX0Nd7cr8LaFGDR2tGC2DqulCn9N8C4UVTJS8SZClWpb3JjOL1w1LFQhx/Wkp6P5AePVKRJtKVr2TVfhYqEfwINfhp1TtF4x1gFEjKqaLoBxM2tjrXlIe2fOze2CYo+FRvHZehnETku2HhrIhoFkU++Tmi/fqUY3cJanrEl1MT6nDBhoTURe1Mf266Yb8vi/wMKw2rwTak/nt0LHK7Ok3l5dMyVqRtcRZxTU4Q7jE6rkRfIz5fTl9qfQBunCATuU/Reu/qqpBTSN35k5iFNe9KqXd+DpurlHUzdy+rgPxklpl9Ej1gDrKbVjfYrewPY4PkCR1xmTzXNiYbhrr+ExzfeW41x8AMnqlW4kTH1TYyd6pBWA3ZHJvN0YpVImt9KobxYpDogCw18XQzbwgNc1rJze8fG3IOY5Ob6D2GVtaBLRPtqd+Nsug+L+lhJRc8Yv5disXGy0POEvvLv2lwapJ6cg79h34OWg13SMY1icMf3gTFo5i8RNgc9ewTTaS6rPc6wirnLqzlyaY6D4ljUfWKhHAZVdCzMAxPh8Vskzr3ZFqRJKPhN/XJpn0cc9Fv/dURYtr86GggjaNWRuk3ea9HUEI/7UIxswyYXBTu2qjyAhB2epn4UhJc1BOgX96lRU9F+8T+PtzSIbRAOpsF8ncqaTAG6T1D4drXjHCMSYI/yNwZagsi37XTqQuo7Yt4MZTGb9FgpYNQd6iNzj98T2SHsVNiC1qYri2dKhc1emuB87I+c4hlEE8L0YoMqY66DPd4e+CTNTb2a/FJ9l6i/dJZ9lsPR6plsFBudDU/FEvsITuN5Jp9CTqazW+22KuBxu7JqvZhzv6+rjNM+4UXXmseQgEFBCG5wT2X/72qwjBGrZdS5aR5OSJ4ov42op7S4J0OhhRp4DUl3tIBjjfOMt6G1umA2qiUYHJKkAQs5M2rz5FEDgevhNst5nECu8GEcLm9KSv9BL7A9kdBqbsRtWWfnk872w2pe5aLLXxW1TZCoFQP+taRPpodVOfg7JTlKmZIxH0/rNKW3Tu/4Kd1m2OEkQEJNpsaSZ63flbB02ZSoJh9lPmTzJhI+5//phEIVhMSulVH/b9Z0zRTCtDE7du04RAderziKj9YHALIrxk6m178nDLYDzDOvOzrz/by7/C9YdCDCbIP3wweYGfVCW/cgcj+QLF0ZbdM9LWaNmnk9fcUTCuvw0hn0FmgbQi93KQfpUZkQQ1rnmnJZa+5X9mSPGXU2/r78KBFIOE7ee3w7OM3Fk77qD80fCiVQf9YcYGidd/yrBoXj8+8TuMZ5rEispk/i9rew4w43MYxqOmu9fm8sZGlYOBPsUg6ZbdxVg2oiu4FfwF0qC+2oz4VZIvipmu/S0UyApcyCTfETYcpoZgqvw1rVrliEALZ3QYBNisaPQ5OytgsvA+3Nm/U9tnUwn1kpJhj7ZOyXjeQhm7R8C4Q6yqpadP3Zuz/HOqGebwOSFXdoNcuZLnUZ8b8ooFHHdqi0c3iC0yo0qR0BdZGTmAnLKhgdsOXERVzVz2cQ/JS678fL+MCxSVCqcP1LctVU9g7Lsck+oIg503mfQMSxiX4867PdpvxX4Pxt5tTDSmXsZ2fZnxxgfbFnVRIjkH/5XdKoAFhE6Koe6aWJSs1GUaF77WOr1XP4A54z2r4Wd+W+NY4UmtrqTIF7lgy/oPjEtMwLy5YMv96ayDxMmpRbD0SA3RhbkjAd3G2llSbmoM1+Xv0WQNRSORfyuMyhvymylGEf1Oe54ioVQrB1P4vfPip4eelijD8BhY5S81CM3V+n9vyNTD5F54lAtNElbG49OJyEde81PSdA6aEt1mcIGw38SCsv7wvI4kZttaVY+EtsQNN2NOzS1Q5trh7XdR3Owkn9Qi2ToB72LCnYM52ODji/ZrvcI/0dmAt24Gc9mqoouf0OeHIe0RMZtgXga6iASIUuN9ppiGSnujI176adaXCV6UMGVy8Uda23tbT89I0ApPEe2O/ADvXX632tVyx/+Pnhh7xWnK9xVhF97KaNPEKR1d74PpSu5cLckDqdCzsDBDHslUgm4KAIbknSB2E3L+lIUaEScV9otCOdlGJG6wZyl/vOPT/EKYmFfaO8QMphJWuoZaxmhVEULC6OSuy7fiBSe1hJE/Q9uoNL5vygxIQ8tJdOHr92Xx0fEAwOrY5JYETR5EDA7uQlueK666WkssRg4NgjkXsLJKd4n73HrsV9nFrw1dw0dH6fEaC7U/sc/4w5A1njcMtFM241qSOqKkIx3ZYBjl5P0VPA1lxNSf9yDBiXcQDAjr/pl43B+f6S5Tk5tyrLBi+a55QwNERPfggwOixea2/lPASOgGwEknbeXZERe+2kWxCyl65UG6RsB9LmBaRtLeWDIOCf7ZGQ3r1lUNys1QJo4SYvggijlX8XJjVi+eZjpzcI7XlPOkA3DlxLdpAyRKTXIajrxdzEliHabZEExdJ9YvAYLdZY2nV6W/xA+Sr81cT6Y671A3HRR1a8kzNVH2WFTWpkygnfPUIjgpEnMiTkCH5S9WwpEwZvaYJEt4v8McFhOfRXVHwnkH7X501+ejKlwMJGt+y8+4ObrBEmg6U1BLdX3aW+oyafKKTfvR522uZaCekhqCjfsJyA2ly1UhkUpoJ400d77+qK+2wVxe2ALgbJhBtf54jxAkTzEA8fJep70gwEaLLMP5DZmaWb2A5hY4lXFRl04DDLtyE2kdQJWhzCtKMqLK0x++YrHbIYSiJVdfC48yvAVCJ0dCO4sVihR7EthY2EBRv6AcGjM6LUhxcdMg++FcAnhfotqWIMWnFXOqNHr4NwmiC1hiVQUrqsEjnaSRdcVAbsB9CCYMfnKo/D6aFQwypvVPfpJLAtfPNkFX2MfXIm+SaHBSX11vU/zvTUnHoYIR9o392T+0HYbXHINiTI0awyA0r2nb4vlf+DXBpwSYTYYiAG/PGbWW3jpgL02UCusfdLetwarfgwzNt+mP8kogcWKBvrPFXZ0ptNpdk5VgrNL3LBVALs6q4pArULQfM5t06Q93oCMQrEo4MZf9cEGl91AjSyugVO7aaLiSB21Yv/IVG3reCaDcX1Lb+LaCPgM6WCN3zakDvwjTPh2XPaaPVnGv2XGm64DLUF8ZrJvkZ7bYhdCxFdPn92y6ww3C1wHg+kw1u/UBMqfoeDUrl8mKTYSi3x7vqUAbzRgEURY8k2hAqILSJ2HaAj8fHUZH0GM5SPe1MB1jMqVWctCUgxn/XpoKmxyCxcob2ZjBtU2PxI/VFg2+PKDwP7V/xVODBs7T80W9LorMX9od13eAJsIeb6ACNt3E4Q4jRNs11ZHKmaufJ4XleXEg9bpmnmcLxIamV14QafSFKk7xgIfEqCYNpu9W5RyHNbKnw5s0JZgEESCqrG8uC0a4c6/WF1JzJTJY+YRdtBohM7C2cI41A1awtsVefgiGfvdfupOvPBhUEvyg72DQBVH2jLXqQ+9EitOyknJpe/tdiietB5BqRv+4DmiQITWVduG1RSqD+o3dM1Qzg3Ue53WgijKRkvP5IA6PQiYtU4gNzvgymMuN8lrVgcnLEKIau2AKtPdvUL2rrblfwAz29DHyhRh1GmoMtikkthGOyrBxKXtVuua/xCGouQAzMhFloE8xwthVnuCl7104+7gVzp0K5WgLVISA==", + "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..79ed6711 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py @@ -0,0 +1,212 @@ +import json +import os +import yaml + +import pytest +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" From bd0bb1c485882ab8855f1b228d99d38f66cc342b Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Wed, 24 Jun 2026 20:41:04 +0000 Subject: [PATCH 09/10] Fix failing workflow checks --- .../google_genai/interactions.py | 21 ++++++++++++++++++- .../tests/interactions/test_e2e.py | 5 ++++- 2 files changed, 24 insertions(+), 2 deletions(-) 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 index 02ae97d6..76c69c18 100644 --- 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 @@ -173,6 +173,24 @@ def _interactions_input_to_messages( 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: @@ -181,10 +199,11 @@ def _interactions_input_to_messages( def _interactions_response_to_messages( interaction: Interaction, ) -> list[OutputMessage]: + output_text = _get_interaction_output_text(interaction) return [ OutputMessage( role="assistant", - parts=[Text(content=interaction.output_text)], + parts=[Text(content=output_text)], finish_reason="stop", ) ] diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py index 79ed6711..46a163b6 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_e2e.py @@ -1,8 +1,11 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + import json import os -import yaml import pytest +import yaml from google.genai import Client from google.genai.types import HttpOptions From c3b899b1b0155bc9891f27e0fce73d2b86d1207e Mon Sep 17 00:00:00 2001 From: Dylan Russell Date: Fri, 26 Jun 2026 17:28:50 +0000 Subject: [PATCH 10/10] Get rid of using generate content context var --- .../google_genai/interactions.py | 15 ----------- .../tests/interactions/base.py | 26 +------------------ 2 files changed, 1 insertion(+), 40 deletions(-) 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 index 76c69c18..58ad448f 100644 --- 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 @@ -41,7 +41,6 @@ ) from wrapt import wrap_function_wrapper -from opentelemetry import context as context_api from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) @@ -63,8 +62,6 @@ Uri, ) -from .generate_content import GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY - class _InteractionsMethodsSnapshot: def __init__(self) -> None: @@ -304,12 +301,6 @@ def instrumented_interactions_create( server_address=server_address, ) - attrs = context_api.get_value( - GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY - ) - if attrs: - invocation.attributes.update(dict(attrs)) - if telemetry_handler.should_capture_content(): invocation.input_messages = _interactions_input_to_messages( kwargs.get("input") @@ -366,12 +357,6 @@ async def instrumented_interactions_create( server_address=server_address, ) - attrs = context_api.get_value( - GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY - ) - if attrs: - invocation.attributes.update(dict(attrs)) - if telemetry_handler.should_capture_content(): invocation.input_messages = _interactions_input_to_messages( kwargs.get("input") diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py index d19a2629..322db65b 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -22,10 +22,7 @@ Interactions as InteractionsResource, ) -from opentelemetry import context as context_api -from opentelemetry.instrumentation.google_genai import ( - GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY, -) + from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes, ) @@ -154,27 +151,6 @@ def test_generated_span_has_minimal_genai_attributes(self) -> None: span.attributes["gen_ai.operation.name"], "interactions.create" ) - def test_generated_span_has_extra_genai_attributes(self) -> None: - self.configure_valid_interaction() - tok = context_api.attach( - context_api.set_value( - GENERATE_CONTENT_EXTRA_ATTRIBUTES_CONTEXT_KEY, - {"extra_attribute_key": "extra_attribute_value"}, - ) - ) - try: - 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["extra_attribute_key"], "extra_attribute_value" - ) - finally: - context_api.detach(tok) - def test_span_and_event_still_written_when_response_is_exception( self, ) -> None: