diff --git a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py index 05c1769f2..e6cabdf36 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/messages_extractors.py @@ -25,8 +25,9 @@ from opentelemetry.util.genai.invocation import InferenceInvocation from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, + TextPart, ) from opentelemetry.util.types import AttributeValue @@ -125,10 +126,16 @@ def get_input_messages( def get_system_instruction( system: str | Iterable[TextBlockParam] | None, -) -> list[MessagePart]: +) -> list[SystemInstructionPart]: if system is None: return [] - return convert_content_to_parts(system) + if isinstance(system, str): + return [TextPart(content=system)] if system else [] + return [ + TextPart(content=block["text"]) + for block in system + if block.get("text") + ] def get_output_messages_from_message( diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py index 88cdaffe3..064fdbca9 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/src/opentelemetry/instrumentation/genai/bedrock/extractors.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +from collections.abc import Mapping, Sequence from typing import Any, TypeGuard from urllib.parse import urlparse @@ -19,6 +20,7 @@ OutputMessage, ReasoningPart, Role, + SystemInstructionPart, TextPart, ToolCallRequestPart, ToolCallResponsePart, @@ -235,7 +237,7 @@ def extract_content_block(block: dict[str, Any]) -> MessagePart | None: "toolRemoval", ): if key in block: - return GenericPart(type=key, value=None) + return GenericPart(type=key) return None @@ -256,6 +258,30 @@ def _extract_parts(content: Any) -> list[MessagePart]: return parts +def _extract_system_parts( + content: str | Sequence[Mapping[str, Any] | str] | None, +) -> list[SystemInstructionPart]: + if not content: + return [] + if isinstance(content, str): + return [TextPart(content=content)] if content else [] + parts: list[SystemInstructionPart] = [] + for item in content: + if isinstance(item, str): + if item: + parts.append(TextPart(content=item)) + elif _is_dict(item): + text = item.get("text") + if isinstance(text, str) and text: + parts.append(TextPart(content=text)) + else: + for key in item: + if key != "text": + parts.append(GenericPart(type=key)) + break + return parts + + def _extract_guardrail_id( params: dict[str, Any], invocation: InferenceInvocation ) -> None: @@ -324,7 +350,7 @@ def extract_converse_request( # system instruction raw_system = kwargs.get("system") if capture_content and raw_system: - system_parts = _extract_parts(raw_system) + system_parts = _extract_system_parts(raw_system) if system_parts: invocation.system_instruction = system_parts @@ -533,7 +559,7 @@ def extract_invoke_model_request( # System instruction (e.g. Anthropic / Nova) raw_system = body.get("system") if raw_system: - system_parts = _extract_parts(raw_system) + system_parts = _extract_system_parts(raw_system) if system_parts: invocation.system_instruction = system_parts diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py index 36745dd81..0450fa270 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py @@ -27,6 +27,7 @@ ) from opentelemetry.trace import StatusCode from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import GenericPart, TextPart @pytest.mark.vcr @@ -596,3 +597,47 @@ def test_extract_converse_request_prompt_variables_no_content( == "sgi5gkybzqak" ) assert "gen_ai.prompt.variable.user_name" not in invocation.attributes + + +def test_extract_converse_request_system_instruction(tracer_provider) -> None: + handler = TelemetryHandler(tracer_provider=tracer_provider) + invocation = handler.inference(provider="aws.bedrock") + + extract_converse_request( + { + "system": [ + {"text": "Be concise"}, + {"text": "Answer politely"}, + ], + }, + invocation, + ) + + assert invocation.system_instruction == [ + TextPart(content="Be concise"), + TextPart(content="Answer politely"), + ] + + +def test_extract_converse_request_system_instruction_generic( + tracer_provider, +) -> None: + handler = TelemetryHandler(tracer_provider=tracer_provider) + invocation = handler.inference(provider="aws.bedrock") + + extract_converse_request( + { + "system": [ + {"text": "Be concise"}, + {"guardContent": {"guardrailIdentifier": "gr-123"}}, + {"cachePoint": {"type": "default"}}, + ], + }, + invocation, + ) + + assert invocation.system_instruction == [ + TextPart(content="Be concise"), + GenericPart(type="guardContent"), + GenericPart(type="cachePoint"), + ] diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/511.fixed b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/511.fixed deleted file mode 100644 index ca6b58e10..000000000 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/511.fixed +++ /dev/null @@ -1 +0,0 @@ -Divert `SystemMessage` inputs to `gen_ai.system_instructions` instead of emitting them as `role="system"` inside `gen_ai.input.messages`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py index 493460592..054e4874c 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/callback_handler.py @@ -40,7 +40,7 @@ prepare_tool_definitions, resolve_response_model_and_id, response_fields_from_generation, - split_system_and_input_messages, + to_input_messages, ) from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import ( @@ -327,15 +327,14 @@ def on_chat_model_start( if "ls_max_tokens" in metadata: max_tokens = metadata.get("ls_max_tokens") - # Flatten ``list[list[BaseMessage]]`` (one inner list per generation - # request) before splitting into system / input. + # ``messages`` from on_chat_model_start is ``list[list[BaseMessage]]`` + # (one inner list per generation request). Flatten and let + # :func:`to_input_messages` produce spec-conformant ``InputMessage`` s + # with proper roles, tool-call requests, tool results, and reasoning. flattened: list[BaseMessage] = [msg for sub in messages for msg in sub] - system_instruction: list[MessagePart] = [] input_messages: list[InputMessage] = [] if self._telemetry_handler.should_capture_content(): - system_instruction, input_messages = ( - split_system_and_input_messages(flattened) - ) + input_messages = to_input_messages(flattened) llm_invocation = self._telemetry_handler.inference( provider, @@ -343,8 +342,6 @@ def on_chat_model_start( ) llm_invocation.conversation_id = _conversation_id(metadata) llm_invocation.input_messages = input_messages - if system_instruction: - llm_invocation.system_instruction = system_instruction llm_invocation.top_p = top_p llm_invocation.frequency_penalty = frequency_penalty llm_invocation.presence_penalty = presence_penalty diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py index 2a1bfbe06..192fd9a24 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py @@ -367,41 +367,6 @@ def to_input_messages( return result -def split_system_and_input_messages( - messages: Iterable[Any], -) -> tuple[list[MessagePart], list[InputMessage]]: - """Split ``messages`` into ``system_instruction`` parts and ``InputMessage`` s. - - Called only when content capture is enabled - (``TelemetryHandler.should_capture_content()``). - """ - materialized = list(messages) - try: - normalized: Iterable[BaseMessage] = convert_to_messages(materialized) - except Exception: # pylint: disable=broad-except - normalized = [m for m in materialized if isinstance(m, BaseMessage)] - - system_parts: list[MessagePart] = [] - input_messages: list[InputMessage] = [] - - for message in normalized: - if isinstance(message, SystemMessage): - system_parts.extend(_content_to_parts(message.content)) - else: - parts = _message_parts(message) - if not parts and not _has_content(message): - continue - input_messages.append( - InputMessage( - role=_normalize_role(message) or Role.USER.value, - parts=parts, - name=_message_name(message), - ) - ) - - return system_parts, input_messages - - def to_output_messages( messages: Iterable[BaseMessage], *, diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/_shared.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/_shared.py deleted file mode 100644 index 1564bf9d2..000000000 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/_shared.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright The OpenTelemetry Authors -# SPDX-License-Identifier: Apache-2.0 - -"""Helpers shared across langchain conformance scenarios.""" - -from __future__ import annotations - -from typing import Any - -from opentelemetry.test.weaver_live_check import LiveCheckReport - - -def span_attribute_values(report: LiveCheckReport, name: str) -> list[Any]: - """Return every value of ``name`` across all span samples in ``report``.""" - return [ - attr["value"] - for entry in report["samples"] - if "span" in entry - for attr in entry["span"]["attributes"] - if attr["name"] == name - ] diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference.py index 16e7976e8..90c0c109e 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference.py @@ -16,15 +16,12 @@ from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.test.weaver_live_check import LiveCheckReport from opentelemetry.test_util_genai.conformance import ( ExpectedViolation, Scenario, ) from opentelemetry.test_util_genai.instrumentor import instrument -from ._shared import span_attribute_values - class InferenceScenario(Scenario): expected_spans = {"chat": 1} @@ -40,20 +37,6 @@ class InferenceScenario(Scenario): ), ) - def validate(self, report: LiveCheckReport) -> None: - super().validate(report) - system_instructions = span_attribute_values( - report, "gen_ai.system_instructions" - ) - assert len(system_instructions) == 1, ( - "chat span with a SystemMessage input should set " - f"gen_ai.system_instructions once; saw {system_instructions}" - ) - assert "You are a helpful assistant!" in system_instructions[0], ( - "gen_ai.system_instructions should carry the SystemMessage " - f"content; got {system_instructions[0]}" - ) - def run( self, *, diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference_streaming.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference_streaming.py index 068ec6b1b..65bf0c609 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference_streaming.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/inference_streaming.py @@ -28,8 +28,6 @@ ) from opentelemetry.test_util_genai.instrumentor import instrument -from ._shared import span_attribute_values - class InferenceStreamingScenario(Scenario): expected_spans = {"chat": 1} @@ -55,22 +53,17 @@ class InferenceStreamingScenario(Scenario): def validate(self, report: LiveCheckReport) -> None: super().validate(report) - stream_values = span_attribute_values(report, "gen_ai.request.stream") + stream_values = [ + attr["value"] + for entry in report["samples"] + if "span" in entry + for attr in entry["span"]["attributes"] + if attr["name"] == "gen_ai.request.stream" + ] assert stream_values == [True], ( "streaming chat should set gen_ai.request.stream=true on the chat " f"span; saw {stream_values}" ) - system_instructions = span_attribute_values( - report, "gen_ai.system_instructions" - ) - assert len(system_instructions) == 1, ( - "streaming chat span with a SystemMessage input should set " - f"gen_ai.system_instructions once; saw {system_instructions}" - ) - assert "You are a helpful assistant!" in system_instructions[0], ( - "gen_ai.system_instructions should carry the SystemMessage " - f"content; got {system_instructions[0]}" - ) def run( self, diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py index d60c1be9a..2be21d95b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py @@ -28,7 +28,6 @@ LangChainInstrumentor, ) from opentelemetry.instrumentation.genai.langchain.utils import ( - split_system_and_input_messages, to_input_messages, ) from opentelemetry.sdk.trace import ReadableSpan @@ -39,6 +38,7 @@ from opentelemetry.semconv._incubating.metrics import gen_ai_metrics from opentelemetry.semconv.attributes import error_attributes from opentelemetry.test_util_genai.instrumentor import instrument +from opentelemetry.util.genai.types import TextPart def _openai_cassette_name(model, base: str) -> str: @@ -726,87 +726,39 @@ def test_function_message_role_maps_to_tool(): assert result[0].role == "tool" -def test_split_system_and_input_messages_diverts_system_instructions(): - system, inputs = split_system_and_input_messages( +def test_system_message_role_maps_to_system(): + result = to_input_messages( [ SystemMessage(content="You are helpful."), HumanMessage(content="Hi"), ] ) - assert len(system) == 1 - assert system[0].content == "You are helpful." - assert system[0].type == "text" - assert len(inputs) == 1 - assert inputs[0].role == "user" - - -def test_split_system_and_input_messages_position_independent(): - # A ``SystemMessage`` interleaved between non-system messages still lands - # in ``system_instruction`` — semconv treats it as a top-level list rather - # than an interleaved role, and combining multiple system chunks in order - # matches how providers deliver them. - system, inputs = split_system_and_input_messages( - [ - SystemMessage(content="First guidance."), - HumanMessage(content="Hi"), - AIMessage(content="Hello"), - SystemMessage(content="Second guidance."), - HumanMessage(content="Follow-up"), - ] - ) - assert [part.content for part in system] == [ - "First guidance.", - "Second guidance.", - ] - assert [msg.role for msg in inputs] == ["user", "assistant", "user"] + assert len(result) == 2 + assert result[0].role == "system" + assert result[0].parts == [TextPart(content="You are helpful.")] + assert result[1].role == "user" -def test_split_system_and_input_messages_handles_system_message_chunk(): - # ``SystemMessageChunk`` (streaming) subclasses ``SystemMessage`` — the - # helper matches by ``isinstance`` so streamed system content is diverted - # too instead of leaking into ``gen_ai.input.messages``. - system, inputs = split_system_and_input_messages( +def test_system_message_handles_system_message_chunk(): + result = to_input_messages( [ SystemMessageChunk(content="Streamed system prompt."), HumanMessage(content="Hi"), ] ) - assert len(system) == 1 - assert system[0].content == "Streamed system prompt." - assert [msg.role for msg in inputs] == ["user"] + assert len(result) == 2 + assert result[0].role == "system" + assert result[0].parts == [TextPart(content="Streamed system prompt.")] + assert result[1].role == "user" -def test_split_system_and_input_messages_empty_when_no_system_message(): - system, inputs = split_system_and_input_messages( - [HumanMessage(content="Hi")] - ) - assert system == [] - assert [msg.role for msg in inputs] == ["user"] - - -def test_split_system_and_input_messages_normalizes_shorthand_inputs(): - # Short-hand tuple / dict forms are normalized via ``convert_to_messages`` - # before the ``isinstance`` partition so that a short-hand system entry is - # diverted to ``system_instruction`` instead of leaking into - # ``gen_ai.input.messages`` as ``role: "system"``. - system, inputs = split_system_and_input_messages( - [ - ("system", "You are helpful."), - {"role": "user", "content": "Hi"}, - ] - ) - assert [part.content for part in system] == ["You are helpful."] - assert [msg.role for msg in inputs] == ["user"] - - -def test_split_system_and_input_messages_preserves_name(): - system, inputs = split_system_and_input_messages( +def test_to_input_messages_preserves_name(): + inputs = to_input_messages( [ HumanMessage(content="Hi", name="Alice"), AIMessage(content="Hello", name="Bob"), ] ) - assert len(inputs) == 2 assert inputs[0].name == "Alice" assert inputs[1].name == "Bob" @@ -916,18 +868,11 @@ def assert_openai_completion_attributes( if verify_content: input_message = attributes[gen_ai_attributes.GEN_AI_INPUT_MESSAGES] assert input_message is not None - assert '"role":"system"' not in input_message + assert '"role":"system"' in input_message + assert '"content":"You are a helpful assistant!"' in input_message assert '"role":"user"' in input_message assert '"content":"What is the capital of France?"' in input_message - system_instructions = attributes[ - gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS - ] - assert system_instructions is not None - assert ( - '"content":"You are a helpful assistant!"' in system_instructions - ) - # Assert output message output_message = attributes[gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES] assert output_message is not None @@ -937,7 +882,7 @@ def assert_openai_completion_attributes( else: assert gen_ai_attributes.GEN_AI_INPUT_MESSAGES not in attributes assert gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES not in attributes - assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attributes + assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attributes def assert_openai_completion_attributes_with_error( @@ -974,24 +919,17 @@ def assert_openai_completion_attributes_with_error( if verify_content: input_message = attributes[gen_ai_attributes.GEN_AI_INPUT_MESSAGES] assert input_message is not None - assert '"role":"system"' not in input_message + assert '"role":"system"' in input_message + assert '"content":"You are a helpful assistant!"' in input_message assert '"role":"user"' in input_message assert '"content":"What is the capital of France?"' in input_message - system_instructions = attributes[ - gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS - ] - assert system_instructions is not None - assert ( - '"content":"You are a helpful assistant!"' in system_instructions - ) - # Assert output message assert gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES not in attributes else: assert gen_ai_attributes.GEN_AI_INPUT_MESSAGES not in attributes assert gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES not in attributes - assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attributes + assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attributes def assert_bedrock_completion_attributes( @@ -1231,6 +1169,12 @@ def assert_log_record(log_record, parent_span, response=None): attrs.get(gen_ai_attributes.GEN_AI_INPUT_MESSAGES, []) ) expected_input = [ + { + "parts": [ + {"content": "You are a helpful assistant!", "type": "text"} + ], + "role": "system", + }, { "parts": [ {"content": "What is the capital of France?", "type": "text"} @@ -1238,18 +1182,13 @@ def assert_log_record(log_record, parent_span, response=None): "role": "user", }, ] - assert len(input_msgs) == 1 + assert len(input_msgs) == 2 for i, exp in enumerate(expected_input): got = _normalize_to_dict(input_msgs[i]) assert got["role"] == exp["role"] assert _normalize_to_list(got["parts"]) == exp["parts"] - system_instructions = _normalize_to_list( - attrs.get(gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS, []) - ) - assert system_instructions == [ - {"content": "You are a helpful assistant!", "type": "text"} - ] + assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attrs output_msgs = _normalize_to_list( attrs.get(gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES, []) @@ -1296,6 +1235,12 @@ def assert_log_record_when_error(log_record, parent_span): attrs.get(gen_ai_attributes.GEN_AI_INPUT_MESSAGES, []) ) expected_input = [ + { + "parts": [ + {"content": "You are a helpful assistant!", "type": "text"} + ], + "role": "system", + }, { "parts": [ {"content": "What is the capital of France?", "type": "text"} @@ -1303,19 +1248,13 @@ def assert_log_record_when_error(log_record, parent_span): "role": "user", }, ] - assert len(input_msgs) == 1 + assert len(input_msgs) == 2 for i, exp in enumerate(expected_input): got = _normalize_to_dict(input_msgs[i]) assert got["role"] == exp["role"] assert _normalize_to_list(got["parts"]) == exp["parts"] - system_instructions = _normalize_to_list( - attrs.get(gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS, []) - ) - assert system_instructions == [ - {"content": "You are a helpful assistant!", "type": "text"} - ] - + assert gen_ai_attributes.GEN_AI_SYSTEM_INSTRUCTIONS not in attrs assert gen_ai_attributes.GEN_AI_OUTPUT_MESSAGES not in attrs assert_log_parent(log_record, parent_span) diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/src/opentelemetry/instrumentation/genai/llama_index/_handler.py b/instrumentation/opentelemetry-instrumentation-genai-llama-index/src/opentelemetry/instrumentation/genai/llama_index/_handler.py index 6bc34b0de..87801c306 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/src/opentelemetry/instrumentation/genai/llama_index/_handler.py +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/src/opentelemetry/instrumentation/genai/llama_index/_handler.py @@ -45,6 +45,7 @@ OutputMessage, ReasoningPart, Role, + SystemInstructionPart, TextPart, ToolCallRequestPart, ToolDefinition, @@ -428,7 +429,7 @@ def new_span( ) tool_definitions = _tool_definitions(instance) system_prompt = instance.system_prompt - system_instruction: list[MessagePart] = ( + system_instruction: list[SystemInstructionPart] = ( [TextPart(content=system_prompt)] if capture_content and system_prompt else [] 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 b6f232cdc..2656c31e4 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 @@ -213,7 +213,7 @@ def _interactions_input_to_messages( ) parts.append(part) elif item_type is not None: - part = GenericPart(type=item_type, value=type(item).__name__) + part = GenericPart(type=item_type) parts.append(part) return [InputMessage(role=Role.USER.value, parts=parts)] 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 cd7f3dd02..aabdf2336 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 @@ -10,10 +10,12 @@ from opentelemetry.util.genai.types import ( BlobPart, FinishReason, + GenericPart, InputMessage, MessagePart, OutputMessage, Role, + SystemInstructionPart, TextPart, ToolCallRequestPart, ToolCallResponsePart, @@ -56,11 +58,15 @@ def content_to_output_message( def to_system_instructions( *, content: genai_types.Content, -) -> list[MessagePart]: - parts = ( - _to_part(part, idx) for idx, part in enumerate(content.parts or []) - ) - return [part for part in parts if part is not None] +) -> list[SystemInstructionPart]: + instructions: list[SystemInstructionPart] = [] + for idx, part in enumerate(content.parts or []): + msg_part = _to_part(part, idx) + if isinstance(msg_part, TextPart): + instructions.append(msg_part) + elif msg_part is not None: + instructions.append(GenericPart(type=msg_part.type)) + return instructions def _to_input_message( 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 new file mode 100644 index 000000000..577335959 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py @@ -0,0 +1,48 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +from google.genai import types as genai_types + +from opentelemetry.instrumentation.google_genai.message import ( + to_system_instructions, +) +from opentelemetry.util.genai.types import GenericPart, TextPart + + +class TestGoogleGenAiMessage(unittest.TestCase): + def test_to_system_instructions_text(self): + content = genai_types.Content( + parts=[genai_types.Part.from_text(text="Be concise")] + ) + instructions = to_system_instructions(content=content) + self.assertEqual(instructions, [TextPart(content="Be concise")]) + + def test_to_system_instructions_non_text_parts(self): + content = genai_types.Content( + parts=[ + genai_types.Part.from_text(text="Be concise"), + genai_types.Part.from_bytes( + data=b"image_bytes", mime_type="image/png" + ), + genai_types.Part.from_uri( + file_uri="gs://bucket/doc.pdf", + mime_type="application/pdf", + ), + ] + ) + instructions = to_system_instructions(content=content) + self.assertEqual( + instructions, + [ + TextPart(content="Be concise"), + GenericPart(type="blob"), + GenericPart(type="uri"), + ], + ) + + 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, []) 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 0587fc290..cc4a2c539 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -103,7 +103,6 @@ def test_input_to_messages_generic_fallback(self) -> None: self.assertEqual(len(messages[0].parts), 1) self.assertIsInstance(messages[0].parts[0], GenericPart) self.assertEqual(messages[0].parts[0].type, "some_unsupported_type") - self.assertEqual(messages[0].parts[0].value, "dict") def test_input_to_messages_single_non_sequence_step(self) -> None: step = {"type": "text", "text": "Hello single step"} diff --git a/util/opentelemetry-util-genai/.changelog/612.added b/util/opentelemetry-util-genai/.changelog/612.added new file mode 100644 index 000000000..27efd0908 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/612.added @@ -0,0 +1 @@ +Add SystemInstructionPart model, align GenericPart with semantic conventions by removing value, and update invocation types. diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py index 68583bf4f..9c80d2883 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_agent_invocation.py @@ -20,6 +20,7 @@ InputMessage, MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) from opentelemetry.util.genai.utils import ContentCapturingMode @@ -98,7 +99,10 @@ def __init__( self.input_messages: list[InputMessage] = [] self.output_messages: list[OutputMessage] = [] - self.system_instruction: list[MessagePart] = [] + self.system_instruction: ( + list[SystemInstructionPart] | list[MessagePart] + ) = [] + """System instructions for the agent. Passing ``MessagePart`` is deprecated; use ``SystemInstructionPart``.""" self.tool_definitions: list[ToolDefinition] | None = None self._start(self._get_start_attributes()) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_fetch_response_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_fetch_response_invocation.py index f9f8c49cd..318b3007c 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_fetch_response_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_fetch_response_invocation.py @@ -22,6 +22,7 @@ ErrorTypeResolver, MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) from opentelemetry.util.genai.utils import ContentCapturingMode @@ -114,7 +115,10 @@ def __init__( self.finish_reasons: list[str] | None = None self.stream_cursor: str | None = None self.output_messages: list[OutputMessage] = [] - self.system_instruction: list[MessagePart] = [] + self.system_instruction: ( + list[SystemInstructionPart] | list[MessagePart] + ) = [] + """System instructions for the model. Passing ``MessagePart`` is deprecated; use ``SystemInstructionPart``.""" self.tool_definitions: list[ToolDefinition] | None = None self._start(self._get_start_attributes()) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py index fdacd54a3..493dca910 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py @@ -23,6 +23,7 @@ InputMessage, MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) from opentelemetry.util.genai.utils import ( @@ -78,7 +79,10 @@ def __init__( self.input_messages: list[InputMessage] = [] self.output_messages: list[OutputMessage] = [] - self.system_instruction: list[MessagePart] = [] + self.system_instruction: ( + list[SystemInstructionPart] | list[MessagePart] + ) = [] + """System instructions for the model. Passing ``MessagePart`` is deprecated; use ``SystemInstructionPart``.""" self._response_model_name: str | None = None self.response_id: str | None = None self.finish_reasons: list[str] | None = None @@ -273,7 +277,9 @@ class LLMInvocation: request_model: str | None = None input_messages: list[InputMessage] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] output_messages: list[OutputMessage] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] - system_instruction: list[MessagePart] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] + system_instruction: ( # pyright: ignore[reportUnknownVariableType] + list[SystemInstructionPart] | list[MessagePart] + ) = field(default_factory=list) provider: str | None = None response_model_name: str | None = None response_id: str | None = None diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py index ee0b242ae..380a6e33b 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -10,7 +10,7 @@ from contextvars import Token from dataclasses import asdict from types import TracebackType -from typing import TYPE_CHECKING, Any, TypeAlias +from typing import TYPE_CHECKING, Any, TypeAlias, cast from typing_extensions import Self @@ -33,6 +33,7 @@ InputMessage, MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) from opentelemetry.util.genai.utils import ( @@ -198,7 +199,9 @@ def _call_completion_hook( *, inputs: list[InputMessage] | None = None, outputs: list[OutputMessage] | None = None, - system_instruction: list[MessagePart] | None = None, + system_instruction: list[SystemInstructionPart] + | list[MessagePart] + | None = None, tool_definitions: list[ToolDefinition] | None = None, log_record: LogRecord | None = None, ) -> None: @@ -210,7 +213,9 @@ def _call_completion_hook( self._completion_hook.on_completion( inputs=inputs or [], outputs=outputs or [], - system_instruction=system_instruction or [], + system_instruction=cast( + "list[MessagePart]", system_instruction or [] + ), tool_definitions=tool_definitions, span=self.span, log_record=log_record, @@ -265,7 +270,7 @@ def get_content_attributes( *, input_messages: Sequence[InputMessage], output_messages: Sequence[OutputMessage], - system_instruction: Sequence[MessagePart], + system_instruction: Sequence[SystemInstructionPart | MessagePart], tool_definitions: Sequence[ToolDefinition] | None, for_span: bool, content_capturing_mode: ContentCapturingMode | None = None, @@ -275,7 +280,8 @@ def get_content_attributes( Args: input_messages: Input messages to serialize. output_messages: Output messages to serialize. - system_instruction: System instructions to serialize. + system_instruction: System instructions to serialize. Passing ``MessagePart`` + is deprecated; use ``SystemInstructionPart``. tool_definitions: Tool definitions to serialize (may be None). for_span: If True, serialize for span attributes (JSON string); if False, serialize for event attributes (list of dicts). diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_upload/completion_hook.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_upload/completion_hook.py index 9900cf13d..c7df68678 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_upload/completion_hook.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_upload/completion_hook.py @@ -11,7 +11,7 @@ import posixpath import threading from collections import OrderedDict -from collections.abc import Callable +from collections.abc import Callable, Sequence from concurrent.futures import ( Future, ThreadPoolExecutor, @@ -28,8 +28,14 @@ from opentelemetry._logs import LogRecord from opentelemetry.semconv._incubating.attributes import gen_ai_attributes from opentelemetry.trace import Span -from opentelemetry.util.genai import types from opentelemetry.util.genai.completion_hook import CompletionHook +from opentelemetry.util.genai.types import ( + InputMessage, + MessagePart, + OutputMessage, + TextPart, + ToolDefinition, +) from opentelemetry.util.genai.utils import gen_ai_json_dump GEN_AI_INPUT_MESSAGES_REF: Final = ( @@ -59,10 +65,10 @@ @dataclass class Completion: - inputs: list[types.InputMessage] | None - outputs: list[types.OutputMessage] | None - system_instruction: list[types.MessagePart] | None - tool_definitions: list[types.ToolDefinition] | None + inputs: list[InputMessage] | None + outputs: list[OutputMessage] | None + system_instruction: list[MessagePart] | None + tool_definitions: list[ToolDefinition] | None @dataclass @@ -80,15 +86,15 @@ class CompletionRefs: def is_message_part_list_hashable( - message_parts: list[types.MessagePart] | None, + message_parts: Sequence[MessagePart] | None, ) -> bool: return bool(message_parts) and all( - isinstance(x, types.TextPart) for x in message_parts + isinstance(x, TextPart) for x in message_parts ) def hash_tool_definitions( - tool_definitions: list[types.ToolDefinition] | None, + tool_definitions: list[ToolDefinition] | None, ) -> str | None: if not tool_definitions: return None @@ -218,8 +224,8 @@ def done(future: Future[None]) -> None: def _calculate_ref_path( self, - system_instruction: list[types.MessagePart], - tool_definitions: list[types.ToolDefinition] | None = None, + system_instruction: Sequence[MessagePart], + tool_definitions: list[ToolDefinition] | None = None, ) -> CompletionRefs: # TODO: experimental with using the trace_id and span_id, or fetching # gen_ai.response.id from the active span. @@ -227,9 +233,11 @@ def _calculate_ref_path( if is_message_part_list_hashable(system_instruction): # Get a hash of the text. system_instruction_hash = hashlib.sha256( - "\n".join(x.content for x in system_instruction).encode( # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownArgumentType, reportCallIssue, reportArgumentType] - "utf-8" - ), + "\n".join( + x.content + for x in system_instruction + if isinstance(x, TextPart) + ).encode("utf-8"), usedforsecurity=False, ).hexdigest() @@ -298,10 +306,10 @@ def _do_upload( def on_completion( self, *, - inputs: list[types.InputMessage], - outputs: list[types.OutputMessage], - system_instruction: list[types.MessagePart], - tool_definitions: list[types.ToolDefinition] | None = None, + inputs: list[InputMessage], + outputs: list[OutputMessage], + system_instruction: list[MessagePart], + tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, **kwargs: Any, @@ -321,10 +329,10 @@ def on_completion( ) def to_dict( - dataclass_list: list[types.InputMessage] - | list[types.OutputMessage] - | list[types.MessagePart] - | list[types.ToolDefinition], + dataclass_list: list[InputMessage] + | list[OutputMessage] + | list[MessagePart] + | list[ToolDefinition], ) -> JsonEncodeable: return [asdict(dc) for dc in dataclass_list] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/completion_hook.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/completion_hook.py index 83c34164b..bec759d29 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/completion_hook.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/completion_hook.py @@ -21,10 +21,15 @@ from opentelemetry.util._importlib_metadata import ( entry_points, # pyright: ignore[reportUnknownVariableType] ) -from opentelemetry.util.genai import types from opentelemetry.util.genai.environment_variables import ( OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK, ) +from opentelemetry.util.genai.types import ( + InputMessage, + MessagePart, + OutputMessage, + ToolDefinition, +) _logger = logging.getLogger(__name__) @@ -68,10 +73,10 @@ class CompletionHook(Protocol): def on_completion( self, *, - inputs: list[types.InputMessage], - outputs: list[types.OutputMessage], - system_instruction: list[types.MessagePart], - tool_definitions: list[types.ToolDefinition] | None = None, + inputs: list[InputMessage], + outputs: list[OutputMessage], + system_instruction: list[MessagePart], + tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, ) -> None: ... diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index b14846b2c..d041923cd 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -36,17 +36,14 @@ class ContentCapturingMode(Enum): @dataclass() class GenericPart: - """Used for provider-specific message part types that don't match - the standard MessagePart types defined in semantic conventions. Set ``type`` - to the provider-specific type discriminator and carry the payload in - ``value`` to explicitly opt-in to non-standard types. - This will be removed in a future version when all instrumentations use core types. + """Represents an arbitrary message part with any type and properties. + This allows for extensibility with custom message part types. - Per the semconv message schema, ``type`` is a free-form string (the - provider's own type name), not a fixed literal.""" + This model is specified as part of semconv in `GenAI messages Python models - GenericPart + `__. + """ type: str - value: Any @dataclass() @@ -308,6 +305,9 @@ class Role(str, Enum): TOOL = "tool" +SystemInstructionPart = Union[TextPart, GenericPart] + + @dataclass() class InputMessage: role: str diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 2670b0fb5..13b382f35 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -42,12 +42,14 @@ ContentCapturingMode, File, FilePart, + GenericPart, InputMessage, MessagePart, OutputMessage, Reasoning, ReasoningPart, Role, + SystemInstructionPart, Text, TextPart, Uri, @@ -403,6 +405,33 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use ) self.assertEqual(span_system[0]["type"], "text") + @patch.dict( + os.environ, + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY", + "OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT": "false", + }, + ) + def test_system_instruction_generic_part_on_span(self): + handler = TelemetryHandler(tracer_provider=self.tracer_provider) + with handler.inference("test-provider") as invocation: + invocation.system_instruction = [ + TextPart(content="You are helpful"), + GenericPart(type="custom"), + ] + + span = _get_single_span(self.span_exporter) + span_attrs = _get_span_attributes(span) + self.assertIn(GenAI.GEN_AI_SYSTEM_INSTRUCTIONS, span_attrs) + instructions = json.loads(span_attrs[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS]) + self.assertEqual( + instructions, + [ + {"type": "text", "content": "You are helpful"}, + {"type": "custom"}, + ], + ) + @patch.dict( os.environ, { @@ -1113,6 +1142,20 @@ def test_messages_with_name(self): serialized_out = json.loads(gen_ai_json_dumps(asdict(out_msg))) self.assertEqual(serialized_out["name"], "bot") + def test_system_instruction_part(self): + text_part: SystemInstructionPart = TextPart(content="You are helpful") + generic_part: SystemInstructionPart = GenericPart(type="custom") + self.assertEqual(text_part.content, "You are helpful") + self.assertEqual(generic_part.type, "custom") + self.assertEqual( + json.loads(gen_ai_json_dumps(asdict(text_part))), + {"type": "text", "content": "You are helpful"}, + ) + self.assertEqual( + json.loads(gen_ai_json_dumps(asdict(generic_part))), + {"type": "custom"}, + ) + _REAL_PNG_BYTES = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00"