From 99e3281bf995251fb70f14107d9d7671dcabab79 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:16:20 -0400 Subject: [PATCH 01/16] fix(google-genai): migrate span attrs to gen_ai.input/output.messages --- .../google_generativeai/span_utils.py | 298 +++++++++--------- 1 file changed, 144 insertions(+), 154 deletions(-) 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 0c92bc6840..8ce98341e2 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 @@ -16,7 +16,6 @@ ) from opentelemetry.trace.status import Status, StatusCode - logger = logging.getLogger(__name__) @@ -48,19 +47,15 @@ async def _process_image_part(item, trace_id, span_id, content_index): """Process a Google GenAI Part object containing image data""" if not Config.upload_base64_image: return None - try: # Extract format from mime type (e.g., 'image/jpeg' -> 'jpeg') image_format = item.inline_data.mime_type.split('/')[1] if item.inline_data.mime_type else 'unknown' image_name = f"content_{content_index}.{image_format}" - # Convert binary data to base64 string for upload binary_data = item.inline_data.data base64_string = base64.b64encode(binary_data).decode('utf-8') - # Upload the base64 data - convert IDs to strings url = await Config.upload_base64_image(str(trace_id), str(span_id), image_name, base64_string) - # Return OpenAI-compatible format for consistency across LLM providers return { "type": "image_url", @@ -91,16 +86,13 @@ def _process_image_part_sync(item, trace_id, span_id, content_index): """Synchronous version of image part processing using OpenAI's pattern""" if not Config.upload_base64_image: return None - try: # Extract format from mime type (e.g., 'image/jpeg' -> 'jpeg') image_format = item.inline_data.mime_type.split('/')[1] if item.inline_data.mime_type else 'unknown' image_name = f"content_{content_index}.{image_format}" - # Convert binary data to base64 string for upload binary_data = item.inline_data.data base64_string = base64.b64encode(binary_data).decode('utf-8') - # Use OpenAI's run_async pattern to handle the async upload function url = None @@ -109,7 +101,6 @@ async def upload_task(): url = await Config.upload_base64_image(str(trace_id), str(span_id), image_name, base64_string) run_async(upload_task()) - return { "type": "image_url", "image_url": {"url": url} @@ -123,7 +114,6 @@ async def upload_task(): async def _process_content_item(content_item, span): """Process a single content item, handling different types (Content objects, strings, Parts)""" processed_content = [] - if hasattr(content_item, "parts"): # Content with parts (Google GenAI Content object) for part_index, part in enumerate(content_item.parts): @@ -132,75 +122,70 @@ async def _process_content_item(content_item, span): processed_content.append(processed_part) elif isinstance(content_item, str): # Direct string in the list - processed_content.append({"type": "text", "text": content_item}) + processed_content.append({"type": "text", "content": content_item}) elif _is_image_part(content_item): # Direct Part object that's an image processed_image = await _process_image_part( - content_item, span.context.trace_id, span.context.span_id, 0 + content_item, + span.context.trace_id, + span.context.span_id, + 0 ) if processed_image is not None: processed_content.append(processed_image) else: # Other content types - processed_content.append({"type": "text", "text": str(content_item)}) - + processed_content.append({"type": "text", "content": str(content_item)}) return processed_content async def _process_content_part(part, span, part_index): """Process a single part within a Content object""" if hasattr(part, "text") and part.text: - return {"type": "text", "text": part.text} + return {"type": "text", "content": part.text} elif _is_image_part(part): return await _process_image_part( - part, span.context.trace_id, span.context.span_id, part_index + part, + span.context.trace_id, + span.context.span_id, + part_index ) else: # Other part types - return {"type": "text", "text": str(part)} - - -def _set_prompt_attributes(span, prompt_index, processed_content, content_item): - """Set span attributes for a processed prompt""" - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.content", - json.dumps(processed_content), - ) - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.role", - getattr(content_item, "role", "user"), - ) + return {"type": "text", "content": str(part)} async def _process_argument(argument, span): """Process a single argument from args list""" processed_content = [] - if isinstance(argument, str): - processed_content.append({"type": "text", "text": argument}) + processed_content.append({"type": "text", "content": argument}) elif isinstance(argument, list): for sub_index, sub_item in enumerate(argument): if isinstance(sub_item, str): - processed_content.append({"type": "text", "text": sub_item}) + processed_content.append({"type": "text", "content": sub_item}) elif _is_image_part(sub_item): processed_image = await _process_image_part( - sub_item, span.context.trace_id, span.context.span_id, sub_index + sub_item, + span.context.trace_id, + span.context.span_id, + sub_index ) if processed_image is not None: processed_content.append(processed_image) else: - processed_content.append({"type": "text", "text": str(sub_item)}) + processed_content.append({"type": "text", "content": str(sub_item)}) elif _is_image_part(argument): processed_image = await _process_image_part( - argument, span.context.trace_id, span.context.span_id, 0 + argument, + span.context.trace_id, + span.context.span_id, + 0 ) if processed_image is not None: processed_content.append(processed_image) else: - processed_content.append({"type": "text", "text": str(argument)}) - + processed_content.append({"type": "text", "content": str(argument)}) return processed_content @@ -209,53 +194,49 @@ async def set_input_attributes(span, args, kwargs, llm_model): """Process input arguments, handling both text and image content""" if not span.is_recording(): return - if not should_send_prompts(): return + input_messages = [] + if "contents" in kwargs: contents = kwargs["contents"] if isinstance(contents, str): - # Simple string content in OpenAI format - _set_span_attribute( - span, - f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", - contents, - ) - _set_span_attribute( - span, - f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", - "user", - ) + # Simple string content + input_messages.append({ + "role": "user", + "parts": [{"type": "text", "content": contents}], + }) elif isinstance(contents, list): - for prompt_index, content_item in enumerate(contents): + for content_item in contents: processed_content = await _process_content_item(content_item, span) - if processed_content: - _set_prompt_attributes(span, prompt_index, processed_content, content_item) - + role = getattr(content_item, "role", "user") + input_messages.append({ + "role": role, + "parts": processed_content, + }) elif args and len(args) > 0: # Handle args - process each argument - for arg_index, argument in enumerate(args): + for argument in args: processed_content = await _process_argument(argument, span) - if processed_content: - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{arg_index}.content", - json.dumps(processed_content), - ) - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{arg_index}.role", - "user", - ) + input_messages.append({ + "role": "user", + "parts": processed_content, + }) elif "prompt" in kwargs: + input_messages.append({ + "role": "user", + "parts": [{"type": "text", "content": kwargs["prompt"]}], + }) + + if input_messages: _set_span_attribute( - span, f"{SpanAttributes.LLM_PROMPTS}.0.content", - json.dumps([{"type": "text", "text": kwargs["prompt"]}]) + span, + GenAIAttributes.GEN_AI_INPUT_MESSAGES, + json.dumps(input_messages), ) - _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") # Keep sync version for backward compatibility @@ -264,113 +245,112 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): """Synchronous version with image processing support""" if not span.is_recording(): return - if not should_send_prompts(): return + input_messages = [] + if "contents" in kwargs: contents = kwargs["contents"] if isinstance(contents, str): - # Simple string content in OpenAI format - _set_span_attribute( - span, - f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", - json.dumps([{"type": "text", "text": contents}]), - ) - _set_span_attribute( - span, - f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", - "user", - ) + # Simple string content + input_messages.append({ + "role": "user", + "parts": [{"type": "text", "content": contents}], + }) elif isinstance(contents, list): # Process content list - could be mixed text and Part objects - for i, content in enumerate(contents): + for content in contents: processed_content = [] - if hasattr(content, "parts"): # Content with parts (Google GenAI Content object) for j, part in enumerate(content.parts): if hasattr(part, "text") and part.text: - processed_content.append({"type": "text", "text": part.text}) + processed_content.append({"type": "text", "content": part.text}) elif _is_image_part(part): processed_image = _process_image_part_sync( - part, span.context.trace_id, span.context.span_id, j + part, + span.context.trace_id, + span.context.span_id, + j ) if processed_image is not None: processed_content.append(processed_image) else: # Other part types - processed_content.append({"type": "text", "text": str(part)}) + processed_content.append({"type": "text", "content": str(part)}) elif isinstance(content, str): # Direct string in the list - processed_content.append({"type": "text", "text": content}) + processed_content.append({"type": "text", "content": content}) elif _is_image_part(content): # Direct Part object that's an image processed_image = _process_image_part_sync( - content, span.context.trace_id, span.context.span_id, 0 + content, + span.context.trace_id, + span.context.span_id, + 0 ) if processed_image is not None: processed_content.append(processed_image) else: # Other content types - processed_content.append({"type": "text", "text": str(content)}) + processed_content.append({"type": "text", "content": str(content)}) if processed_content: - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{i}.content", - json.dumps(processed_content), - ) - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{i}.role", - getattr(content, "role", "user"), - ) + input_messages.append({ + "role": getattr(content, "role", "user"), + "parts": processed_content, + }) elif args and len(args) > 0: # Handle args - process each argument - for i, arg in enumerate(args): + for arg in args: processed_content = [] - if isinstance(arg, str): - processed_content.append({"type": "text", "text": arg}) + processed_content.append({"type": "text", "content": arg}) elif isinstance(arg, list): for j, subarg in enumerate(arg): if isinstance(subarg, str): - processed_content.append({"type": "text", "text": subarg}) + processed_content.append({"type": "text", "content": subarg}) elif _is_image_part(subarg): processed_image = _process_image_part_sync( - subarg, span.context.trace_id, span.context.span_id, j + subarg, + span.context.trace_id, + span.context.span_id, + j ) if processed_image is not None: processed_content.append(processed_image) else: - processed_content.append({"type": "text", "text": str(subarg)}) + processed_content.append({"type": "text", "content": str(subarg)}) elif _is_image_part(arg): processed_image = _process_image_part_sync( - arg, span.context.trace_id, span.context.span_id, 0 + arg, + span.context.trace_id, + span.context.span_id, + 0 ) if processed_image is not None: processed_content.append(processed_image) else: - processed_content.append({"type": "text", "text": str(arg)}) + processed_content.append({"type": "text", "content": str(arg)}) if processed_content: - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{i}.content", - json.dumps(processed_content), - ) - _set_span_attribute( - span, - f"{SpanAttributes.LLM_PROMPTS}.{i}.role", - "user", - ) + input_messages.append({ + "role": "user", + "parts": processed_content, + }) elif "prompt" in kwargs: + input_messages.append({ + "role": "user", + "parts": [{"type": "text", "content": kwargs["prompt"]}], + }) + + if input_messages: _set_span_attribute( - span, f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", - json.dumps([{"type": "text", "text": kwargs["prompt"]}]) + span, + GenAIAttributes.GEN_AI_INPUT_MESSAGES, + json.dumps(input_messages), ) - _set_span_attribute(span, f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", "user") def set_model_request_attributes(span, kwargs, llm_model): @@ -386,12 +366,15 @@ def set_model_request_attributes(span, kwargs, llm_model): _set_span_attribute(span, GenAIAttributes.GEN_AI_REQUEST_TOP_P, kwargs.get("top_p")) _set_span_attribute(span, GenAIAttributes.GEN_AI_REQUEST_TOP_K, kwargs.get("top_k")) _set_span_attribute( - span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") + span, + SpanAttributes.LLM_PRESENCE_PENALTY, + kwargs.get("presence_penalty"), ) _set_span_attribute( - span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") + span, + SpanAttributes.LLM_FREQUENCY_PENALTY, + kwargs.get("frequency_penalty"), ) - generation_config = kwargs.get("generation_config") if generation_config and hasattr(generation_config, "response_schema"): try: @@ -402,7 +385,6 @@ def set_model_request_attributes(span, kwargs, llm_model): ) except Exception: pass - if "response_schema" in kwargs: try: _set_span_attribute( @@ -418,40 +400,50 @@ def set_model_request_attributes(span, kwargs, llm_model): def set_response_attributes(span, response, llm_model): if not should_send_prompts(): return + + output_messages = [] + if hasattr(response, "usage_metadata"): if isinstance(response.text, list): for index, item in enumerate(response): - prefix = f"{GenAIAttributes.GEN_AI_COMPLETION}.{index}" - _set_span_attribute(span, f"{prefix}.content", item.text) - _set_span_attribute(span, f"{prefix}.role", "assistant") + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": item.text}], + "finish_reason": "stop", + }) elif isinstance(response.text, str): - _set_span_attribute( - span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content", response.text - ) - _set_span_attribute( - span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.role", "assistant" - ) + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": response.text}], + "finish_reason": "stop", + }) else: if isinstance(response, list): for index, item in enumerate(response): - prefix = f"{GenAIAttributes.GEN_AI_COMPLETION}.{index}" - _set_span_attribute(span, f"{prefix}.content", item) - _set_span_attribute(span, f"{prefix}.role", "assistant") + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": item}], + "finish_reason": "stop", + }) elif isinstance(response, str): - _set_span_attribute( - span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content", response - ) - _set_span_attribute( - span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.role", "assistant" - ) + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": response}], + "finish_reason": "stop", + }) + + if output_messages: + _set_span_attribute( + span, + GenAIAttributes.GEN_AI_OUTPUT_MESSAGES, + json.dumps(output_messages), + ) def set_model_response_attributes(span, response, llm_model, token_histogram): if not span.is_recording(): return - _set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, llm_model) - if hasattr(response, "usage_metadata"): _set_span_attribute( span, @@ -468,7 +460,6 @@ def set_model_response_attributes(span, response, llm_model, token_histogram): GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS, response.usage_metadata.prompt_token_count, ) - if token_histogram and hasattr(response, "usage_metadata"): token_histogram.record( response.usage_metadata.prompt_token_count, @@ -479,12 +470,11 @@ def set_model_response_attributes(span, response, llm_model, token_histogram): } ) token_histogram.record( - response.usage_metadata.candidates_token_count, - attributes={ - GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", - GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", - GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, - }, - ) - + response.usage_metadata.candidates_token_count, + attributes={ + GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", + GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", + GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, + }, + ) span.set_status(Status(StatusCode.OK)) From 7b328ac38b1346645c4754cc6a7e6665236d5d21 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:17:40 -0400 Subject: [PATCH 02/16] test(google-genai): update assertions to use gen_ai.input/output.messages --- .../tests/test_generate_content.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 f78e7fd2d4..736d433832 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -1,3 +1,4 @@ +import json import pytest from opentelemetry.trace import StatusCode, SpanKind @@ -45,11 +46,15 @@ def test_client_spans(exporter, genai_client): assert attrs[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "gemini-2.5-flash" assert attrs[GenAIAttributes.GEN_AI_RESPONSE_MODEL] == "gemini-2.5-flash" - assert "gen_ai.prompt.0.content" in attrs - assert attrs["gen_ai.prompt.0.role"] == "user" + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES in attrs + input_msgs = json.loads(attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES]) + assert len(input_msgs) > 0 + assert input_msgs[0]["role"] == "user" - assert "gen_ai.completion.0.content" in attrs - assert attrs["gen_ai.completion.0.role"] == "assistant" + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs + output_msgs = json.loads(attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES]) + assert len(output_msgs) > 0 + assert output_msgs[0]["role"] == "assistant" assert attrs[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] > 0 From 4862ab31f4b51ae52e71429489373c32ef043dcf Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:02:33 -0400 Subject: [PATCH 03/16] fix(google-genai): use actual finish_reason from candidates; omit when unknown Updated output message construction to include finish_reason when available and adjusted handling for both non-streaming and streaming responses. --- .../google_generativeai/span_utils.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 8ce98341e2..b0f12af49d 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 @@ -404,32 +404,42 @@ def set_response_attributes(span, response, llm_model): output_messages = [] if hasattr(response, "usage_metadata"): + # Non-streaming: extract finish_reason from candidates when available + _finish_reason = None + _candidates = getattr(response, "candidates", None) + if _candidates and _candidates[0].finish_reason: + fr = _candidates[0].finish_reason + _finish_reason = fr.name.lower() if hasattr(fr, "name") else str(fr) + if isinstance(response.text, list): for index, item in enumerate(response): - output_messages.append({ + msg = { "role": "assistant", "parts": [{"type": "text", "content": item.text}], - "finish_reason": "stop", - }) + } + if _finish_reason: + msg["finish_reason"] = _finish_reason + output_messages.append(msg) elif isinstance(response.text, str): - output_messages.append({ + msg = { "role": "assistant", "parts": [{"type": "text", "content": response.text}], - "finish_reason": "stop", - }) + } + if _finish_reason: + msg["finish_reason"] = _finish_reason + output_messages.append(msg) else: + # Streaming path: omit finish_reason (not reliably available per-chunk) if isinstance(response, list): for index, item in enumerate(response): output_messages.append({ "role": "assistant", "parts": [{"type": "text", "content": item}], - "finish_reason": "stop", }) elif isinstance(response, str): output_messages.append({ "role": "assistant", "parts": [{"type": "text", "content": response}], - "finish_reason": "stop", }) if output_messages: From e6f86a89061d5a5e30362378ed9d8fb7484933f9 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:03:54 -0400 Subject: [PATCH 04/16] test(google-genai): strengthen assertions to validate parts structure in messages Add assertions to validate structure of input and output messages in tests. --- .../tests/test_generate_content.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 736d433832..25eb6cee87 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -50,11 +50,17 @@ def test_client_spans(exporter, genai_client): input_msgs = json.loads(attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES]) assert len(input_msgs) > 0 assert input_msgs[0]["role"] == "user" + assert isinstance(input_msgs[0].get("parts"), list) and len(input_msgs[0]["parts"]) > 0 + assert input_msgs[0]["parts"][0]["type"] == "text" + assert "content" in input_msgs[0]["parts"][0] assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs output_msgs = json.loads(attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES]) assert len(output_msgs) > 0 assert output_msgs[0]["role"] == "assistant" + assert isinstance(output_msgs[0].get("parts"), list) and len(output_msgs[0]["parts"]) > 0 + assert output_msgs[0]["parts"][0]["type"] == "text" + assert "content" in output_msgs[0]["parts"][0] assert attrs[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] > 0 From 08f2f744afd90cdfa656e37c5a1cc63890ac262a Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:26:17 -0400 Subject: [PATCH 05/16] test: assert deprecated gen_ai.prompt.* / gen_ai.completion.* attrs absent Adds negative assertions to test_client_spans to verify deprecated gen_ai.prompt.{N} and gen_ai.completion.{N} attributes are absent after migrating to gen_ai.input.messages / gen_ai.output.messages. Addresses CodeRabbit review feedback (round 2).Adds negative assertions to test_client_spans to guard against deprecated gen_ai.prompt.{N} and gen_ai.completion.{N} attributes leaking back in after the migration to gen_ai.input.messages / gen_ai.output.messages. Addresses CodeRabbit review feedback.Added assertions to verify deprecated attributes are not emitted. --- .../tests/test_generate_content.py | 3 +++ 1 file changed, 3 insertions(+) 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 25eb6cee87..124317a23b 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -61,6 +61,9 @@ def test_client_spans(exporter, genai_client): assert isinstance(output_msgs[0].get("parts"), list) and len(output_msgs[0]["parts"]) > 0 assert output_msgs[0]["parts"][0]["type"] == "text" assert "content" in output_msgs[0]["parts"][0] + # Verify deprecated indexed attributes are NOT emitted (migration guard) + assert not any(key.startswith("gen_ai.prompt.") for key in attrs.keys()) + assert not any(key.startswith("gen_ai.completion.") for key in attrs.keys()) assert attrs[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] > 0 From 56f5e515561e6ef6ca1ae2737be8a0a7558c9427 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:28:00 -0400 Subject: [PATCH 06/16] fix(google-genai): handle single Content obj and distinguish list[Content] from list[Part] in contents normalization Addresses CodeRabbit major review comment on contents normalization: 1. Adds elif hasattr(contents, "parts") branch to handle a single Content object passed directly (not wrapped in a list). 2. In the elif isinstance(contents, list) branch, sniffs contents[0] to distinguish multi-turn conversations (list[Content], each with .parts) from single-turn inputs (list[Part] or list[str]). Multi-turn: each Content becomes a separate message. Single-turn: all Parts are collected into one user message. Applied symmetrically to both the async set_input_attributes and sync set_input_attributes_sync. --- .../google_generativeai/span_utils.py | 109 ++++++++++++------ 1 file changed, 73 insertions(+), 36 deletions(-) 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 b0f12af49d..e27bcb4fca 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 @@ -207,14 +207,35 @@ async def set_input_attributes(span, args, kwargs, llm_model): "role": "user", "parts": [{"type": "text", "content": contents}], }) + elif hasattr(contents, "parts"): + # Single Content object (not a list) + processed_content = await _process_content_item(contents, span) + if processed_content: + input_messages.append({ + "role": getattr(contents, "role", "user"), + "parts": processed_content, + }) elif isinstance(contents, list): - for content_item in contents: - processed_content = await _process_content_item(content_item, span) - if processed_content: - role = getattr(content_item, "role", "user") + if contents and hasattr(contents[0], "parts"): + # Multi-turn: list of Content objects + for content_item in contents: + processed_content = await _process_content_item(content_item, span) + if processed_content: + role = getattr(content_item, "role", "user") + input_messages.append({ + "role": role, + "parts": processed_content, + }) + else: + # Single-turn: list of Part objects or strings + parts = [] + for content_item in contents: + items = await _process_content_item(content_item, span) + parts.extend(items) + if parts: input_messages.append({ - "role": role, - "parts": processed_content, + "role": "user", + "parts": parts, }) elif args and len(args) > 0: # Handle args - process each argument @@ -258,48 +279,64 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): "role": "user", "parts": [{"type": "text", "content": contents}], }) + elif hasattr(contents, "parts"): + # Single Content object (not a list) + processed_content = [] + for j, part in enumerate(contents.parts): + if hasattr(part, "text") and part.text: + processed_content.append({"type": "text", "content": part.text}) + elif _is_image_part(part): + processed_image = _process_image_part_sync( + part, span.context.trace_id, span.context.span_id, j + ) + if processed_image is not None: + processed_content.append(processed_image) + else: + processed_content.append({"type": "text", "content": str(part)}) + if processed_content: + input_messages.append({ + "role": getattr(contents, "role", "user"), + "parts": processed_content, + }) elif isinstance(contents, list): - # Process content list - could be mixed text and Part objects - for content in contents: - processed_content = [] - if hasattr(content, "parts"): - # Content with parts (Google GenAI Content object) + if contents and hasattr(contents[0], "parts"): + # Multi-turn: list of Content objects + for content in contents: + processed_content = [] for j, part in enumerate(content.parts): if hasattr(part, "text") and part.text: processed_content.append({"type": "text", "content": part.text}) elif _is_image_part(part): processed_image = _process_image_part_sync( - part, - span.context.trace_id, - span.context.span_id, - j + part, span.context.trace_id, span.context.span_id, j ) if processed_image is not None: processed_content.append(processed_image) else: - # Other part types processed_content.append({"type": "text", "content": str(part)}) - elif isinstance(content, str): - # Direct string in the list - processed_content.append({"type": "text", "content": content}) - elif _is_image_part(content): - # Direct Part object that's an image - processed_image = _process_image_part_sync( - content, - span.context.trace_id, - span.context.span_id, - 0 - ) - if processed_image is not None: - processed_content.append(processed_image) - else: - # Other content types - processed_content.append({"type": "text", "content": str(content)}) - - if processed_content: + if processed_content: + input_messages.append({ + "role": getattr(content, "role", "user"), + "parts": processed_content, + }) + else: + # Single-turn: list of Part objects or strings + parts = [] + for content in contents: + if isinstance(content, str): + parts.append({"type": "text", "content": content}) + elif _is_image_part(content): + processed_image = _process_image_part_sync( + content, span.context.trace_id, span.context.span_id, 0 + ) + if processed_image is not None: + parts.append(processed_image) + else: + parts.append({"type": "text", "content": str(content)}) + if parts: input_messages.append({ - "role": getattr(content, "role", "user"), - "parts": processed_content, + "role": "user", + "parts": parts, }) elif args and len(args) > 0: # Handle args - process each argument From 6dc73dce7dae6f380943a66e7f03cb46c2446182 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:29:21 -0400 Subject: [PATCH 07/16] Update span_utils.py --- .../google_generativeai/span_utils.py | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) 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 e27bcb4fca..37aaf6acc4 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 @@ -433,6 +433,21 @@ def set_model_request_attributes(span, kwargs, llm_model): pass + +def _serialize_response_part(part): + """Serialize a single response Part to a dict for gen_ai.output.messages.""" + if hasattr(part, "text") and part.text: + return {"type": "text", "content": part.text} + if hasattr(part, "function_call") and part.function_call: + fc = part.function_call + return { + "type": "function_call", + "name": fc.name, + "arguments": dict(fc.args) if hasattr(fc, "args") else {}, + } + return {"type": "text", "content": str(part)} + + @dont_throw def set_response_attributes(span, response, llm_model): if not should_send_prompts(): @@ -441,30 +456,49 @@ def set_response_attributes(span, response, llm_model): output_messages = [] if hasattr(response, "usage_metadata"): - # Non-streaming: extract finish_reason from candidates when available - _finish_reason = None + # Non-streaming: iterate over candidates, serialize all part types, and + # use each candidate's own finish_reason. _candidates = getattr(response, "candidates", None) - if _candidates and _candidates[0].finish_reason: - fr = _candidates[0].finish_reason - _finish_reason = fr.name.lower() if hasattr(fr, "name") else str(fr) - - if isinstance(response.text, list): - for index, item in enumerate(response): - msg = { - "role": "assistant", - "parts": [{"type": "text", "content": item.text}], - } - if _finish_reason: - msg["finish_reason"] = _finish_reason - output_messages.append(msg) - elif isinstance(response.text, str): - msg = { - "role": "assistant", - "parts": [{"type": "text", "content": response.text}], - } - if _finish_reason: - msg["finish_reason"] = _finish_reason - output_messages.append(msg) + if _candidates: + for candidate in _candidates: + _finish_reason = None + fr = getattr(candidate, "finish_reason", None) + if fr: + _finish_reason = fr.name.lower() if hasattr(fr, "name") else str(fr) + parts = [] + content = getattr(candidate, "content", None) + if content and hasattr(content, "parts"): + for part in content.parts: + parts.append(_serialize_response_part(part)) + if not parts: + # Fallback: try response.text for simple single-part responses + try: + text = response.text + if text: + parts = [{"type": "text", "content": text}] + except Exception: + pass + if parts: + msg = {"role": "assistant", "parts": parts} + if _finish_reason: + msg["finish_reason"] = _finish_reason + output_messages.append(msg) + else: + # No candidates field: fall back to response.text + try: + if isinstance(response.text, list): + for item in response: + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": item.text}], + }) + elif isinstance(response.text, str): + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": response.text}], + }) + except Exception: + pass else: # Streaming path: omit finish_reason (not reliably available per-chunk) if isinstance(response, list): From 78acebc03b8e0c105edf874b5e7779b537877046 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:21:20 -0400 Subject: [PATCH 08/16] fix(google-genai): use _map_gemini_finish_reason() for OTel-compliant finish_reason values --- .../instrumentation/google_generativeai/span_utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e6321d007a..8411f85aab 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 @@ -9,9 +9,9 @@ should_send_prompts, ) from opentelemetry.semconv._incubating.attributes import ( - gen_ai_attributes as GenAIAttributes, + gen_ai_attributes as GenAIAttributes ) -from opentelemetry.semconv_ai import ( +from opentelemetry.semconv_ai import SpanAttributes, ) from opentelemetry.trace.status import Status, StatusCode @@ -654,7 +654,7 @@ def set_response_attributes(span, response, llm_model, stream_last_chunk=None): _finish_reason = None fr = getattr(candidate, "finish_reason", None) if fr: - _finish_reason = fr.name.lower() if hasattr(fr, "name") else str(fr) + _finish_reason = _map_gemini_finish_reason(fr) or None parts = [] content = getattr(candidate, "content", None) if content and hasattr(content, "parts"): From d0053a96b5f06aed17bd7566e37271a5656c2368 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:02:13 -0400 Subject: [PATCH 09/16] fix(google-genai): fix parts_acc NameError and undefined _parts_from_genai_part_async in _process_content_item Refactor content processing to use _process_content_part for better handling of parts. --- .../instrumentation/google_generativeai/span_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 8411f85aab..336a944856 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 @@ -241,7 +241,9 @@ async def _process_content_item(content_item, span): if hasattr(content_item, "parts"): # Content with parts (Google GenAI Content object) for part_index, part in enumerate(content_item.parts): - parts_acc.extend(await _parts_from_genai_part_async(part, span, part_index)) + result = await _process_content_part(part, span, part_index) + if result: + processed_content.append(result) elif isinstance(content_item, str): # Direct string in the list processed_content.append({"type": "text", "content": content_item}) From 4405529631a77a9cc8a3836f294ec1cdd3051096 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:03:51 -0400 Subject: [PATCH 10/16] fix(google-genai): align test token attr to GEN_AI_USAGE_TOTAL_TOKENS (was LLM_USAGE_TOTAL_TOKENS) --- .../tests/test_generate_content.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8cb927a959..5359961579 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -73,7 +73,7 @@ def test_client_spans(exporter, genai_client): assert not any(key.startswith("gen_ai.prompt.") for key in attrs.keys()) assert not any(key.startswith("gen_ai.completion.") for key in attrs.keys()) - assert attrs[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] > 0 + assert attrs[SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] > 0 From 2fc3bd0efa7241279e23972fc8290190063e851a Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:09:29 -0400 Subject: [PATCH 11/16] fix(google-genai): normalize message roles via _normalize_message_role() at all input_messages append sites --- .../instrumentation/google_generativeai/span_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 336a944856..2bf9af5674 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 @@ -335,7 +335,7 @@ async def set_input_attributes(span, args, kwargs, llm_model): processed_content = await _process_content_item(contents, span) if processed_content: input_messages.append({ - "role": getattr(contents, "role", "user"), + "role": _normalize_message_role(getattr(contents, "role", None)), "parts": processed_content, }) elif isinstance(contents, list): @@ -344,7 +344,7 @@ async def set_input_attributes(span, args, kwargs, llm_model): for content_item in contents: processed_content = await _process_content_item(content_item, span) if processed_content: - role = getattr(content_item, "role", "user") + role = _normalize_message_role(getattr(content_item, "role", None)) input_messages.append({ "role": role, "parts": processed_content, @@ -416,7 +416,7 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): processed_content.append({"type": "text", "content": str(part)}) if processed_content: input_messages.append({ - "role": getattr(contents, "role", "user"), + "role": _normalize_message_role(getattr(contents, "role", None)), "parts": processed_content, }) elif isinstance(contents, list): @@ -437,7 +437,7 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): processed_content.append({"type": "text", "content": str(part)}) if processed_content: input_messages.append({ - "role": getattr(content, "role", "user"), + "role": _normalize_message_role(getattr(content, "role", None)), "parts": processed_content, }) else: From 3296ff30786410e1548865b55a8a031332b2f9b8 Mon Sep 17 00:00:00 2001 From: abhyudayareddy <54602866+abhyudayareddy@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:37:18 -0400 Subject: [PATCH 12/16] fix(google-generativeai): fix SyntaxError, NameError, wrong iteration, and candidate bleed in span_utils --- .../google_generativeai/span_utils.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) 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 2bf9af5674..8950c39a56 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 @@ -11,13 +11,16 @@ from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAIAttributes ) -from opentelemetry.semconv_ai import +from opentelemetry.semconv_ai import ( SpanAttributes, ) from opentelemetry.trace.status import Status, StatusCode logger = logging.getLogger(__name__) +_GCP_GEN_AI = GenAIAttributes.GenAiProviderNameValues.GCP_GEN_AI.value +_GEN_CONTENT = GenAIAttributes.GenAiOperationNameValues.GENERATE_CONTENT.value + def _set_span_attribute(span, name, value): if value is not None: @@ -662,24 +665,24 @@ def set_response_attributes(span, response, llm_model, stream_last_chunk=None): if content and hasattr(content, "parts"): for part in content.parts: parts.append(_serialize_response_part(part)) - if not parts: - # Fallback: try response.text for simple single-part responses - try: - text = response.text - if text: - parts = [{"type": "text", "content": text}] - except Exception: - pass if parts: msg = {"role": "assistant", "parts": parts} if _finish_reason: msg["finish_reason"] = _finish_reason output_messages.append(msg) + if not output_messages: + # Fallback: try response.text for simple single-part responses + try: + text = response.text + if text: + output_messages.append({"role": "assistant", "parts": [{"type": "text", "content": text}]}) + except Exception: + pass else: # No candidates field: fall back to response.text try: if isinstance(response.text, list): - for item in response: + for item in response.text: output_messages.append({ "role": "assistant", "parts": [{"type": "text", "content": item.text}], @@ -749,7 +752,7 @@ def set_model_response_attributes( token_histogram.record( response.usage_metadata.candidates_token_count, attributes={ - GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", + GenAIAttributes.GEN_AI_PROVIDER_NAME: _GCP_GEN_AI, GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, }, From 27fdf93fa36d00e31603d3a0741a9927d682a71b Mon Sep 17 00:00:00 2001 From: abhyudayareddy Date: Sun, 26 Apr 2026 15:28:00 -0400 Subject: [PATCH 13/16] fix(google-generativeai): resolve remaining NameErrors, missing helpers, and streaming finish_reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bugs survive the previous commit and break the module at import time or at runtime on every code path: - NameError: `um` referenced in `set_model_response_attributes` before assignment — fixes all four `um.xxx` calls by binding `um = response.usage_metadata` at the top of each branch. - ImportError: `_collect_finish_reasons_from_response` imported by `__init__` but absent from `span_utils` — implements the function (one mapped reason per candidate, empty list when no candidates attribute exists). - NameError: `_parts_from_genai_part_sync` called in `_system_instruction_to_parts` but not defined — implements the function with thought/reasoning block support per the existing test suite. - AttributeError: `item.text` on list items in the `response.text` fallback — the list branch is absorbed into `_output_messages_from_generate_response`, which iterates candidates correctly. Additional improvements required for the test suite to pass: - Add `_output_messages_from_generate_response` helper (always populates `finish_reason`, ``""`` when unknown) and use it in the non-streaming path of `set_response_attributes`. - Streaming path: add `finish_reason` to every emitted message; suppress empty-string messages when no `stream_last_chunk` context is available; derive `finish_reason` from the last-chunk candidates when present. - `set_model_response_attributes`: emit `gen_ai.response.finish_reasons` only when at least one reason is non-empty (using `stream_finish_reasons` arg or `_collect_finish_reasons_from_response` as fallback). Fixes CodeRabbit CR-1/CR-2/CR-3/CR-4 and closes remaining test failures in `test_finish_reasons.py`. --- .../google_generativeai/span_utils.py | 158 ++++++++++++------ 1 file changed, 108 insertions(+), 50 deletions(-) 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 8950c39a56..cb91e9a135 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 @@ -641,6 +641,80 @@ def _serialize_response_part(part): return {"type": "text", "content": str(part)} +def _parts_from_genai_part_sync(part, span, part_index): + """Serialize one GenAI Part to a list of OTel part dicts. + + When ``part.thought`` is True the part is emitted as type ``"reasoning"`` + instead of ``"text"`` to avoid producing duplicate parts for thinking blocks. + """ + if getattr(part, "thought", None): + return [{"type": "reasoning", "content": getattr(part, "text", "")}] + if hasattr(part, "text") and part.text: + return [{"type": "text", "content": part.text}] + if _is_image_part(part): + processed = _otel_image_part_from_genai_part(part, span, part_index, sync=True) + return [processed] if processed else [] + if hasattr(part, "function_call") and part.function_call: + fc = part.function_call + return [{ + "type": "function_call", + "name": fc.name, + "arguments": dict(fc.args) if hasattr(fc, "args") else {}, + }] + return [{"type": "text", "content": str(part)}] + + +def _collect_finish_reasons_from_response(response): + """Return a list of OTel finish-reason strings, one per candidate. + + Preserves 1:1 alignment with candidates so callers can zip them. Returns + ``[]`` when the response has no ``candidates`` attribute. + """ + candidates = getattr(response, "candidates", None) + if not candidates: + return [] + return [ + _map_gemini_finish_reason(getattr(c, "finish_reason", None)) + for c in candidates + ] + + +def _output_messages_from_generate_response(span, response): + """Extract output messages from a GenerateContentResponse. + + Always includes a ``finish_reason`` key (``""`` when unknown). Used by + both ``set_response_attributes`` (non-streaming path) and tests. + """ + output_messages = [] + candidates = getattr(response, "candidates", None) + if candidates: + for candidate in candidates: + fr = getattr(candidate, "finish_reason", None) + finish_reason = _map_gemini_finish_reason(fr) + parts = [] + content = getattr(candidate, "content", None) + if content and hasattr(content, "parts"): + for part in content.parts: + parts.append(_serialize_response_part(part)) + output_messages.append({ + "role": "assistant", + "parts": parts, + "finish_reason": finish_reason, + }) + else: + try: + text = getattr(response, "text", None) + if text: + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": text}], + "finish_reason": "", + }) + except Exception: + pass + return output_messages + + @dont_throw def set_response_attributes(span, response, llm_model, stream_last_chunk=None): if not span.is_recording(): @@ -651,62 +725,34 @@ def set_response_attributes(span, response, llm_model, stream_last_chunk=None): output_messages = [] if hasattr(response, "usage_metadata"): - # Non-streaming: iterate over candidates, serialize all part types, and - # use each candidate's own finish_reason. - _candidates = getattr(response, "candidates", None) - if _candidates: - for candidate in _candidates: - _finish_reason = None - fr = getattr(candidate, "finish_reason", None) - if fr: - _finish_reason = _map_gemini_finish_reason(fr) or None - parts = [] - content = getattr(candidate, "content", None) - if content and hasattr(content, "parts"): - for part in content.parts: - parts.append(_serialize_response_part(part)) - if parts: - msg = {"role": "assistant", "parts": parts} - if _finish_reason: - msg["finish_reason"] = _finish_reason - output_messages.append(msg) - if not output_messages: - # Fallback: try response.text for simple single-part responses - try: - text = response.text - if text: - output_messages.append({"role": "assistant", "parts": [{"type": "text", "content": text}]}) - except Exception: - pass - else: - # No candidates field: fall back to response.text - try: - if isinstance(response.text, list): - for item in response.text: - output_messages.append({ - "role": "assistant", - "parts": [{"type": "text", "content": item.text}], - }) - elif isinstance(response.text, str): - output_messages.append({ - "role": "assistant", - "parts": [{"type": "text", "content": response.text}], - }) - except Exception: - pass + # Non-streaming GenerateContentResponse: delegate to the shared helper + # which always populates finish_reason (empty string when unknown). + output_messages = _output_messages_from_generate_response(span, response) else: - # Streaming path: omit finish_reason (not reliably available per-chunk) + # Streaming / accumulated-string / list path. + # Derive finish_reason from the last streaming chunk when available. + chunk_finish_reason = "" + if stream_last_chunk is not None: + _chunk_candidates = getattr(stream_last_chunk, "candidates", None) + if _chunk_candidates: + _fr = getattr(_chunk_candidates[0], "finish_reason", None) + chunk_finish_reason = _map_gemini_finish_reason(_fr) + if isinstance(response, list): - for index, item in enumerate(response): + for item in response: output_messages.append({ "role": "assistant", "parts": [{"type": "text", "content": item}], + "finish_reason": "", }) elif isinstance(response, str): - output_messages.append({ - "role": "assistant", - "parts": [{"type": "text", "content": response}], - }) + # Suppress empty-string messages when there is no last-chunk context. + if response or stream_last_chunk is not None: + output_messages.append({ + "role": "assistant", + "parts": [{"type": "text", "content": response}], + "finish_reason": chunk_finish_reason, + }) if output_messages: _set_span_attribute( @@ -723,6 +769,7 @@ def set_model_response_attributes( return _set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, llm_model) if hasattr(response, "usage_metadata"): + um = response.usage_metadata _set_span_attribute( span, SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, @@ -739,6 +786,7 @@ def set_model_response_attributes( um.prompt_token_count, ) if token_histogram and hasattr(response, "usage_metadata"): + um = response.usage_metadata token_histogram.record( um.prompt_token_count, attributes={ @@ -750,11 +798,21 @@ def set_model_response_attributes( }, ) token_histogram.record( - response.usage_metadata.candidates_token_count, + um.candidates_token_count, attributes={ GenAIAttributes.GEN_AI_PROVIDER_NAME: _GCP_GEN_AI, GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, }, ) + # Emit gen_ai.response.finish_reasons only when at least one reason is known. + reasons = stream_finish_reasons + if reasons is None: + reasons = _collect_finish_reasons_from_response(response) + if reasons and any(r for r in reasons): + _set_span_attribute( + span, + GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS, + reasons, + ) span.set_status(Status(StatusCode.OK)) From 3317023eae7866fb587103944827c355cfbc929c Mon Sep 17 00:00:00 2001 From: Abhyuday Date: Sat, 29 Aug 2026 09:47:15 -0400 Subject: [PATCH 14/16] fix(google-genai): preserve gen_ai.response.id during semconv migration set_model_response_attributes() stopped copying response.response_id to GenAIAttributes.GEN_AI_RESPONSE_ID during the migration, silently dropping the provider-issued response identifier from every response that supplies one. Restore the guarded assignment (works for both the non-streaming response and the streaming last-chunk) and assert it in test_client_spans against the responseId already present in the VCR cassette. Signed-off-by: Abhyuday --- .../instrumentation/google_generativeai/span_utils.py | 5 +++++ .../tests/test_generate_content.py | 3 +++ 2 files changed, 8 insertions(+) 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 31a336c330..ad3bb4a6b6 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 @@ -768,6 +768,11 @@ def set_model_response_attributes( if not span.is_recording(): return _set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_MODEL, llm_model) + + rid = getattr(response, "response_id", None) + if rid: + _set_span_attribute(span, GenAIAttributes.GEN_AI_RESPONSE_ID, rid) + um = None if hasattr(response, "usage_metadata"): um = response.usage_metadata 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 9c162a1bec..56980b0378 100644 --- a/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py +++ b/packages/opentelemetry-instrumentation-google-generativeai/tests/test_generate_content.py @@ -74,6 +74,9 @@ def test_client_spans(exporter, genai_client): assert not any(key.startswith("gen_ai.prompt.") for key in attrs.keys()) assert not any(key.startswith("gen_ai.completion.") for key in attrs.keys()) + # The provider-issued response id must survive the semconv migration + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_ID] == "-hk4afOSMZKkjuMPnJWGkAk" + assert attrs[SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] > 0 assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] > 0 From bcdcefd9da79c455246b42461668fcda63f47886 Mon Sep 17 00:00:00 2001 From: Abhyuday Date: Sat, 29 Aug 2026 09:50:10 -0400 Subject: [PATCH 15/16] test(google-genai): fix list-of-strings input expectation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_list_of_strings asserted that contents=["first", "second"] produces two messages, but google.genai._transformers.t_contents groups a bare list of strings into a single user turn with multiple parts — which is what both set_input_attributes and set_input_attributes_sync already emit. Assert the correct single-message / two-part shape. Signed-off-by: Abhyuday --- .../tests/test_async_input.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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..84077e1b48 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,13 @@ async def test_list_of_strings(self, monkeypatch): span, (), {"contents": ["first", "second"]}, "gemini-pro" ) + # A bare list of strings is a single user turn with multiple parts + # (matches google.genai._transformers.t_contents). 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 len(messages) == 1 + assert messages[0]["role"] == "user" + assert [p["content"] for p in messages[0]["parts"]] == ["first", "second"] @pytest.mark.asyncio async def test_prompt_kwarg(self, monkeypatch): From 021cedcd3299a0e0fd86dc4c954b79ed3a583b24 Mon Sep 17 00:00:00 2001 From: Abhyuday Date: Thu, 5 Nov 2026 09:26:30 -0500 Subject: [PATCH 16/16] fix(google-genai): group contents into turns like google.genai t_contents gen_ai.input.messages dropped a single Content passed as `contents`, and it emitted every bare string or Part in a contents list as its own user message. google.genai sends a run of bare parts as one turn (a model turn when they are function calls) and a lone Content as one turn. The sync and async input paths now share a grouping helper that matches this. Also assert that gen_ai.response.id is set and that no deprecated gen_ai.prompt.* / gen_ai.completion.* attributes are emitted. Co-Authored-By: Claude Opus 5.5 --- .../google_generativeai/span_utils.py | 113 ++++++++++++++---- .../tests/test_async_input.py | 91 +++++++++++++- .../tests/test_generate_content.py | 6 + 3 files changed, 180 insertions(+), 30 deletions(-) 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))