From 32d75dc48fbc8dd4a52be9faedb747eab60b0f69 Mon Sep 17 00:00:00 2001 From: bhumikadangayach Date: Fri, 19 Jun 2026 12:22:39 +0530 Subject: [PATCH 1/2] fix(genai-utils): use AttributeValue instead of Any for attributes/metric_attributes Replaces typing.Any with opentelemetry.util.types.AttributeValue for attributes and metric_attributes dicts across invocation types, since these values must serialize to OTel span/metric attributes. Scoped to the span/metric attribute path per discussion in #41. Follow-up needed for: dataclass fields in types.py that hold nested/ structured payloads (ToolCallRequest.arguments, ServerToolCall, etc.), and metrics/logs usages not yet audited. Fixes #41 (partial) --- .../util/genai/_agent_invocation.py | 20 +++++++++---------- .../util/genai/_embedding_invocation.py | 8 ++++---- .../util/genai/_inference_invocation.py | 16 +++++++-------- .../opentelemetry/util/genai/_invocation.py | 15 +++++++------- .../util/genai/_tool_invocation.py | 10 +++++----- .../util/genai/_workflow_invocation.py | 12 +++++------ 6 files changed, 41 insertions(+), 40 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 eebb05c5..a6ad71e6 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 @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Any +from opentelemetry.util.types import AttributeValue from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( @@ -101,7 +101,7 @@ def __init__( self._start(self._get_base_attributes()) - def _get_base_attributes(self) -> dict[str, Any]: + def _get_base_attributes(self) -> dict[str, AttributeValue]: """Return sampling-relevant attributes available at span creation time.""" optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), @@ -115,7 +115,7 @@ def _get_base_attributes(self) -> dict[str, Any]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_common_attributes(self) -> dict[str, Any]: + def _get_common_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), (server_attributes.SERVER_ADDRESS, self.server_address), @@ -131,7 +131,7 @@ def _get_common_attributes(self) -> dict[str, Any]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_request_attributes(self) -> dict[str, Any]: + def _get_request_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_CONVERSATION_ID, self.conversation_id), (GenAI.GEN_AI_DATA_SOURCE_ID, self.data_source_id), @@ -147,12 +147,12 @@ def _get_request_attributes(self) -> dict[str, Any]: ) return {k: v for k, v in optional_attrs if v is not None} - def _get_response_attributes(self) -> dict[str, Any]: + def _get_response_attributes(self) -> dict[str, AttributeValue]: if self.finish_reasons: return {GenAI.GEN_AI_RESPONSE_FINISH_REASONS: self.finish_reasons} return {} - def _get_usage_attributes(self) -> dict[str, Any]: + def _get_usage_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_USAGE_INPUT_TOKENS, self.input_tokens), (GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, self.output_tokens), @@ -167,7 +167,7 @@ def _get_usage_attributes(self) -> dict[str, Any]: ) return {k: v for k, v in optional_attrs if v is not None} - def _get_content_attributes_for_span(self) -> dict[str, Any]: + def _get_content_attributes_for_span(self) -> dict[str, AttributeValue]: return get_content_attributes( input_messages=self.input_messages, output_messages=self.output_messages, @@ -176,14 +176,14 @@ def _get_content_attributes_for_span(self) -> dict[str, Any]: for_span=True, ) - def _get_metric_attributes(self) -> dict[str, Any]: + def _get_metric_attributes(self) ->dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_PROVIDER_NAME, self.provider), (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), (server_attributes.SERVER_ADDRESS, self.server_address), (server_attributes.SERVER_PORT, self.server_port), ) - attrs: dict[str, Any] = { + attrs: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, **{k: v for k, v in optional_attrs if v is not None}, } @@ -204,7 +204,7 @@ def _apply_finish(self, error: Error | None = None) -> None: if error is not None: self._apply_error_attributes(error) - attributes: dict[str, Any] = {} + attributes: dict[str, AttributeValue] = {} attributes.update(self._get_common_attributes()) attributes.update(self._get_request_attributes()) attributes.update(self._get_response_attributes()) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py index eedd2fcd..e9467d25 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Any + from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( @@ -60,7 +60,7 @@ def __init__( self.response_model_name: str | None = None self._start(self._get_base_attributes()) - def _get_base_attributes(self) -> dict[str, Any]: + def _get_base_attributes(self) -> dict[str, AttributeValue]: """Return sampling-relevant attributes available at span creation time.""" optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), @@ -73,7 +73,7 @@ def _get_base_attributes(self) -> dict[str, Any]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_metric_attributes(self) -> dict[str, Any]: + def _get_metric_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_PROVIDER_NAME, self.provider), (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), @@ -104,7 +104,7 @@ def _apply_finish(self, error: Error | None = None) -> None: (GenAI.GEN_AI_RESPONSE_MODEL, self.response_model_name), (GenAI.GEN_AI_USAGE_INPUT_TOKENS, self.input_tokens), ) - attributes: dict[str, Any] = { + attributes: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, **{ key: value 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 751403e7..0ca640fb 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 @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any from opentelemetry._logs import Logger, LogRecord from opentelemetry.semconv._incubating.attributes import ( @@ -28,6 +27,7 @@ from opentelemetry.util.genai.utils import ( should_emit_event, ) +from opentelemetry.util.types import AttributeValue # TODO: Migrate to GenAI constants once available in semconv package _GEN_AI_REASONING_OUTPUT_TOKENS = "gen_ai.usage.reasoning.output_tokens" @@ -97,7 +97,7 @@ def __init__( self.output_type: str | None = None self._start(self._get_base_attributes()) - def _get_message_attributes(self, *, for_span: bool) -> dict[str, Any]: + def _get_message_attributes(self, *, for_span: bool) -> dict[str, AttributeValue]: return get_content_attributes( input_messages=self.input_messages, output_messages=self.output_messages, @@ -118,7 +118,7 @@ def _get_finish_reasons(self) -> list[str] | None: return reasons or None return None - def _get_base_attributes(self) -> dict[str, Any]: + def _get_base_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), (GenAI.GEN_AI_PROVIDER_NAME, self.provider), @@ -130,7 +130,7 @@ def _get_base_attributes(self) -> dict[str, Any]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_attributes(self) -> dict[str, Any]: + def _get_attributes(self) -> dict[str, AttributeValue]: attrs = self._get_base_attributes() if self.output_tokens is None and self.thinking_tokens is None: output_tokens = None @@ -170,7 +170,7 @@ def _get_attributes(self) -> dict[str, Any]: attrs.update({k: v for k, v in optional_attrs if v is not None}) return attrs - def _get_metric_attributes(self) -> dict[str, Any]: + def _get_metric_attributes(self) -> dict[str, AttributeValue]: attrs = self._get_base_attributes() if self.response_model_name is not None: attrs[GenAI.GEN_AI_RESPONSE_MODEL] = self.response_model_name @@ -243,9 +243,9 @@ class LLMInvocation: finish_reasons: list[str] | None = None input_tokens: int | None = None output_tokens: int | None = None - attributes: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + attributes: dict[str, AttributeValue] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] """Additional attributes to set on spans and/or events. Not set on metrics.""" - metric_attributes: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + metric_attributes: dict[str, AttributeValue] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] """Additional attributes to set on metrics. Must be low cardinality. Not set on spans or events.""" temperature: float | None = None top_p: float | None = None @@ -331,4 +331,4 @@ def span(self) -> Span: self._inference_invocation.span if self._inference_invocation is not None else INVALID_SPAN - ) + ) \ No newline at end of file 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 4b1d7e66..51313ccb 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -10,6 +10,7 @@ from dataclasses import asdict from types import TracebackType from typing import TYPE_CHECKING, Any, Sequence, TypeAlias +from opentelemetry.util.types import AttributeValue from opentelemetry._logs import Logger, LogRecord from opentelemetry.context import Context, attach, detach @@ -61,19 +62,19 @@ def __init__( operation_name: str, span_name: str, span_kind: SpanKind = SpanKind.CLIENT, - attributes: dict[str, Any] | None = None, - metric_attributes: dict[str, Any] | None = None, + attributes: dict[str, AttributeValue] | None = None, + metric_attributes: dict[str, AttributeValue] | None = None, ) -> None: self._tracer = tracer self._metrics_recorder = metrics_recorder self._logger = logger self._completion_hook = completion_hook self._operation_name: str = operation_name - self.attributes: dict[str, Any] = ( + self.attributes: dict[str, AttributeValue] = ( {} if attributes is None else attributes ) """Additional attributes to set on spans and/or events. Not set on metrics.""" - self.metric_attributes: dict[str, Any] = ( + self.metric_attributes: dict[str, AttributeValue] = ( {} if metric_attributes is None else metric_attributes ) """Additional attributes to set on metrics. Must be low cardinality. Not set on spans or events.""" @@ -84,7 +85,7 @@ def __init__( self._context_token: ContextToken | None = None self._monotonic_start_s: float | None = None - def _start(self, attributes: dict[str, Any] | None = None) -> None: + def _start(self, attributes: dict[str, AttributeValue] | None = None) -> None: """Start the invocation span and attach it to the current context. Args: @@ -99,7 +100,7 @@ def _start(self, attributes: dict[str, Any] | None = None) -> None: self._monotonic_start_s = timeit.default_timer() self._context_token = attach(self._span_context) - def _get_metric_attributes(self) -> dict[str, Any]: + def _get_metric_attributes(self) -> dict[str, AttributeValue]: """Return low-cardinality attributes for metric recording.""" return self.metric_attributes @@ -186,7 +187,7 @@ def get_content_attributes( system_instruction: Sequence[MessagePart], tool_definitions: Sequence[ToolDefinition] | None, for_span: bool, -) -> dict[str, Any]: +) -> dict[str, AttributeValue]: """Serialize messages, system instructions, and tool definitions into attributes. Args: diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py index 8563685f..df2d296b 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import Any + from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( @@ -74,7 +74,7 @@ def __init__( self.tool_description = tool_description self._start(self._get_base_attributes()) - def _get_base_attributes(self) -> dict[str, Any]: + def _get_base_attributes(self) -> dict[str, AttributeValue]: """Return sampling-relevant attributes available at span creation time.""" optional_attrs = ( (GenAI.GEN_AI_TOOL_NAME, self.name), @@ -87,8 +87,8 @@ def _get_base_attributes(self) -> dict[str, Any]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_metric_attributes(self) -> dict[str, Any]: - attrs: dict[str, Any] = { + def _get_metric_attributes(self) -> dict[str, AttributeValue]: + attrs: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, } attrs.update(self.metric_attributes) @@ -115,7 +115,7 @@ def _apply_finish(self, error: Error | None = None) -> None: else None, ), ) - attributes: dict[str, Any] = { + attributes: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, **{k: v for k, v in optional_attrs if v is not None}, } 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 e7d6ff7e..01293ba1 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 @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import asdict -from typing import Any from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( @@ -22,6 +21,7 @@ gen_ai_json_dumps, should_capture_content_on_spans, ) +from opentelemetry.util.types import AttributeValue class WorkflowInvocation(GenAIInvocation): @@ -57,14 +57,14 @@ def __init__( self.output_messages: list[OutputMessage] = [] self._start(self._get_base_attributes()) - def _get_base_attributes(self) -> dict[str, Any]: + def _get_base_attributes(self) -> dict[str, AttributeValue]: """Return sampling-relevant attributes available at span creation time.""" - attrs: dict[str, Any] = { + attrs: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, } return attrs - def _get_messages_for_span(self) -> dict[str, Any]: + def _get_messages_for_span(self) -> dict[str, AttributeValue]: if not should_capture_content_on_spans(): return {} optional_attrs = ( @@ -86,7 +86,7 @@ def _get_messages_for_span(self) -> dict[str, Any]: } def _apply_finish(self, error: Error | None = None) -> None: - attributes: dict[str, Any] = { + attributes: dict[str, AttributeValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name } attributes.update(self._get_messages_for_span()) @@ -98,4 +98,4 @@ def _apply_finish(self, error: Error | None = None) -> None: inputs=self.input_messages, outputs=self.output_messages, ) - # TODO: Add workflow metrics when supported + # TODO: Add workflow metrics when supported \ No newline at end of file From c133de9002202b9528c4a8556052413515b98de1 Mon Sep 17 00:00:00 2001 From: bhumikadangayach Date: Fri, 19 Jun 2026 12:51:22 +0530 Subject: [PATCH 2/2] fix: address Copilot review feedback on PR #153 - Revert get_content_attributes return type to dict[str, Any] in _invocation.py since it returns list[dict] for event attributes when for_span=False - Fix import grouping in _invocation.py and _agent_invocation.py - Minor formatting fixes (arrow spacing, trailing comma) --- .../src/opentelemetry/util/genai/_agent_invocation.py | 5 ++--- .../src/opentelemetry/util/genai/_embedding_invocation.py | 2 -- .../src/opentelemetry/util/genai/_invocation.py | 4 ++-- .../src/opentelemetry/util/genai/_tool_invocation.py | 2 -- .../src/opentelemetry/util/genai/_workflow_invocation.py | 2 +- 5 files changed, 5 insertions(+), 10 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 a6ad71e6..a92d7a71 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 @@ -3,8 +3,6 @@ from __future__ import annotations -from opentelemetry.util.types import AttributeValue - from opentelemetry._logs import Logger from opentelemetry.semconv._incubating.attributes import ( gen_ai_attributes as GenAI, @@ -24,6 +22,7 @@ OutputMessage, ToolDefinition, ) +from opentelemetry.util.types import AttributeValue class AgentInvocation(GenAIInvocation): @@ -176,7 +175,7 @@ def _get_content_attributes_for_span(self) -> dict[str, AttributeValue]: for_span=True, ) - def _get_metric_attributes(self) ->dict[str, AttributeValue]: + def _get_metric_attributes(self) -> dict[str, AttributeValue]: optional_attrs = ( (GenAI.GEN_AI_PROVIDER_NAME, self.provider), (GenAI.GEN_AI_REQUEST_MODEL, self.request_model), diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py index e9467d25..95c9aa6f 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_embedding_invocation.py @@ -3,8 +3,6 @@ from __future__ import annotations - - 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/_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py index 51313ccb..b03b129c 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,6 @@ from dataclasses import asdict from types import TracebackType from typing import TYPE_CHECKING, Any, Sequence, TypeAlias -from opentelemetry.util.types import AttributeValue from opentelemetry._logs import Logger, LogRecord from opentelemetry.context import Context, attach, detach @@ -34,6 +33,7 @@ gen_ai_json_dumps, get_content_capturing_mode, ) +from opentelemetry.util.types import AttributeValue if TYPE_CHECKING: from opentelemetry.util.genai.metrics import InvocationMetricsRecorder @@ -187,7 +187,7 @@ def get_content_attributes( system_instruction: Sequence[MessagePart], tool_definitions: Sequence[ToolDefinition] | None, for_span: bool, -) -> dict[str, AttributeValue]: +) -> dict[str, Any]: """Serialize messages, system instructions, and tool definitions into attributes. Args: diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py index df2d296b..c25aef8a 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_tool_invocation.py @@ -3,8 +3,6 @@ from __future__ import annotations - - 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/_workflow_invocation.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_workflow_invocation.py index 01293ba1..dd657858 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 @@ -87,7 +87,7 @@ def _get_messages_for_span(self) -> dict[str, AttributeValue]: def _apply_finish(self, error: Error | None = None) -> None: attributes: dict[str, AttributeValue] = { - GenAI.GEN_AI_OPERATION_NAME: self._operation_name + GenAI.GEN_AI_OPERATION_NAME: self._operation_name, } attributes.update(self._get_messages_for_span()) if error is not None: