From 4c6cd92847ddb9f5ff52ac4d185d3df75a397a0d Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 17 Jul 2026 19:50:25 -0700 Subject: [PATCH 1/2] Align GenAI message dataclasses with semconv message schemas Add `name` to InputMessage/OutputMessage, add Compaction, SystemInstructionPart, MemoryRecord, and RetrievalDocument, extend Modality with `document`, and update FinishReason to the schema enum (`tool_calls` -> `tool_call`, add `compaction`). Message and tool-definition serialization now omits None-valued fields (new `asdict_without_none` helper) to match the semconv JSON schemas, on the span, event, and upload-hook paths. --- .../google_genai/interactions.py | 2 +- .../generate_content/nonstreaming_base.py | 17 ++--- .../tests/interactions/base.py | 2 +- .../tests/interactions/test_parser.py | 1 + .../.changelog/+message-schema-parity.changed | 1 + .../opentelemetry/util/genai/_invocation.py | 4 +- .../util/genai/_upload/completion_hook.py | 15 +++-- .../src/opentelemetry/util/genai/types.py | 63 +++++++++++++++++-- .../src/opentelemetry/util/genai/utils.py | 14 +++++ .../tests/test_upload.py | 2 +- 10 files changed, 91 insertions(+), 30 deletions(-) create mode 100644 util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed 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 a35368e3..b3614a00 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 @@ -205,7 +205,7 @@ def _interactions_input_to_messages( ) parts.append(part) elif item_type is not None: - part = GenericPart(value=type(item).__name__) + part = GenericPart(type=item_type, value=type(item).__name__) parts.append(part) return [InputMessage(role="user", parts=parts)] diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py index 36c6e925..6577ade1 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/generate_content/nonstreaming_base.py @@ -108,13 +108,11 @@ def setUp(self): # pylint: disable=invalid-name { "name": "_mock_callable_tool", "description": "Description of some tool.", - "parameters": None, "type": "function", }, { "name": "mock_tool", "description": "Description of mock tool.", - "parameters": None, "type": "function", }, { @@ -128,7 +126,6 @@ def setUp(self): # pylint: disable=invalid-name { "name": "mcp_tool", "description": "A standalone mcp tool", - "parameters": None, "type": "function", }, ), @@ -136,13 +133,11 @@ def setUp(self): # pylint: disable=invalid-name { "name": "mcp_tool", "description": "Tool from session", - "parameters": None, "type": "function", }, { "name": "mcp_tool", "description": "A standalone mcp tool", - "parameters": None, "type": "function", }, ), @@ -534,14 +529,14 @@ def test_span_attributes_no_content_capture(self): self.assertIn( span.attributes[GEN_AI_TOOL_DEFINITIONS], [ - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","parameters":null,"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":null,"type":"function"}]', - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":null,"type":"function"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","type":"function"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","type":"function"}]', ], ) else: self.assertEqual( span.attributes[GEN_AI_TOOL_DEFINITIONS], - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"}]', ) @patch.dict( @@ -593,14 +588,14 @@ def test_span_attributes_with_content_capture(self): self.assertIn( span.attributes[GEN_AI_TOOL_DEFINITIONS], [ - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]', - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"Tool from session","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"},{"name":"mcp_tool","description":"A standalone mcp tool","parameters":{"type":"object","properties":{"id":{"type":"integer"}}},"type":"function"}]', ], ) else: self.assertEqual( span.attributes[GEN_AI_TOOL_DEFINITIONS], - '[{"name":"_mock_callable_tool","description":"Description of some tool.","parameters":null,"type":"function"},{"name":"mock_tool","description":"Description of mock tool.","parameters":null,"type":"function"},{"name":"google_maps","type":"google_maps"}]', + '[{"name":"_mock_callable_tool","description":"Description of some tool.","type":"function"},{"name":"mock_tool","description":"Description of mock tool.","type":"function"},{"name":"google_maps","type":"google_maps"}]', ) def test_log_has_extra_genai_attributes(self): diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py index 87744180..19437f11 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/base.py @@ -382,7 +382,7 @@ def test_interaction_with_dict_tools_records_tool_definitions( span = self.otel.get_span_named("interactions.create gemini-2.5-flash") self.assertEqual( span.attributes["gen_ai.tool.definitions"], - '[{"name":"dict_tool","description":"Dict tool desc","parameters":null,"type":"function"}]', + '[{"name":"dict_tool","description":"Dict tool desc","type":"function"}]', ) def test_interaction_with_builtin_tools_records_definitions( 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 e80a8f82..1625a2ea 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/interactions/test_parser.py @@ -102,6 +102,7 @@ def test_input_to_messages_generic_fallback(self) -> None: messages = _interactions_input_to_messages(steps) 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: diff --git a/util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed b/util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed new file mode 100644 index 00000000..7a1061d5 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed @@ -0,0 +1 @@ +Align message dataclasses with the GenAI semconv schemas: add `name` to `InputMessage`/`OutputMessage`, add `Compaction`, `SystemInstructionPart`, `MemoryRecord`, and `RetrievalDocument`, extend `Modality` with `document`, and update `FinishReason` to the schema enum (`tool_calls` becomes `tool_call`, adds `compaction`). Message and tool-definition serialization now omits `None`-valued fields to match the semconv JSON schemas. 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 5fd921fc..d7885330 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -7,7 +7,6 @@ from abc import abstractmethod from contextlib import AbstractContextManager from contextvars import Token -from dataclasses import asdict from types import TracebackType from typing import TYPE_CHECKING, Any, Sequence, TypeAlias @@ -30,6 +29,7 @@ ) from opentelemetry.util.genai.utils import ( ContentCapturingMode, + asdict_without_none, gen_ai_json_dumps, get_content_capturing_mode, ) @@ -214,7 +214,7 @@ def get_content_attributes( ) def serialize(items: Sequence[Any]) -> Any: - dicts = [asdict(item) for item in items] + dicts = [asdict_without_none(item) for item in items] return gen_ai_json_dumps(dicts) if for_span else dicts # Tool definitions are always captured, the sem conv recommends adding params / description only 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 f4fd4958..6f53f985 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 @@ -4,7 +4,6 @@ from __future__ import annotations -import dataclasses import hashlib import json import logging @@ -16,7 +15,7 @@ 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, Callable, Final, Literal @@ -29,7 +28,10 @@ from opentelemetry.trace import Span from opentelemetry.util.genai import types from opentelemetry.util.genai.completion_hook import CompletionHook -from opentelemetry.util.genai.utils import gen_ai_json_dump +from opentelemetry.util.genai.utils import ( + asdict_without_none, + gen_ai_json_dump, +) GEN_AI_INPUT_MESSAGES_REF: Final = ( gen_ai_attributes.GEN_AI_INPUT_MESSAGES + "_ref" @@ -92,10 +94,7 @@ def hash_tool_definitions( if not tool_definitions: return None try: - tool_dicts = [ - {k: v for k, v in dataclasses.asdict(t).items() if v is not None} - for t in tool_definitions - ] + tool_dicts = [asdict_without_none(t) for t in tool_definitions] encoded_tools = json.dumps( tool_dicts, @@ -325,7 +324,7 @@ def to_dict( | list[types.MessagePart] | list[types.ToolDefinition], ) -> JsonEncodeable: - return [asdict(dc) for dc in dataclass_list] + return [asdict_without_none(dc) for dc in dataclass_list] references = [ (ref_name, ref, ref_attr, contents_hashed_to_filename) 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 6ec3266c..5a9e549c 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -30,12 +30,16 @@ 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. Wrap custom - types with GenericPart(value=...) to explicitly opt-in to non-standard types. - This will be removed in a future version when all instrumentations use core types.""" + 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. + Per the semconv message schema, ``type`` is a free-form string (the + provider's own type name), not a fixed literal.""" + + type: str value: Any - type: Literal["generic"] = "generic" @dataclass() @@ -127,7 +131,20 @@ class Reasoning: type: Literal["reasoning"] = "reasoning" -Modality = Literal["image", "video", "audio"] +@dataclass() +class Compaction: + """Represents a compaction/summary of prior conversation history. + + This model is specified as part of semconv in `GenAI messages Python models - CompactionPart + `__. + """ + + id: str | None + content: str | None + type: Literal["compaction"] = "compaction" + + +Modality = Literal["image", "video", "audio", "document"] @dataclass() @@ -202,12 +219,18 @@ class GenericToolDefinition: File, Uri, Reasoning, + Compaction, GenericPart, # For provider-specific types; prefer standard types above ] +# System instructions are a narrower set of parts than message content. +# Mirrors the `gen_ai.system_instructions` semconv schema (TextPart | GenericPart). +SystemInstructionPart = Union[Text, GenericPart] + + FinishReason = Literal[ - "content_filter", "error", "length", "stop", "tool_calls" + "content_filter", "error", "length", "stop", "tool_call", "compaction" ] @@ -215,6 +238,7 @@ class GenericToolDefinition: class InputMessage: role: str parts: list[MessagePart] + name: str | None = None @dataclass() @@ -222,6 +246,33 @@ class OutputMessage: role: str parts: list[MessagePart] finish_reason: str | FinishReason + name: str | None = None + + +@dataclass() +class MemoryRecord: + """Represents a single memory record exposed to or produced by the model. + + This model is specified as part of semconv in `GenAI messages Python models - MemoryRecord + `__. + """ + + content: Any + id: str | None = None + metadata: dict[str, Any] | None = None + score: float | None = None + + +@dataclass() +class RetrievalDocument: + """Represents a document returned by a retrieval operation. + + This model is specified as part of semconv in `GenAI messages Python models - RetrievalDocument + `__. + """ + + id: str + score: float @dataclass diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py index 7df35b28..2e0174c8 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py @@ -5,6 +5,7 @@ import logging import os from base64 import b64encode +from dataclasses import asdict from functools import partial from typing import Any @@ -85,6 +86,19 @@ def should_capture_content_on_spans() -> bool: ) +def asdict_without_none(obj: Any) -> dict[str, Any]: + """``dataclasses.asdict`` that omits keys whose value is ``None``. + + Matches the GenAI semconv message schemas, which exclude unset optional + fields rather than serializing them as ``null``. The ``dict_factory`` is + applied recursively by ``asdict``, so nested message parts are pruned too. + """ + return asdict( + obj, + dict_factory=lambda items: {k: v for k, v in items if v is not None}, + ) + + class _GenAiJsonEncoder(json.JSONEncoder): def default(self, o: Any) -> Any: if isinstance(o, bytes): diff --git a/util/opentelemetry-util-genai/tests/test_upload.py b/util/opentelemetry-util-genai/tests/test_upload.py index 10474645..409b43df 100644 --- a/util/opentelemetry-util-genai/tests/test_upload.py +++ b/util/opentelemetry-util-genai/tests/test_upload.py @@ -454,7 +454,7 @@ def test_upload_completions(self): ) self.assert_fsspec_equal( span.attributes["gen_ai.tool.definitions_ref"], - '[{"name":"test_tool","description":"does something","parameters":null,"type":"function"}]\n', + '[{"name":"test_tool","description":"does something","type":"function"}]\n', ) def test_stamps_empty_log(self): From ac138918e41b664555cc675a074b2fcb5ee4bc80 Mon Sep 17 00:00:00 2001 From: Liudmila Molkova Date: Fri, 17 Jul 2026 22:17:42 -0700 Subject: [PATCH 2/2] Rename changelog fragment, default Compaction fields, bridge oldest env - Rename changelog fragment to PR number - Default Compaction.id/content to None (optional and omitted) - Bridge google-genai oldest env to workspace util-genai (needs 1.1b0 None-omitting serialization) Assisted-by: Claude Opus 4.8 (1M context) --- .../tests/requirements.oldest.txt | 6 ++++++ .../{+message-schema-parity.changed => 284.changed} | 0 .../src/opentelemetry/util/genai/types.py | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) rename util/opentelemetry-util-genai/.changelog/{+message-schema-parity.changed => 284.changed} (100%) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 4c636820..18d5ffb4 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -21,5 +21,11 @@ # OpenTelemetry SDK and test utilities come transitively from opentelemetry-test-util-genai, which # every oldest env installs. Pin here only test-only deps that nothing else already provides. +# Exception: this change depends on the None-omitting message/tool serialization added in +# opentelemetry-util-genai 1.1b0, which is not on PyPI yet. Install it from the workspace so the +# oldest env exercises the required behavior. Remove this once 1.1b0 is released and bump the +# pyproject floor to >= 1.1b0. +-e ./util/opentelemetry-util-genai + google-auth==2.50.0 fsspec==2025.9.0 diff --git a/util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed b/util/opentelemetry-util-genai/.changelog/284.changed similarity index 100% rename from util/opentelemetry-util-genai/.changelog/+message-schema-parity.changed rename to util/opentelemetry-util-genai/.changelog/284.changed 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 5a9e549c..cd92fb0f 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -139,8 +139,8 @@ class Compaction: `__. """ - id: str | None - content: str | None + id: str | None = None + content: str | None = None type: Literal["compaction"] = "compaction"