From af308b93e4575b3244b0989160a6da273d3f9693 Mon Sep 17 00:00:00 2001 From: Sangkyoon Nam Date: Tue, 20 Oct 2026 17:08:35 +0900 Subject: [PATCH 1/2] fix(google-genai): report thought parts as reasoning Assisted-by: Claude Opus 5.5 --- .../instrumentation/google_genai/message.py | 3 +++ .../generate_content/nonstreaming_base.py | 19 +++++++++++++++++++ .../tests/generate_content/streaming_base.py | 16 ++++++++++++++++ .../tests/generate_content/test_message.py | 18 ++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py index febfef29b..0a4476632 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py @@ -14,6 +14,7 @@ InputMessage, MessagePart, OutputMessage, + ReasoningPart, Role, ServerToolCallPart, ServerToolCallResponsePart, @@ -91,6 +92,8 @@ def tool_call_id(name: str | None) -> str: return f"{idx}" if (text := part.text) is not None: + if part.thought: + return ReasoningPart(content=text) return TextPart(content=text) if inline_data := part.inline_data: diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 0f5dfd69b..0e775404d 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -13,6 +13,7 @@ GoogleMaps, MediaModality, ModalityTokenCount, + Part, ToolDict, ) from pydantic import BaseModel, Field @@ -751,6 +752,24 @@ def test_span_attributes_with_content_capture(self): '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"}]', ) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_reports_thought_parts_as_reasoning(self): + self.configure_valid_response( + parts=[ + Part(text="Let me think.", thought=True), + Part(text="It is sunny."), + ] + ) + self.generate_content(model="gemini-2.0-flash", contents="Hi") + span = self.otel.get_span_named("generate_content gemini-2.0-flash") + self.assertEqual( + span.attributes[gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES], + '[{"role":"assistant","parts":[{"content":"Let me think.","type":"reasoning"},{"content":"It is sunny.","type":"text"}],"finish_reason":"","name":null}]', + ) + def test_log_has_extra_genai_attributes(self): self.configure_valid_response(text="Yep, it works!") tok = context_api.attach( diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py index e83aefed1..48f55f0ea 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/streaming_base.py @@ -9,6 +9,7 @@ GenerateContentResponseUsageMetadata, MediaModality, ModalityTokenCount, + Part, ) from opentelemetry import context as context_api @@ -163,6 +164,21 @@ def test_merges_streamed_chunks_into_one_output_message(self): '[{"role":"assistant","parts":[{"content":"Hello, world","type":"text"}],"finish_reason":"","name":null}]', ) + @patch.dict( + "os.environ", + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_reports_streamed_thought_parts_as_reasoning(self): + self.configure_valid_response(part=Part(text="Let me ", thought=True)) + self.configure_valid_response(part=Part(text="think.", thought=True)) + self.configure_valid_response(text="It is sunny.") + self.generate_content(model="gemini-2.0-flash", contents="Hi") + span = self.otel.get_span_named("generate_content gemini-2.0-flash") + self.assertEqual( + span.attributes[gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES], + '[{"role":"assistant","parts":[{"content":"Let me think.","type":"reasoning"},{"content":"It is sunny.","type":"text"}],"finish_reason":"","name":null}]', + ) + def test_includes_token_counts_in_span_not_aggregated_from_responses(self): # Tokens should not be aggregated in streaming. Cumulative counts are returned on each response. self.configure_valid_response( diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py index 8aed71709..3afd007d7 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py @@ -11,6 +11,7 @@ ) from opentelemetry.util.genai.types import ( GenericPart, + ReasoningPart, ServerToolCallPart, ServerToolCallResponsePart, TextPart, @@ -48,11 +49,28 @@ def test_to_system_instructions_non_text_parts(self): ], ) + def test_to_system_instructions_thought_part(self): + content = genai_types.Content( + parts=[genai_types.Part(text="Let me think.", thought=True)] + ) + instructions = to_system_instructions(content=content) + self.assertEqual(instructions, [GenericPart(type="reasoning")]) + def test_to_system_instructions_empty_or_unknown_part(self): content = genai_types.Content(parts=[genai_types.Part()]) instructions = to_system_instructions(content=content) self.assertEqual(instructions, []) + def test_to_part_maps_thought_text_to_reasoning(self): + self.assertEqual( + _to_part(genai_types.Part(text="Let me think.", thought=True), 0), + ReasoningPart(content="Let me think."), + ) + self.assertEqual( + _to_part(genai_types.Part(text="Sunny.", thought=False), 0), + TextPart(content="Sunny."), + ) + def test_to_part_maps_code_execution(self): call_id = ( {"id": "code-1"} From a9a453218facbc9559152d883aa5dbbf63565af0 Mon Sep 17 00:00:00 2001 From: Sangkyoon Nam Date: Tue, 20 Oct 2026 17:34:27 +0900 Subject: [PATCH 2/2] Add changelog entry for #930 --- .../.changelog/930.fixed | 1 + 1 file changed, 1 insertion(+) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed new file mode 100644 index 000000000..df6e9e925 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/930.fixed @@ -0,0 +1 @@ +Report text parts with `thought=True` as `ReasoningPart` instead of `TextPart` in `generate_content` and `generate_content_stream` messages.