diff --git a/packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py b/packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py index a6500cddac..b90901ccd2 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py @@ -405,6 +405,89 @@ def _process_argument_sync(argument, span): return [{"type": "text", "content": str(argument)}] +def _is_content_like(item): + """True for a Content (or Content dict) turn, as opposed to a bare part.""" + if isinstance(item, dict): + return "parts" in item or "role" in item + return not isinstance(item, str) and hasattr(item, "parts") + + +def _is_model_part(item): + if isinstance(item, dict): + return "functionCall" in item or "function_call" in item + return bool(getattr(item, "function_call", None)) + + +def _group_contents(contents): + """Split ``contents`` into turns the way google.genai's ``t_contents`` does. + + A single (non-list) value is one turn. In a list, every Content is its own + turn, while consecutive bare parts (strings, Parts, images) are grouped into + one turn: ``user``, or ``model`` for a run of function-call parts. + Returns ``(role, content_or_None, parts)`` tuples. + """ + if not isinstance(contents, list): + contents = [contents] + turns = [] + pending = [] + pending_is_model = False + for item in contents: + if _is_content_like(item): + if pending: + turns.append(("model" if pending_is_model else "user", None, pending)) + pending = [] + role = item.get("role") if isinstance(item, dict) else getattr(item, "role", None) + turns.append((role or "user", item, None)) + continue + is_model = _is_model_part(item) + if pending and is_model != pending_is_model: + turns.append(("model" if pending_is_model else "user", None, pending)) + pending = [] + pending_is_model = is_model + pending.append(item) + if pending: + turns.append(("model" if pending_is_model else "user", None, pending)) + return turns + + +async def _input_messages_from_contents(contents, span): + messages = [] + for role, content, bare_parts in _group_contents(contents): + if content is not None: + parts = await _process_content_item(content, span) + else: + parts = [] + for idx, item in enumerate(bare_parts): + if isinstance(item, str): + parts.append({"type": "text", "content": item}) + elif isinstance(item, dict): + parts.append(_map_dict_part(item)) + else: + parts.extend(await _parts_from_genai_part_async(item, span, idx)) + if parts: + messages.append({"role": _normalize_message_role(role), "parts": parts}) + return messages + + +def _input_messages_from_contents_sync(contents, span): + messages = [] + for role, content, bare_parts in _group_contents(contents): + if content is not None: + parts = _process_content_item_sync(content, span) + else: + parts = [] + for idx, item in enumerate(bare_parts): + if isinstance(item, str): + parts.append({"type": "text", "content": item}) + elif isinstance(item, dict): + parts.append(_map_dict_part(item)) + else: + parts.extend(_parts_from_genai_part_sync(item, span, idx)) + if parts: + messages.append({"role": _normalize_message_role(role), "parts": parts}) + return messages + + def _collect_finish_reasons_from_response(response): reasons = [] candidates = getattr(response, "candidates", None) or [] @@ -455,19 +538,8 @@ async def set_input_attributes(span, args, kwargs, llm_model): "parts": [{"type": "text", "content": contents}], } ) - elif isinstance(contents, list): - for content_item in contents: - parts = await _process_content_item(content_item, span) - if parts: - messages.append( - { - "role": _normalize_message_role( - content_item.get("role", "user") if isinstance(content_item, dict) - else getattr(content_item, "role", "user") - ), - "parts": parts, - } - ) + elif contents is not None: + messages.extend(await _input_messages_from_contents(contents, span)) elif args and len(args) > 0: for argument in args: parts = await _process_argument(argument, span) @@ -506,19 +578,8 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): "parts": [{"type": "text", "content": contents}], } ) - elif isinstance(contents, list): - for content in contents: - parts = _process_content_item_sync(content, span) - if parts: - messages.append( - { - "role": _normalize_message_role( - content.get("role", "user") if isinstance(content, dict) - else getattr(content, "role", "user") - ), - "parts": parts, - } - ) + elif contents is not None: + messages.extend(_input_messages_from_contents_sync(contents, span)) elif args and len(args) > 0: for arg in args: parts = _process_argument_sync(arg, span) diff --git a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_async_input.py b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_async_input.py index 3833be9581..74a0076069 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_async_input.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_async_input.py @@ -91,11 +91,73 @@ async def test_list_of_strings(self, monkeypatch): span, (), {"contents": ["first", "second"]}, "gemini-pro" ) + # A bare list of strings is one user turn with several parts, which is + # how google.genai's t_contents sends it. messages = _recorded_input_messages(span) - assert messages is not None - assert len(messages) == 2 - assert messages[0]["parts"][0]["content"] == "first" - assert messages[1]["parts"][0]["content"] == "second" + assert messages == [ + { + "role": "user", + "parts": [ + {"type": "text", "content": "first"}, + {"type": "text", "content": "second"}, + ], + } + ] + + @pytest.mark.asyncio + async def test_single_content_object(self, monkeypatch): + """A Content passed directly (not in a list) is one turn.""" + from google.genai import types + + monkeypatch.setattr(su, "should_send_prompts", lambda: True) + span = self._make_span() + + content = types.Content(role="user", parts=[types.Part(text="Hi there")]) + await su.set_input_attributes(span, (), {"contents": content}, "gemini-pro") + + messages = _recorded_input_messages(span) + assert messages == [ + {"role": "user", "parts": [{"type": "text", "content": "Hi there"}]} + ] + + @pytest.mark.asyncio + async def test_bare_parts_grouped_between_contents(self, monkeypatch): + """Bare parts are grouped into turns; function calls form a model turn.""" + from google.genai import types + + monkeypatch.setattr(su, "should_send_prompts", lambda: True) + span = self._make_span() + + contents = [ + "What is the weather?", + types.Part(text="In Paris."), + types.Part( + function_call=types.FunctionCall( + id="c1", name="get_weather", args={"city": "Paris"} + ) + ), + types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + id="c1", name="get_weather", response={"temp": 20} + ) + ) + ], + ), + ] + await su.set_input_attributes(span, (), {"contents": contents}, "gemini-pro") + + messages = _recorded_input_messages(span) + assert [m["role"] for m in messages] == ["user", "assistant", "user"] + assert [p["content"] for p in messages[0]["parts"]] == [ + "What is the weather?", + "In Paris.", + ] + assert messages[1]["parts"][0]["type"] == "tool_call" + assert messages[1]["parts"][0]["name"] == "get_weather" + assert messages[2]["parts"][0]["type"] == "tool_call_response" @pytest.mark.asyncio async def test_prompt_kwarg(self, monkeypatch): @@ -207,6 +269,27 @@ async def test_list_contents_match(self, monkeypatch): async_messages = _recorded_input_messages(span_async) assert sync_messages == async_messages + @pytest.mark.asyncio + async def test_single_content_object_match(self, monkeypatch): + from google.genai import types + + monkeypatch.setattr(su, "should_send_prompts", lambda: True) + contents = types.Content(role="user", parts=[types.Part(text="Hi")]) + + span_sync = MagicMock() + span_sync.is_recording.return_value = True + su.set_input_attributes_sync(span_sync, (), {"contents": contents}, "gemini-pro") + + span_async = MagicMock() + span_async.is_recording.return_value = True + await su.set_input_attributes( + span_async, (), {"contents": contents}, "gemini-pro" + ) + + sync_messages = _recorded_input_messages(span_sync) + assert sync_messages is not None + assert sync_messages == _recorded_input_messages(span_async) + @pytest.mark.asyncio async def test_prompt_kwarg_match(self, monkeypatch): monkeypatch.setattr(su, "should_send_prompts", lambda: True) diff --git a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py index c97a35ba33..99ed8176ce 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -72,6 +72,12 @@ def test_client_spans(exporter, genai_client): assert output_messages[0]["parts"][0]["type"] == "text" assert "content" in output_messages[0]["parts"][0] + # The deprecated indexed attributes must not be emitted alongside them + assert not any(key.startswith("gen_ai.prompt.") for key in attrs) + assert not any(key.startswith("gen_ai.completion.") for key in attrs) + + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_ID] == "-hk4afOSMZKkjuMPnJWGkAk" + assert GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS in attrs finish_reasons = attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] assert isinstance(finish_reasons, (list, tuple))