From fbca49f31bd4b2842b2623c54bc450a832d6ee27 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Thu, 3 Sep 2026 18:25:26 -0700 Subject: [PATCH 1/6] Add SystemInstructionPart and support in invocations and completion hooks --- .../util/genai/_agent_invocation.py | 5 +- .../util/genai/_inference_invocation.py | 9 ++- .../opentelemetry/util/genai/_invocation.py | 7 ++- .../util/genai/_upload/completion_hook.py | 57 +++++++++++-------- .../util/genai/_workflow_invocation.py | 2 - .../util/genai/completion_hook.py | 16 ++++-- .../src/opentelemetry/util/genai/types.py | 3 + .../tests/test_utils.py | 11 ++++ 8 files changed, 75 insertions(+), 35 deletions(-) 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..81a4f1bf0 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,9 @@ def __init__( self.input_messages: list[InputMessage] = [] self.output_messages: list[OutputMessage] = [] - self.system_instruction: list[MessagePart] = [] + self.system_instruction: ( + list[SystemInstructionPart] | list[MessagePart] + ) = [] 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..fa173cfa7 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,9 @@ def __init__( self.input_messages: list[InputMessage] = [] self.output_messages: list[OutputMessage] = [] - self.system_instruction: list[MessagePart] = [] + self.system_instruction: ( + list[SystemInstructionPart] | list[MessagePart] + ) = [] self._response_model_name: str | None = None self.response_id: str | None = None self.finish_reasons: list[str] | None = None @@ -273,7 +276,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..34615a223 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -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: @@ -265,7 +268,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, 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..c0a279e70 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,13 +11,13 @@ 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, ) from contextlib import ExitStack -from dataclasses import asdict, dataclass +from dataclasses import dataclass from functools import partial from time import time from typing import Any, Final, Literal @@ -28,8 +28,16 @@ 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, + SystemInstructionPart, + TextPart, + ToolDefinition, +) from opentelemetry.util.genai.utils import gen_ai_json_dump GEN_AI_INPUT_MESSAGES_REF: Final = ( @@ -59,10 +67,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[SystemInstructionPart] | list[MessagePart] | None + tool_definitions: list[ToolDefinition] | None @dataclass @@ -80,15 +88,15 @@ class CompletionRefs: def is_message_part_list_hashable( - message_parts: list[types.MessagePart] | None, + message_parts: Sequence[SystemInstructionPart | 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 +226,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[SystemInstructionPart | 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 +235,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 +308,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[SystemInstructionPart] | list[MessagePart], + tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, **kwargs: Any, @@ -321,10 +331,11 @@ 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[SystemInstructionPart] + | list[MessagePart] + | list[ToolDefinition], ) -> JsonEncodeable: return [asdict(dc) for dc in dataclass_list] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py index 82fa633e6..c012d3e5e 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py @@ -3,8 +3,6 @@ from __future__ import annotations -from dataclasses import asdict - from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, 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..369f96241 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,16 @@ 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, + SystemInstructionPart, + ToolDefinition, +) _logger = logging.getLogger(__name__) @@ -68,10 +74,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[SystemInstructionPart] | 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..dbac73d61 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -308,6 +308,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..e0c74912a 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, @@ -1113,6 +1115,15 @@ 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", value={"k": "v"} + ) + self.assertEqual(text_part.content, "You are helpful") + self.assertEqual(generic_part.type, "custom") + self.assertEqual(generic_part.value, {"k": "v"}) + _REAL_PNG_BYTES = ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00" From d88dd4b3996d79d75c0d2b1769d8a8ad0ef271c5 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Thu, 3 Sep 2026 18:25:41 -0700 Subject: [PATCH 2/6] Add changelog fragment for #612 --- util/opentelemetry-util-genai/.changelog/612.added | 1 + 1 file changed, 1 insertion(+) create mode 100644 util/opentelemetry-util-genai/.changelog/612.added diff --git a/util/opentelemetry-util-genai/.changelog/612.added b/util/opentelemetry-util-genai/.changelog/612.added new file mode 100644 index 000000000..1100c68b4 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/612.added @@ -0,0 +1 @@ +Add SystemInstructionPart model and update invocation and completion hook signatures to support it. From a834c2964a73d4836dff991c26e60821a934f688 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Thu, 3 Sep 2026 20:52:13 -0700 Subject: [PATCH 3/6] Use SystemInstructionPart across instrumentations and keep LangChain system messages in input_messages --- .../genai/anthropic/messages_extractors.py | 15 +- .../examples/manual/custom_hook.py | 4 +- .../genai/bedrock/extractors.py | 25 +++- .../tests/test_converse.py | 21 +++ .../.changelog/511.fixed | 1 - .../genai/langchain/callback_handler.py | 15 +- .../instrumentation/genai/langchain/utils.py | 33 ----- .../tests/conformance/_shared.py | 21 --- .../tests/conformance/inference.py | 17 --- .../tests/conformance/inference_streaming.py | 21 +-- .../tests/test_llm_call.py | 131 +++++------------- .../examples/manual/custom_hook.py | 4 +- .../genai/llama_index/_handler.py | 3 +- .../examples/manual/custom_hook.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../instrumentation/google_genai/message.py | 12 +- .../util/genai/_agent_invocation.py | 1 + .../util/genai/_inference_invocation.py | 1 + .../opentelemetry/util/genai/_invocation.py | 3 +- .../util/genai/_workflow_invocation.py | 2 + .../util/genai/completion_hook.py | 4 +- 22 files changed, 130 insertions(+), 216 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/511.fixed delete mode 100644 instrumentation/opentelemetry-instrumentation-genai-langchain/tests/conformance/_shared.py 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..58231fb0f 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]: - if system is None: +) -> list[SystemInstructionPart]: + if not system: return [] - return convert_content_to_parts(system) + if isinstance(system, str): + return [TextPart(content=system)] + 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/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py index 480c120e7..c4da3047c 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py @@ -30,8 +30,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) @@ -48,7 +48,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[MessagePart], + system_instruction: list[SystemInstructionPart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, 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..ec32a2b62 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, @@ -256,6 +258,25 @@ 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)) + else: + text = item.get("text") + if isinstance(text, str) and text: + parts.append(TextPart(content=text)) + return parts + + def _extract_guardrail_id( params: dict[str, Any], invocation: InferenceInvocation ) -> None: @@ -324,7 +345,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 +554,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..2256fa95d 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 TextPart @pytest.mark.vcr @@ -596,3 +597,23 @@ 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"), + ] 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..80eba5a85 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,39 +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( 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..f7cfed4e4 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py @@ -12,7 +12,6 @@ FakeMessagesListChatModel, ) from langchain_core.messages import ( - AIMessage, AIMessageChunk, FunctionMessage, HumanMessage, @@ -28,7 +27,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 +37,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,77 +725,30 @@ 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" + 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_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"] - - -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"] - - -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"] + 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_preserves_name(): @@ -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/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py index 1833f5e3a..c8c93a4ee 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py @@ -33,8 +33,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) @@ -47,7 +47,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[MessagePart], + system_instruction: list[SystemInstructionPart], tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, 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-genai-openai/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py index 7be05858b..99177ae95 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py @@ -32,8 +32,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) @@ -50,7 +50,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[MessagePart], + system_instruction: list[SystemInstructionPart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py index cd0640c49..69045609e 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py @@ -36,8 +36,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) @@ -54,7 +54,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[MessagePart], + system_instruction: list[SystemInstructionPart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py index a1e9be93a..a1d201bcd 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py @@ -36,8 +36,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, - MessagePart, OutputMessage, + SystemInstructionPart, ToolDefinition, ) @@ -54,7 +54,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[MessagePart], + system_instruction: list[SystemInstructionPart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, 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..3f2758351 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/message.py @@ -14,6 +14,7 @@ MessagePart, OutputMessage, Role, + SystemInstructionPart, TextPart, ToolCallRequestPart, ToolCallResponsePart, @@ -56,11 +57,12 @@ 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]: + return [ + TextPart(content=part.text) + for part in content.parts or [] + if part.text is not None + ] def _to_input_message( 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 81a4f1bf0..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 @@ -102,6 +102,7 @@ def __init__( 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/_inference_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_inference_invocation.py index fa173cfa7..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 @@ -82,6 +82,7 @@ def __init__( 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 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 34615a223..3929bb63b 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -278,7 +278,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/_workflow_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py index c012d3e5e..82fa633e6 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py @@ -3,6 +3,8 @@ from __future__ import annotations +from dataclasses import asdict + from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, 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 369f96241..2bd7c2f88 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 @@ -62,8 +62,8 @@ class CompletionHook(Protocol): Args: inputs: The inputs of the GenAI interaction. outputs: The outputs of the GenAI interaction. - system_instruction: The system instruction of the GenAI - interaction. + system_instruction: The system instruction parts. Passing ``MessagePart`` + is deprecated; use ``SystemInstructionPart``. tool_definitions: The list of source system tool definitions available to the GenAI agent or model. span: The span associated with the GenAI interaction. From d15e2573db3dbc01e9d6f54f09da8676edee1bf7 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Thu, 3 Sep 2026 21:43:02 -0700 Subject: [PATCH 4/6] Address review feedback on system instruction parts and GenericPart - Align GenericPart with semconv by removing the value field - Extract non-text Bedrock system content blocks as GenericPart - Check system is None in Anthropic message extractors - Retain list[MessagePart] in CompletionHook signature - Add tests for GenericPart serialization and system instruction span attributes --- .../genai/anthropic/messages_extractors.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../genai/bedrock/extractors.py | 22 ++++++++--- .../tests/test_converse.py | 26 ++++++++++++- .../instrumentation/genai/langchain/utils.py | 2 - .../tests/test_llm_call.py | 6 +-- .../examples/manual/custom_hook.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../examples/manual/custom_hook.py | 4 +- .../google_genai/interactions.py | 2 +- .../tests/interactions/test_parser.py | 1 - .../.changelog/612.added | 2 +- .../opentelemetry/util/genai/_invocation.py | 6 ++- .../util/genai/_upload/completion_hook.py | 13 +++---- .../util/genai/completion_hook.py | 7 ++-- .../src/opentelemetry/util/genai/types.py | 13 +++---- .../tests/test_utils.py | 39 +++++++++++++++++-- 18 files changed, 111 insertions(+), 52 deletions(-) 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 58231fb0f..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 @@ -127,10 +127,10 @@ def get_input_messages( def get_system_instruction( system: str | Iterable[TextBlockParam] | None, ) -> list[SystemInstructionPart]: - if not system: + if system is None: return [] if isinstance(system, str): - return [TextPart(content=system)] + return [TextPart(content=system)] if system else [] return [ TextPart(content=block["text"]) for block in system diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py index c4da3047c..480c120e7 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/examples/manual/custom_hook.py @@ -30,8 +30,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, + MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -48,7 +48,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, 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 ec32a2b62..8425346ce 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 @@ -237,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 @@ -258,6 +258,18 @@ def _extract_parts(content: Any) -> list[MessagePart]: return parts +def _extract_system_content_block( + block: Mapping[str, Any], +) -> SystemInstructionPart | None: + text = block.get("text") + if isinstance(text, str) and text: + return TextPart(content=text) + for key in block: + if key != "text": + return GenericPart(type=key) + return None + + def _extract_system_parts( content: str | Sequence[Mapping[str, Any] | str] | None, ) -> list[SystemInstructionPart]: @@ -270,10 +282,10 @@ def _extract_system_parts( if isinstance(item, str): if item: parts.append(TextPart(content=item)) - else: - text = item.get("text") - if isinstance(text, str) and text: - parts.append(TextPart(content=text)) + elif _is_dict(item): + part = _extract_system_content_block(item) + if part is not None: + parts.append(part) return parts diff --git a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py index 2256fa95d..0450fa270 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py +++ b/instrumentation/opentelemetry-instrumentation-genai-bedrock/tests/test_converse.py @@ -27,7 +27,7 @@ ) from opentelemetry.trace import StatusCode from opentelemetry.util.genai.handler import TelemetryHandler -from opentelemetry.util.genai.types import TextPart +from opentelemetry.util.genai.types import GenericPart, TextPart @pytest.mark.vcr @@ -617,3 +617,27 @@ def test_extract_converse_request_system_instruction(tracer_provider) -> None: 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/src/opentelemetry/instrumentation/genai/langchain/utils.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/src/opentelemetry/instrumentation/genai/langchain/utils.py index 80eba5a85..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,8 +367,6 @@ def to_input_messages( return result - - def to_output_messages( messages: Iterable[BaseMessage], *, 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 f7cfed4e4..2be21d95b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_llm_call.py @@ -12,6 +12,7 @@ FakeMessagesListChatModel, ) from langchain_core.messages import ( + AIMessage, AIMessageChunk, FunctionMessage, HumanMessage, @@ -751,14 +752,13 @@ def test_system_message_handles_system_message_chunk(): assert result[1].role == "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" diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py index c8c93a4ee..1833f5e3a 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/examples/manual/custom_hook.py @@ -33,8 +33,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, + MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -47,7 +47,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py index 99177ae95..7be05858b 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai/examples/manual/custom_hook.py @@ -32,8 +32,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, + MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -50,7 +50,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py index 69045609e..cd0640c49 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-portkey/examples/manual/custom_hook.py @@ -36,8 +36,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, + MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -54,7 +54,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py index a1d201bcd..a1e9be93a 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/examples/manual/custom_hook.py @@ -36,8 +36,8 @@ from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, + MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -54,7 +54,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span=None, log_record=None, 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/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 index 1100c68b4..27efd0908 100644 --- a/util/opentelemetry-util-genai/.changelog/612.added +++ b/util/opentelemetry-util-genai/.changelog/612.added @@ -1 +1 @@ -Add SystemInstructionPart model and update invocation and completion hook signatures to support it. +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/_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py index 3929bb63b..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 @@ -213,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, 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 c0a279e70..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 @@ -17,7 +17,7 @@ ThreadPoolExecutor, ) from contextlib import ExitStack -from dataclasses import dataclass +from dataclasses import asdict, dataclass from functools import partial from time import time from typing import Any, Final, Literal @@ -28,13 +28,11 @@ from opentelemetry._logs import LogRecord from opentelemetry.semconv._incubating.attributes import gen_ai_attributes from opentelemetry.trace import Span - from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.types import ( InputMessage, MessagePart, OutputMessage, - SystemInstructionPart, TextPart, ToolDefinition, ) @@ -69,7 +67,7 @@ class Completion: inputs: list[InputMessage] | None outputs: list[OutputMessage] | None - system_instruction: list[SystemInstructionPart] | list[MessagePart] | None + system_instruction: list[MessagePart] | None tool_definitions: list[ToolDefinition] | None @@ -88,7 +86,7 @@ class CompletionRefs: def is_message_part_list_hashable( - message_parts: Sequence[SystemInstructionPart | MessagePart] | None, + message_parts: Sequence[MessagePart] | None, ) -> bool: return bool(message_parts) and all( isinstance(x, TextPart) for x in message_parts @@ -226,7 +224,7 @@ def done(future: Future[None]) -> None: def _calculate_ref_path( self, - system_instruction: Sequence[SystemInstructionPart | MessagePart], + system_instruction: Sequence[MessagePart], tool_definitions: list[ToolDefinition] | None = None, ) -> CompletionRefs: # TODO: experimental with using the trace_id and span_id, or fetching @@ -310,7 +308,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart] | list[MessagePart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | None = None, @@ -333,7 +331,6 @@ def on_completion( def to_dict( dataclass_list: list[InputMessage] | list[OutputMessage] - | list[SystemInstructionPart] | list[MessagePart] | list[ToolDefinition], ) -> JsonEncodeable: 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 2bd7c2f88..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 @@ -28,7 +28,6 @@ InputMessage, MessagePart, OutputMessage, - SystemInstructionPart, ToolDefinition, ) @@ -62,8 +61,8 @@ class CompletionHook(Protocol): Args: inputs: The inputs of the GenAI interaction. outputs: The outputs of the GenAI interaction. - system_instruction: The system instruction parts. Passing ``MessagePart`` - is deprecated; use ``SystemInstructionPart``. + system_instruction: The system instruction of the GenAI + interaction. tool_definitions: The list of source system tool definitions available to the GenAI agent or model. span: The span associated with the GenAI interaction. @@ -76,7 +75,7 @@ def on_completion( *, inputs: list[InputMessage], outputs: list[OutputMessage], - system_instruction: list[SystemInstructionPart] | list[MessagePart], + system_instruction: list[MessagePart], tool_definitions: list[ToolDefinition] | None = None, span: Span | None = None, log_record: LogRecord | 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 dbac73d61..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() diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index e0c74912a..4c8fb3fdd 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -405,6 +405,32 @@ 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): + with self.telemetry_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, { @@ -1117,12 +1143,17 @@ def test_messages_with_name(self): def test_system_instruction_part(self): text_part: SystemInstructionPart = TextPart(content="You are helpful") - generic_part: SystemInstructionPart = GenericPart( - type="custom", value={"k": "v"} - ) + generic_part: SystemInstructionPart = GenericPart(type="custom") self.assertEqual(text_part.content, "You are helpful") self.assertEqual(generic_part.type, "custom") - self.assertEqual(generic_part.value, {"k": "v"}) + 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 = ( From 5d3904c60cc77f2795be91712b12144f2da956a8 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Thu, 3 Sep 2026 22:35:07 -0700 Subject: [PATCH 5/6] Support non-text system instructions in google-genai and update FetchResponseInvocation --- .../instrumentation/google_genai/message.py | 14 ++++-- .../tests/generate_content/test_message.py | 48 +++++++++++++++++++ .../util/genai/_fetch_response_invocation.py | 6 ++- 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/test_message.py 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 3f2758351..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,6 +10,7 @@ from opentelemetry.util.genai.types import ( BlobPart, FinishReason, + GenericPart, InputMessage, MessagePart, OutputMessage, @@ -58,11 +59,14 @@ def to_system_instructions( *, content: genai_types.Content, ) -> list[SystemInstructionPart]: - return [ - TextPart(content=part.text) - for part in content.parts or [] - if part.text is not None - ] + 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/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()) From 420b5777b78b61d0e0dfb54877cdedabe429d541 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 4 Sep 2026 14:06:26 -0700 Subject: [PATCH 6/6] Inline Bedrock system content block extractor and fix test handler --- .../genai/bedrock/extractors.py | 23 +++++++------------ .../tests/test_utils.py | 3 ++- 2 files changed, 10 insertions(+), 16 deletions(-) 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 8425346ce..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 @@ -258,18 +258,6 @@ def _extract_parts(content: Any) -> list[MessagePart]: return parts -def _extract_system_content_block( - block: Mapping[str, Any], -) -> SystemInstructionPart | None: - text = block.get("text") - if isinstance(text, str) and text: - return TextPart(content=text) - for key in block: - if key != "text": - return GenericPart(type=key) - return None - - def _extract_system_parts( content: str | Sequence[Mapping[str, Any] | str] | None, ) -> list[SystemInstructionPart]: @@ -283,9 +271,14 @@ def _extract_system_parts( if item: parts.append(TextPart(content=item)) elif _is_dict(item): - part = _extract_system_content_block(item) - if part is not None: - parts.append(part) + 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 diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 4c8fb3fdd..13b382f35 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -413,7 +413,8 @@ def test_llm_start_and_stop_creates_span(self): # pylint: disable=no-self-use }, ) def test_system_instruction_generic_part_on_span(self): - with self.telemetry_handler.inference("test-provider") as invocation: + 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"),