diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-genai-agno/.changelog/821.changed new file mode 100644 index 000000000..43180e801 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/.changelog/821.changed @@ -0,0 +1 @@ +Record tool call arguments and dict results as compact JSON. Previously they included spaces, e.g. `{"a": 3, "b": 4}`, now `{"a":3,"b":4}`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-agno/pyproject.toml index 040e62cee..d4b0fc841 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/patch.py b/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/patch.py index a0f1d1caf..1b658dde5 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/patch.py @@ -65,7 +65,7 @@ Role, TextPart, ) -from opentelemetry.util.genai.utils import get_argument +from opentelemetry.util.genai.utils import get_argument, object_to_any_value logger = logging.getLogger(__name__) @@ -248,17 +248,17 @@ def _extract_input_content(input_val: Any) -> str: def _extract_output_content(result: Any) -> str: if result is None: return "" + return format_content(_extract_output_value(result)) + + +def _extract_output_value(result: Any) -> Any: content = _get_property_value(result, "content") if content is not None: - return format_content(content) + return content val = _get_property_value(result, "result") if val is not None: - return format_content(val) - return format_content(result) - - -def _extract_arguments_str(args_val: Any) -> str: - return format_content(args_val) + return val + return result def _set_tool_invocation_input( @@ -266,9 +266,7 @@ def _set_tool_invocation_input( instance: FunctionCall, ) -> None: if invocation.should_capture_content: - arguments = instance.arguments - if arguments is not None: - invocation.arguments = _extract_arguments_str(arguments) + invocation.arguments = object_to_any_value(instance.arguments) def _fail_tool_invocation( @@ -291,7 +289,9 @@ def _set_tool_invocation_output( if result.status == "failure": return if invocation.should_capture_content: - invocation.tool_result = _extract_output_content(result) + invocation.tool_result = object_to_any_value( + _extract_output_value(result) + ) def _set_invocation_input( diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/utils.py b/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/utils.py index 611423b1e..9f37f5d43 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/utils.py +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/src/opentelemetry/instrumentation/genai/agno/utils.py @@ -5,10 +5,8 @@ from __future__ import annotations -import dataclasses -import json from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: from agno.knowledge.document.base import Document @@ -18,6 +16,10 @@ RetrievalDocument, ToolDefinition, ) +from opentelemetry.util.genai.utils import ( + gen_ai_json_dumps, + object_to_any_value, +) def format_retrieval_document(doc: Document) -> RetrievalDocument: @@ -34,94 +36,14 @@ def format_retrieval_document(doc: Document) -> RetrievalDocument: ) -@runtime_checkable -class _ModelDumpJson(Protocol): - def model_dump_json(self) -> str: ... - - -@runtime_checkable -class _JsonDump(Protocol): - def json(self) -> str: ... - - -@runtime_checkable -class _ModelDump(Protocol): - def model_dump(self) -> Any: ... - - -@runtime_checkable -class _DictDump(Protocol): - def dict(self) -> Any: ... - - -def _json_default(obj: object) -> object: - if isinstance(obj, type): - return str(obj) - if isinstance(obj, _ModelDump): - try: - return obj.model_dump() - except Exception: - pass - if isinstance(obj, _DictDump): - try: - return obj.dict() - except Exception: - pass - if dataclasses.is_dataclass(obj) and not isinstance(obj, type): - try: - return dataclasses.asdict(obj) - except Exception: - pass - return str(obj) - - def format_content(val: object) -> str: """Format content into a string, converting structured objects to JSON.""" - if val is None: - return "" if isinstance(val, str): return val - if isinstance(val, type): + any_value = object_to_any_value(val) + if any_value is None: return str(val) - if isinstance(val, _ModelDumpJson): - try: - return str(val.model_dump_json()) - except Exception: - pass - if isinstance(val, _JsonDump): - try: - return str(val.json()) - except Exception: - pass - if isinstance(val, _ModelDump): - try: - return json.dumps( - val.model_dump(), ensure_ascii=False, default=_json_default - ) - except Exception: - pass - if isinstance(val, _DictDump): - try: - return json.dumps( - val.dict(), ensure_ascii=False, default=_json_default - ) - except Exception: - pass - if dataclasses.is_dataclass(val) and not isinstance(val, type): - try: - return json.dumps( - dataclasses.asdict(val), - ensure_ascii=False, - default=_json_default, - ) - except Exception: - pass - if isinstance(val, (dict, list)): - try: - return json.dumps(val, ensure_ascii=False, default=_json_default) - except Exception: - pass - return str(cast(object, val)) + return gen_ai_json_dumps(any_value, ensure_ascii=False) def _get_property_value(obj: Any, property_name: str) -> Any: diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt index f0e0323ff..71c42e1d9 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt @@ -22,3 +22,6 @@ # every oldest env installs. Pin here only test-only deps that nothing else already provides. # agno workflow dependencies fastapi >= 0.100.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_agent.py index 8e38154b1..cbe5fac4e 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_agent.py +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_agent.py @@ -663,7 +663,6 @@ class MyDataClass: item: str assert format_content("plain text") == "plain text" - assert format_content(None) == "" assert format_content(42) == "42" assert json.loads(format_content(MyModel(name="test", count=5))) == { "name": "test", @@ -735,8 +734,6 @@ def test_set_tool_invocation_output_structured_result( tracer_provider, monkeypatch, ) -> None: - import json - from pydantic import BaseModel from opentelemetry.instrumentation.genai.agno.patch import ( @@ -758,7 +755,7 @@ class ToolOutput(BaseModel): ToolOutput(status="ok", code=200), ) invocation.stop() - assert json.loads(invocation.tool_result) == { + assert invocation.tool_result == { "status": "ok", "code": 200, } diff --git a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_tools.py b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_tools.py index 683c696f1..cd1bed1c5 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_tools.py +++ b/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_tools.py @@ -322,9 +322,10 @@ def multiply(a: int, b: int) -> int: span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ID) == "call_sync_1" ) - assert span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"a": 3, "b": 4}) + assert ( + span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"a":3,"b":4}' + ) assert span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "12" @@ -365,9 +366,10 @@ async def _test() -> None: span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ID) == "call_async_1" ) - assert span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"a": 3, "b": 4}) + assert ( + span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"a":3,"b":4}' + ) assert span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "12" @@ -720,9 +722,10 @@ def streaming_tool(prefix: str) -> Iterator[str]: assert [s.get_span_context().span_id for s in inside] == [ tool_span.context.span_id ] * 2 - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "chunk"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"chunk"}' + ) assert ( tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "chunk_1chunk_2" @@ -768,9 +771,10 @@ async def _test() -> None: assert [s.get_span_context().span_id for s in inside] == [ tool_span.context.span_id ] * 2 - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "chunk"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"chunk"}' + ) assert ( tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "chunk_1chunk_2" @@ -797,9 +801,10 @@ def stream_dicts(count: int): spans = span_exporter.get_finished_spans() assert len(spans) == 1 span = spans[0] - assert span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"count": 2}) + assert ( + span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"count":2}' + ) assert json.loads( span.attributes[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] ) == [{"index": 0}, {"index": 1}] @@ -830,9 +835,10 @@ async def _test() -> None: spans = span_exporter.get_finished_spans() assert len(spans) == 1 span = spans[0] - assert span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"count": 2}) + assert ( + span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"count":2}' + ) assert json.loads( span.attributes[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] ) == [{"index": 0}, {"index": 1}] @@ -875,9 +881,10 @@ def failing_streaming_tool(prefix: str) -> Iterator[str]: tool_span = spans[0] assert tool_span.status.status_code == StatusCode.ERROR assert tool_span.attributes.get("error.type") == "ValueError" - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "part"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"part"}' + ) assert [s.get_span_context().span_id for s in inside] == [ tool_span.context.span_id ] * 2 @@ -923,9 +930,10 @@ async def _test() -> None: tool_span = spans[0] assert tool_span.status.status_code == StatusCode.ERROR assert tool_span.attributes.get("error.type") == "ValueError" - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "part"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"part"}' + ) assert [s.get_span_context().span_id for s in inside] == [ tool_span.context.span_id ] * 2 @@ -981,9 +989,10 @@ def streaming_tool(prefix: str) -> Iterator[str]: assert len(spans) == 1 tool_span = spans[0] assert tool_span.status.status_code != StatusCode.ERROR - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "part"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"part"}' + ) assert ( tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "part_1" @@ -1049,9 +1058,10 @@ async def _test() -> None: assert len(spans) == 1 tool_span = spans[0] assert tool_span.status.status_code != StatusCode.ERROR - assert tool_span.attributes.get( - GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS - ) == json.dumps({"prefix": "part"}) + assert ( + tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS) + == '{"prefix":"part"}' + ) assert ( tool_span.attributes.get(GenAIAttributes.GEN_AI_TOOL_CALL_RESULT) == "part_1" diff --git a/instrumentation/opentelemetry-instrumentation-genai-dspy/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-genai-dspy/.changelog/821.changed new file mode 100644 index 000000000..be43c096c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-dspy/.changelog/821.changed @@ -0,0 +1 @@ +Record non-string tool call results as JSON strings, and convert objects through `model_dump` or `__dict__`. Previously primitives were recorded as-is and objects through `str()`, e.g. a result of `7` was recorded as `7`, now `"7"`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-dspy/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-dspy/pyproject.toml index 9db1860a7..db6f51f08 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-dspy/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-dspy/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", "wrapt >= 1.17.0, < 3.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-genai-dspy/src/opentelemetry/instrumentation/genai/dspy/patch.py b/instrumentation/opentelemetry-instrumentation-genai-dspy/src/opentelemetry/instrumentation/genai/dspy/patch.py index 39a3f2ac7..6baea00fd 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-dspy/src/opentelemetry/instrumentation/genai/dspy/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-dspy/src/opentelemetry/instrumentation/genai/dspy/patch.py @@ -39,7 +39,10 @@ RetrievalDocument, TextPart, ) -from opentelemetry.util.genai.utils import bind_arguments +from opentelemetry.util.genai.utils import ( + bind_arguments, + object_to_any_value, +) _REACT_MODULE = "dspy.predict.react" _REACT_CLASS = "ReAct" @@ -348,7 +351,9 @@ def _start_tool_invocation( if tool_desc is not None: invocation.tool_description = str(tool_desc) if handler.should_capture_content(): - invocation.arguments = _extract_tool_arguments(instance, args, kwargs) + invocation.arguments = object_to_any_value( + _extract_tool_arguments(instance, args, kwargs) + ) return invocation @@ -366,7 +371,7 @@ def traced_method( with invocation: result = wrapped(*args, **kwargs) if handler.should_capture_content(): - invocation.tool_result = result + invocation.tool_result = object_to_any_value(result) return result return traced_method @@ -386,7 +391,7 @@ async def traced_method( with invocation: result = await wrapped(*args, **kwargs) if handler.should_capture_content(): - invocation.tool_result = result + invocation.tool_result = object_to_any_value(result) return result return traced_method diff --git a/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/requirements.oldest.txt index a7b6a29dd..0719d2007 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/requirements.oldest.txt @@ -4,3 +4,6 @@ # The oldest tox factor resolves declared dependencies from pyproject.toml # with UV_RESOLUTION=lowest-direct. Pin here only test-only deps. numpy >= 1.26.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/test_tools.py b/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/test_tools.py index 64315b005..8ed0ee59d 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/test_tools.py +++ b/instrumentation/opentelemetry-instrumentation-genai-dspy/tests/test_tools.py @@ -79,7 +79,7 @@ def test_sync_tool_execution( args_attr = attrs.get(GenAI.GEN_AI_TOOL_CALL_ARGUMENTS) assert args_attr is not None assert json.loads(str(args_attr)) == {"x": 3, "y": 4} - assert attrs.get(GenAI.GEN_AI_TOOL_CALL_RESULT) == 7 + assert attrs.get(GenAI.GEN_AI_TOOL_CALL_RESULT) == "7" def test_sync_tool_positional_and_mixed_args_extraction( @@ -169,7 +169,7 @@ async def test_async_tool_execution( args_attr = attrs.get(GenAI.GEN_AI_TOOL_CALL_ARGUMENTS) assert args_attr is not None assert json.loads(str(args_attr)) == {"a": 5, "b": 6} - assert attrs.get(GenAI.GEN_AI_TOOL_CALL_RESULT) == 30 + assert attrs.get(GenAI.GEN_AI_TOOL_CALL_RESULT) == "30" def test_sync_tool_error( diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/821.fixed b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/821.fixed new file mode 100644 index 000000000..1dc7a4057 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/821.fixed @@ -0,0 +1 @@ +Record the tool call result when a tool is invoked without a tool call id. Previously the result was dropped because LangChain passes the raw return value instead of a `ToolMessage`, e.g. `weather.invoke({"city": "Paris"})` returning `{"temp": 20}` recorded no result, now `{"temp":20}`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-langchain/pyproject.toml index 9955207e7..5312f9539 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dependencies = [ "opentelemetry-instrumentation >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] 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 66319ed52..585038511 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 @@ -3,7 +3,6 @@ from __future__ import annotations -import json import math from collections.abc import Mapping, Sequence from typing import Any, cast @@ -58,6 +57,10 @@ RetrievalDocument, Role, ) +from opentelemetry.util.genai.utils import ( + object_to_any_value, + tool_arguments_to_any_value, +) SUPPORTED_RAPI_RESPONSE_HEADERS = ("x-ms-served-model",) @@ -708,14 +711,6 @@ def on_tool_start( name = serialized.get("name") or "unknown" description = serialized.get("description") - arguments: Any - if inputs is not None: - arguments = inputs - else: - try: - arguments = json.loads(input_str) - except (json.JSONDecodeError, ValueError): - arguments = input_str agent_name, _ = self._find_agent_context(parent_run_id) parent_context = self._invocation_manager.get_parent_context( parent_run_id @@ -729,7 +724,10 @@ def on_tool_start( _attach_to_context=self._attach_to_context, ) tool_invocation.tool_description = description - tool_invocation.arguments = arguments + if tool_invocation.should_capture_content: + tool_invocation.arguments = tool_arguments_to_any_value( + inputs if inputs is not None else input_str + ) tool_call_id = kwargs.get("tool_call_id") if tool_call_id: tool_invocation.tool_call_id = tool_call_id @@ -751,7 +749,10 @@ def on_tool_end( end_tool_call_id = getattr(output, "tool_call_id", None) if end_tool_call_id and not tool_invocation.tool_call_id: tool_invocation.tool_call_id = end_tool_call_id - tool_invocation.tool_result = getattr(output, "content", None) + if tool_invocation.should_capture_content: + tool_invocation.tool_result = object_to_any_value( + getattr(output, "content", output) + ) tool_invocation.stop() self._invocation_manager.delete_invocation_state(run_id=run_id) diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt index db47a8396..dcd04c9d3 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt @@ -25,3 +25,6 @@ langchain-aws==0.2.2 langchain-google-genai==2.0.0 langchain-anthropic==0.3.0 boto3==1.37.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_tools.py b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_tools.py index 9e24bb23e..d1fb8f9b0 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_tools.py +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_tools.py @@ -1174,6 +1174,56 @@ def test_on_tool_end_captures_result_with_span_only_mode(monkeypatch): assert attrs[gen_ai_attributes.GEN_AI_TOOL_CALL_RESULT] == "result text" +@pytest.mark.parametrize( + "tool_input, expected_result", + [ + # Without a tool_call_id LangChain passes the raw return value. + ({"city": "Paris"}, '{"temp":20}'), + # With one, it wraps the result in a ToolMessage. + ( + { + "name": "weather", + "args": {"city": "Paris"}, + "id": "call_1", + "type": "tool_call", + }, + '{"temp": 20}', + ), + ], +) +def test_tool_result_captured_with_and_without_tool_call_id( + monkeypatch, tool_input: dict[str, Any], expected_result: str +): + monkeypatch.setenv( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_ONLY" + ) + tracer_provider, span_exporter, logger_provider, meter_provider = ( + _make_providers() + ) + instrumentor = LangChainInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + try: + + @tool + def weather(city: str) -> dict[str, int]: + """Get the weather.""" + return {"temp": 20} + + weather.invoke(tool_input) + + (span,) = span_exporter.get_finished_spans() + assert ( + span.attributes[gen_ai_attributes.GEN_AI_TOOL_CALL_RESULT] + == expected_result + ) + finally: + instrumentor.uninstrument() + + # --------------------------------------------------------------------------- # on_tool_end attribute types # --------------------------------------------------------------------------- diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-genai-llama-index/.changelog/821.changed new file mode 100644 index 000000000..0d92cdca2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/.changelog/821.changed @@ -0,0 +1 @@ +Record non-string tool call results as JSON strings, and convert objects through `model_dump` or `__dict__`. Previously primitives were recorded as-is and objects through `str()`, e.g. a result of `8` was recorded as `8`, now `"8"`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-llama-index/pyproject.toml index bae15f881..40a1675c5 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] 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 8d6e41581..06c4408d8 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 @@ -71,6 +71,8 @@ ToolDefinition, UriPart, ) +from opentelemetry.util.genai.utils import object_to_any_value +from opentelemetry.util.types import AnyValue _logger = logging.getLogger(__name__) @@ -533,7 +535,7 @@ def _set_workflow_output(invocation: WorkflowInvocation, result: Any) -> None: def _tool_arguments( tool: FunctionTool, bound_args: inspect.BoundArguments -) -> dict[str, Any]: +) -> AnyValue: """Bind tool arguments to user-facing parameter names. LlamaIndex exposes positional values under ``args`` and may inject a @@ -559,7 +561,7 @@ def _tool_arguments( arguments = {"args": list(args), **kwargs} if tool.ctx_param_name: arguments.pop(tool.ctx_param_name, None) - return arguments + return object_to_any_value(arguments) class _LlamaIndexInvocation(BaseSpan): @@ -1028,8 +1030,8 @@ def new_span( tool_invocation.tool_call_id = tool_call.tool_id tool_invocation.tool_description = tool_description if tool_invocation.should_capture_content: - tool_invocation.arguments = cast( - dict[str, Any], cast(Any, tool_call).tool_kwargs + tool_invocation.arguments = object_to_any_value( + cast(Any, tool_call).tool_kwargs ) invocation = tool_invocation if parent is not None and isinstance( @@ -1218,7 +1220,9 @@ def prepare_to_exit_span( tool_output, ) if span._invocation.should_capture_content: - span._invocation.tool_result = tool_output.raw_output + span._invocation.tool_result = object_to_any_value( + tool_output.raw_output + ) if tool_output.is_error: # LlamaIndex reports failures such as unknown tools without an # exception, so provide one to record error telemetry: diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt index 7ec6c7fe9..5600531fa 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt @@ -19,3 +19,6 @@ # ``UV_RESOLUTION=lowest-direct`` on the oldest tox factor. Pin test-only # dependencies here only when they are not already provided elsewhere. llama-index-llms-openai==0.7.10 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_agent.py index 7ed2f7192..aaba813a2 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_agent.py +++ b/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_agent.py @@ -1079,7 +1079,6 @@ def response_generator(messages, **kwargs): workflow = AgentWorkflow(agents=[agent]) result = await workflow.run(user_msg="Call both tools") - assert result.response.content == "SECOND" agent_span = _spans_named( span_exporter, "invoke_agent arrival-order-agent" @@ -1087,7 +1086,10 @@ def response_generator(messages, **kwargs): agent_output = json.loads( agent_span.attributes[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] ) - assert agent_output[0]["parts"] == [{"type": "text", "content": "SECOND"}] + # Which return_direct result LlamaIndex picks depends on task scheduling. + assert agent_output[0]["parts"] == [ + {"type": "text", "content": result.response.content} + ] @pytest.mark.asyncio @@ -1387,7 +1389,7 @@ def multiply(value: int) -> int: assert json.loads(attrs[GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS]) == { "value": 4 } - assert attrs[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] == 8 + assert attrs[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] == "8" @pytest.mark.asyncio diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/821.changed new file mode 100644 index 000000000..21705b40b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/821.changed @@ -0,0 +1 @@ +Record tool call arguments as compact JSON for any valid JSON input and non-string results as JSON. Previously only JSON objects were normalized and results went through `str()`, e.g. arguments `[1, 2]` were recorded as `[1, 2]`, now `[1,2]`; `null` arguments are no longer recorded. diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/pyproject.toml index 2e8a89c20..e36fcc143 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/processor.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/processor.py index aa68d4dcf..50e5e1b25 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/processor.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/processor.py @@ -32,9 +32,7 @@ from __future__ import annotations -import json import weakref -from collections.abc import Mapping from typing import Any from agents.tracing import Span, Trace, TracingProcessor @@ -53,7 +51,10 @@ WorkflowInvocation, ) from opentelemetry.util.genai.types import Error -from opentelemetry.util.types import AnyValue +from opentelemetry.util.genai.utils import ( + object_to_any_value, + tool_arguments_to_any_value, +) # Non-semconv attribute: surfaces the workflow name on the workflow span # so callers can query/filter by it. util-genai's WorkflowInvocation @@ -61,14 +62,6 @@ _WORKFLOW_NAME_ATTR = "gen_ai.workflow.name" -def _tool_arguments(raw: str) -> AnyValue: - try: - parsed: AnyValue = json.loads(raw) - except ValueError: - return raw - return parsed if isinstance(parsed, Mapping) else raw - - class GenAITracingProcessor(TracingProcessor): """Translate agents-library tracing into util-genai invocations. @@ -140,12 +133,8 @@ def on_span_end(self, span: Span[Any]) -> None: ): arguments = span.span_data.input if arguments: - invocation.arguments = _tool_arguments(arguments) - output = span.span_data.output - if output is not None: - invocation.tool_result = ( - output if isinstance(output, str) else str(output) - ) + invocation.arguments = tool_arguments_to_any_value(arguments) + invocation.tool_result = object_to_any_value(span.span_data.output) # SpanError is a mapping, not a raised exception, so it never # reaches util-genai's exception path on its own. span_error = getattr(span, "error", None) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt index 106af7e25..b26083ee0 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt @@ -27,3 +27,6 @@ httpx==0.27.2 Deprecated==1.2.14 importlib-metadata==6.11.0 packaging==24.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_processor.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_processor.py index 5f1363428..d012d5948 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_processor.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_processor.py @@ -272,12 +272,12 @@ def test_tool_content_captured_from_span_end( # value as the compact JSON the OpenAI APIs forward. ('{"city": "Barcelona"}', '{"city":"Barcelona"}'), ('{"city":"Barcelona"}', '{"city":"Barcelona"}'), - # A provider may emit something that isn't valid JSON, or valid JSON - # that isn't an object; neither may change the attribute's type. + # Invalid JSON is kept as a string; any valid JSON round-trips. ("city=Barcelona", "city=Barcelona"), ("[1,2]", "[1,2]"), + ("[1, 2]", "[1,2]"), ("42", "42"), - ("null", "null"), + ("null", None), # No arguments to record: `trace_include_sensitive_data=False` leaves # `input` None, and tool types without arguments leave it empty. (None, None), diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/.gitignore b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/.gitignore new file mode 100644 index 000000000..f935021a8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/821.changed new file mode 100644 index 000000000..f362dd37b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/.changelog/821.changed @@ -0,0 +1 @@ +Record tool call arguments as compact JSON and non-string results as JSON. Previously arguments were recorded as given and results through `str()`, e.g. a result of `{"temp": 20}` was recorded as `{'temp': 20}`, now `{"temp":20}`. diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/pyproject.toml index 2dcd21b8c..51f9becdb 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", ] [project.optional-dependencies] diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/patch.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/patch.py index 32e3c2604..12be3e3d0 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/patch.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/src/opentelemetry/instrumentation/genai/qwen_agent/patch.py @@ -29,7 +29,12 @@ from opentelemetry.util.genai.handler import TelemetryHandler from opentelemetry.util.genai.invocation import LocalAgentInvocation from opentelemetry.util.genai.stream import SyncStreamWrapper -from opentelemetry.util.genai.utils import bind_arguments, get_argument +from opentelemetry.util.genai.utils import ( + bind_arguments, + get_argument, + object_to_any_value, + tool_arguments_to_any_value, +) class _AgentRunStreamWrapper(SyncStreamWrapper[Any]): @@ -104,8 +109,8 @@ def wrap_agent_call_tool( messages_arg: Any = kwargs.get("messages") or bound.get("messages") invocation.tool_call_id = find_tool_call_id(messages_arg, tool_name) invocation.tool_description = getattr(tool, "description", None) - if invocation.should_capture_content and tool_args is not None: - invocation.arguments = tool_args + if invocation.should_capture_content: + invocation.arguments = tool_arguments_to_any_value(tool_args) try: result = wrapped(*args, **kwargs) @@ -113,9 +118,7 @@ def wrap_agent_call_tool( invocation.fail(error) raise - if invocation.should_capture_content and result is not None: - invocation.tool_result = ( - result if isinstance(result, str) else str(result) - ) + if invocation.should_capture_content: + invocation.tool_result = object_to_any_value(result) invocation.stop() return result diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/requirements.oldest.txt index 3fda1cd74..edf8c66e8 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/requirements.oldest.txt @@ -32,3 +32,6 @@ tqdm==4.66.1 # Exercise the oldest supported wrapt major (1.17 is the floor inherited # from opentelemetry-util-genai). wrapt==1.17.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_tool.py b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_tool.py index 7951e23ee..650a90ee0 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_tool.py +++ b/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_tool.py @@ -78,7 +78,7 @@ def test_call_tool_span_attributes(span_exporter, instrument_with_content): ) assert ( attrs[GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS] - == '{"city": "Beijing"}' + == '{"city":"Beijing"}' ) assert ( attrs[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] == "Sunny, 25 degrees" diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/821.changed b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/821.changed new file mode 100644 index 000000000..3504f8e71 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/821.changed @@ -0,0 +1 @@ +Record string tool call results as-is and drop only the arguments that cannot be converted. Previously string results were JSON-quoted, e.g. `done` was recorded as `"done"`, and one unconvertible argument dropped all arguments. diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/pyproject.toml b/instrumentation/opentelemetry-instrumentation-google-genai/pyproject.toml index 704a5a8f4..df0ead0fa 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-google-genai/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "opentelemetry-api ~= 1.43", "opentelemetry-instrumentation >= 0.64b0, <1", "opentelemetry-semantic-conventions >= 0.64b0, <1", - "opentelemetry-util-genai >= 1.2b0, <2", + "opentelemetry-util-genai >= 1.3b0.dev, <2", "wrapt >= 1.17.0, < 3.0.0", ] diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py index 136ff08d7..2bc4a3316 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/tool_call_wrapper.py @@ -3,11 +3,7 @@ import functools import inspect -import json -import logging from collections.abc import Callable -from copy import deepcopy -from dataclasses import asdict, is_dataclass from typing import Any from google.genai.types import ( @@ -17,70 +13,12 @@ ) from opentelemetry.util.genai.handler import TelemetryHandler -from opentelemetry.util.genai.utils import bind_arguments -from opentelemetry.util.types import AnyValue +from opentelemetry.util.genai.utils import ( + bind_arguments, + object_to_any_value, +) ToolFunction = Callable[..., Any] -_logger = logging.getLogger(__name__) - - -def _is_primitive(value): - return isinstance(value, (str, int, bool, float)) - - -def _to_otel_value(python_value): - """Coerces parameters to something representable with Open Telemetry.""" - if python_value is None or _is_primitive(python_value): - return python_value - if isinstance(python_value, list): - return [_to_otel_value(x) for x in python_value] - if isinstance(python_value, dict): - return { - key: _to_otel_value(val) for (key, val) in python_value.items() - } - if hasattr(python_value, "model_dump"): - return python_value.model_dump() - if hasattr(python_value, "__dict__"): - return _to_otel_value(python_value.__dict__) - return repr(python_value) - - -def _snapshot_tool_arguments( - tool_function: ToolFunction, - args: tuple[object, ...], - kwargs: dict[str, object], -) -> dict[str, AnyValue] | None: - # ToolInvocation serializes at span end, after the tool may mutate its inputs. - try: - bound = deepcopy( - bind_arguments(tool_function, args, kwargs, apply_defaults=False) - ) - return { - name: _normalize_tool_argument(value) - for name, value in bound.items() - } - except Exception: - _logger.warning("Failed to snapshot tool arguments", exc_info=True) - return None - - -def _normalize_tool_argument(value: object) -> AnyValue: - if value is None or isinstance(value, (str, int, bool, float, bytes)): - return value - if isinstance(value, (list, tuple)): - return [_normalize_tool_argument(item) for item in value] - if isinstance(value, dict): - return { - key: _normalize_tool_argument(item) for key, item in value.items() - } - model_dump = getattr(value, "model_dump", None) - if callable(model_dump): - return _normalize_tool_argument(model_dump()) - if is_dataclass(value) and not isinstance(value, type): - return _normalize_tool_argument(asdict(value)) - if hasattr(value, "__dict__"): - return _normalize_tool_argument(value.__dict__) - return _to_otel_value(value) def _wrap_tool_function( @@ -97,16 +35,14 @@ async def async_wrapped_function( tool_function.__name__, ) as tool_invocation: tool_invocation.tool_description = tool_function.__doc__ - # Do this before calling the tool in case that crashes. + # Snapshot before the call: the tool may mutate its inputs. if tool_invocation.should_capture_content: - tool_invocation.arguments = _snapshot_tool_arguments( - tool_function, args, kwargs + tool_invocation.arguments = object_to_any_value( + bind_arguments(tool_function, args, kwargs) ) result = await tool_function(*args, **kwargs) if tool_invocation.should_capture_content: - tool_invocation.tool_result = json.dumps( - _to_otel_value(result) - ) + tool_invocation.tool_result = object_to_any_value(result) return result return async_wrapped_function @@ -118,16 +54,14 @@ def wrapped_function(*args: object, **kwargs: object) -> Any: tool_function.__name__, ) as tool_invocation: tool_invocation.tool_description = tool_function.__doc__ - # Do this before calling the tool in case that crashes. + # Snapshot before the call: the tool may mutate its inputs. if tool_invocation.should_capture_content: - tool_invocation.arguments = _snapshot_tool_arguments( - tool_function, args, kwargs + tool_invocation.arguments = object_to_any_value( + bind_arguments(tool_function, args, kwargs) ) result = tool_function(*args, **kwargs) if tool_invocation.should_capture_content: - tool_invocation.tool_result = json.dumps( - _to_otel_value(result) - ) + tool_invocation.tool_result = object_to_any_value(result) return result return wrapped_function diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt index 34517fcdc..8c53e241d 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt @@ -24,3 +24,6 @@ fsspec==2025.9.0 + +# Drop this once opentelemetry-util-genai 1.3b0 is published. +-e util/opentelemetry-util-genai diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py index f194d1b46..e436d9b6a 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/utils/test_tool_call_wrapper.py @@ -504,17 +504,11 @@ def tool(payload: dict[str, list[str]]) -> str: } -@pytest.mark.parametrize("failure", ["copy", "normalization"]) @pytest.mark.asyncio -async def test_argument_snapshot_failure_does_not_prevent_tool_execution( - invoke_tool, span_exporter, caplog, failure: str +async def test_unconvertible_argument_dropped( + invoke_tool, span_exporter ) -> None: class Unserializable: - def __deepcopy__(self, memo: dict[int, object]) -> Unserializable: - if failure == "copy": - raise RuntimeError("cannot copy") - return self - def model_dump(self) -> dict[str, object]: raise RuntimeError("cannot normalize") @@ -527,9 +521,9 @@ def tool(value: Unserializable) -> str: assert await invoke_tool(tool, argument) == "done" (span,) = span_exporter.get_finished_spans() assert span.status.status_code == StatusCode.UNSET - assert GenAI.GEN_AI_TOOL_CALL_ARGUMENTS not in span.attributes - assert json.loads(span.attributes[GenAI.GEN_AI_TOOL_CALL_RESULT]) == "done" - assert "Failed to snapshot tool arguments" in caplog.text + # Values that fail to convert are dropped, not the whole snapshot. + assert span.attributes.get(GenAI.GEN_AI_TOOL_CALL_ARGUMENTS) == "{}" + assert span.attributes[GenAI.GEN_AI_TOOL_CALL_RESULT] == "done" @pytest.mark.asyncio diff --git a/util/opentelemetry-util-genai/.changelog/821.added b/util/opentelemetry-util-genai/.changelog/821.added new file mode 100644 index 000000000..5d699bdf9 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/821.added @@ -0,0 +1 @@ +Add `object_to_any_value` and `tool_arguments_to_any_value` to convert arbitrary objects (models, dataclasses, datetimes, UUIDs, enums, objects with a custom `__str__`) into OpenTelemetry `AnyValue`, dropping values that cannot be converted. For example, `object_to_any_value({"when": date(2026, 1, 1), "cb": print})` returns `{"when": "2026-01-01"}`. diff --git a/util/opentelemetry-util-genai/.changelog/821.changed b/util/opentelemetry-util-genai/.changelog/821.changed new file mode 100644 index 000000000..345c6aadf --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/821.changed @@ -0,0 +1 @@ +`ToolInvocation` always records non-string arguments and results as JSON strings and converts objects that are not JSON-serializable through `object_to_any_value`; previously primitives were recorded as-is and other objects through `str()`, e.g. `arguments = 42` was recorded as `42`, now `"42"`. Prompt variables set to `None` are no longer recorded; previously they were recorded as `"null"`. 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 12645c105..d70142fea 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py @@ -56,6 +56,7 @@ ContentCapturingMode, gen_ai_json_dumps, get_content_capturing_mode, + object_to_any_value, ) from opentelemetry.util.types import AttributeValue @@ -371,6 +372,20 @@ def __exit__( self.stop() +def _json_dumps_unless_str(value: object) -> str | None: + if value is None or isinstance(value, str): + return value + try: + return gen_ai_json_dumps(value, allow_nan=False) + except (TypeError, ValueError, RecursionError): + pass + # Callers may set objects (e.g. models) that weren't converted to AnyValue. + converted = object_to_any_value(value) + if converted is None or isinstance(converted, str): + return converted + return gen_ai_json_dumps(converted) + + def get_content_attributes( *, input_messages: Sequence[InputMessage], @@ -440,7 +455,7 @@ def serialize(items: Sequence[Any]) -> Any: result = {key: value for key, value in optional_attrs if value is not None} if prompt_variables: for k, v in prompt_variables.items(): - result[f"{_GEN_AI_PROMPT_VARIABLE_PREFIX}{k}"] = ( - v if isinstance(v, str) else gen_ai_json_dumps(v) - ) + serialized = _json_dumps_unless_str(v) + if serialized is not None: + result[f"{_GEN_AI_PROMPT_VARIABLE_PREFIX}{k}"] = serialized return result 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 a903d0143..4f1446fe1 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 @@ -12,27 +12,16 @@ ) from opentelemetry.trace import SpanKind, Tracer from opentelemetry.util.genai._instruments import _Instruments -from opentelemetry.util.genai._invocation import Error, GenAIInvocation -from opentelemetry.util.genai.completion_hook import CompletionHook -from opentelemetry.util.genai.utils import ( - ContentCapturingMode, - gen_ai_json_dumps, +from opentelemetry.util.genai._invocation import ( + Error, + GenAIInvocation, + _json_dumps_unless_str, ) +from opentelemetry.util.genai.completion_hook import CompletionHook +from opentelemetry.util.genai.utils import ContentCapturingMode from opentelemetry.util.types import AnyValue, AttributeValue -def _any_value_to_attribute_value(value: AnyValue) -> AttributeValue | None: - """Serialize an AnyValue to an AttributeValue for OTel span attributes.""" - if value is None: - return None - if isinstance(value, (bool, str, bytes, int, float)): - return value - try: - return gen_ai_json_dumps(value) - except (TypeError, ValueError): - return str(value) - - class ToolInvocation(GenAIInvocation): """Represents a tool call invocation for execute_tool span tracking. @@ -133,14 +122,14 @@ def _apply_finish(self, error: Error | None = None) -> None: (GenAI.GEN_AI_AGENT_NAME, self._agent_name), ( GenAI.GEN_AI_TOOL_CALL_ARGUMENTS, - _any_value_to_attribute_value(self.arguments) - if capture_content_on_span and self.arguments is not None + _json_dumps_unless_str(self.arguments) + if capture_content_on_span else None, ), ( GenAI.GEN_AI_TOOL_CALL_RESULT, - _any_value_to_attribute_value(self.tool_result) - if capture_content_on_span and self.tool_result is not None + _json_dumps_unless_str(self.tool_result) + if capture_content_on_span else None, ), ) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/stream.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/stream.py index 7ac7530a0..f5dece4d5 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/stream.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/stream.py @@ -21,6 +21,7 @@ from opentelemetry.semconv.attributes.error_attributes import ErrorTypeValues from opentelemetry.util.genai._tool_invocation import ToolInvocation +from opentelemetry.util.genai.utils import object_to_any_value if TYPE_CHECKING: from opentelemetry.util.genai.types import Error @@ -491,7 +492,9 @@ def _on_stream_end(self) -> None: self._self_chunks ) else: - self._self_tool_invocation.tool_result = self._self_chunks + self._self_tool_invocation.tool_result = object_to_any_value( + self._self_chunks + ) self._self_tool_invocation.stop() def _on_stream_error(self, error: BaseException) -> None: @@ -551,7 +554,9 @@ def _on_stream_end(self) -> None: self._self_chunks ) else: - self._self_tool_invocation.tool_result = self._self_chunks + self._self_tool_invocation.tool_result = object_to_any_value( + self._self_chunks + ) self._self_tool_invocation.stop() def _on_stream_error(self, error: BaseException) -> None: 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 e67112c06..081162cf5 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py @@ -6,13 +6,17 @@ import inspect import json import logging +import math import os import urllib.parse from base64 import b64decode, b64encode -from collections.abc import Callable, Mapping -from dataclasses import asdict, is_dataclass +from collections.abc import Callable, Iterable, Mapping +from dataclasses import asdict, fields, is_dataclass +from datetime import date, datetime, time +from enum import Enum from functools import lru_cache, partial -from typing import Any +from typing import Any, cast +from uuid import UUID from opentelemetry.util.genai.environment_variables import ( OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, @@ -24,6 +28,7 @@ Modality, UriPart, ) +from opentelemetry.util.types import AnyValue logger = logging.getLogger(__name__) @@ -143,6 +148,138 @@ def default(self, o: Any) -> Any: bytes, datetimes, etc. for GenAI observability.""" +_MAX_DEPTH = 32 +_OMIT = object() + + +def object_to_any_value(value: object) -> AnyValue | None: + """Convert an object into an AnyValue, or None if it cannot be converted. + + Models, dataclasses and objects with public ``__dict__`` attributes become + dicts; datetimes, UUIDs and enums become their primitive form. Other + objects with a custom ``__str__`` (e.g. ``Decimal``, ``Path``) become + strings. Values that cannot be converted (callables, types, non-finite + floats, cycles, too deep structures, objects raising on access) are + dropped from collections. Generators and other iterables are not consumed. + """ + res = _sanitize_for_any_value(value, max_depth=_MAX_DEPTH) + return None if res is _OMIT else cast(AnyValue | None, res) + + +def tool_arguments_to_any_value(arguments: object) -> AnyValue | None: + """Convert tool call arguments to AnyValue, parsing JSON strings. + + Strings that are not valid JSON are returned as-is. + """ + if isinstance(arguments, str): + try: + return object_to_any_value(json.loads(arguments)) + except ValueError: + return arguments + return object_to_any_value(arguments) + + +def _sanitize_for_any_value( + value: object, + *, + max_depth: int, + seen: frozenset[int] = frozenset(), +) -> object: + if max_depth <= 0: + return _OMIT + + if value is None or isinstance(value, (str, bool, int, bytes)): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, float): + return value if math.isfinite(value) else _OMIT + if isinstance(value, (datetime, date, time)): + return value.isoformat() + if isinstance(value, UUID): + return str(value) + if isinstance(value, Enum): + return _sanitize_for_any_value( + value.value, max_depth=max_depth - 1, seen=seen + ) + if callable(value) or isinstance(value, type) or id(value) in seen: + return _OMIT + + # Arbitrary user objects can raise from model dumps, __getitem__, __dict__. + try: + return _sanitize_object( + value, max_depth=max_depth - 1, seen=seen | {id(value)} + ) + except Exception: + return _OMIT + + +def _dump_model(value: object) -> object | None: + if is_dataclass(value) and not isinstance(value, type): + return {f.name: getattr(value, f.name) for f in fields(value)} + for method_name in ("model_dump", "dict", "to_dict"): + fn = getattr(value, method_name, None) + if callable(fn): + return cast(Callable[[], object], fn)() + return None + + +def _sanitize_object( + value: object, *, max_depth: int, seen: frozenset[int] +) -> object: + dumped_model = _dump_model(value) + if dumped_model is not None: + return _sanitize_for_any_value( + dumped_model, max_depth=max_depth, seen=seen + ) + + if isinstance(value, Mapping): + mapping = cast(Mapping[object, object], value) + if any(not isinstance(k, str) for k in mapping): + return _OMIT + return { + k: cleaned + for k, v in mapping.items() + if ( + cleaned := _sanitize_for_any_value( + v, max_depth=max_depth, seen=seen + ) + ) + is not _OMIT + } + + # Arbitrary iterables (generators, streams) would be consumed or may never end. + if isinstance(value, (list, tuple, set, frozenset)): + return [ + cleaned + for item in cast(Iterable[object], value) + if ( + cleaned := _sanitize_for_any_value( + item, max_depth=max_depth, seen=seen + ) + ) + is not _OMIT + ] + + obj_dict: object = getattr(value, "__dict__", None) + if isinstance(obj_dict, dict): + # Skip private state, e.g. pydantic SecretStr's raw _secret_value. + public = { + k: v + for k, v in cast(dict[object, object], obj_dict).items() + if isinstance(k, str) and not k.startswith("_") + } + if public: + return _sanitize_for_any_value( + public, max_depth=max_depth, seen=seen + ) + + # Default object.__str__ gives a repr with a memory address, not data. + if type(value).__str__ is not object.__str__: + return str(value) + return _OMIT + + _SIGNATURE_CACHE_MAX_SIZE = 1024 _inspect_signature = inspect.signature diff --git a/util/opentelemetry-util-genai/tests/test_stream.py b/util/opentelemetry-util-genai/tests/test_stream.py index c83093510..755c7ba0c 100644 --- a/util/opentelemetry-util-genai/tests/test_stream.py +++ b/util/opentelemetry-util-genai/tests/test_stream.py @@ -1644,6 +1644,22 @@ def test_sync_tool_stream_wrapper_non_string_chunks(): ) +def test_tool_stream_wrapper_converts_model_chunks(): + class Model: + def model_dump(self): + return {"k": "v"} + + invocation, span_exporter = _started_tool_invocation() + wrapper = SyncToolStreamWrapper( + _FakeSyncStream(chunks=[Model(), {"n": 1}]), invocation + ) + + list(wrapper) + + (span,) = span_exporter.get_finished_spans() + assert span.attributes["gen_ai.tool.call.result"] == '[{"k":"v"},{"n":1}]' + + def test_async_tool_stream_wrapper_non_string_chunks(): async def exercise(): invocation, span_exporter = _started_tool_invocation() diff --git a/util/opentelemetry-util-genai/tests/test_toolcall.py b/util/opentelemetry-util-genai/tests/test_toolcall.py index ffb59e696..cdf07a510 100644 --- a/util/opentelemetry-util-genai/tests/test_toolcall.py +++ b/util/opentelemetry-util-genai/tests/test_toolcall.py @@ -4,6 +4,7 @@ """Tests for ToolCallRequestPart and ToolInvocation inheritance structure""" import os +from decimal import Decimal from unittest.mock import MagicMock, patch import pytest @@ -300,7 +301,7 @@ def test_arguments_dict_serialized_to_json(): {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, ) def test_arguments_str_passed_through(): - """str arguments are stored as-is (no JSON wrapping).""" + """str arguments are recorded as-is, without JSON quoting.""" span_exporter, handler = _make_span_exporter_and_handler() invocation = handler.tool("echo") invocation.arguments = "hello" @@ -314,15 +315,51 @@ def test_arguments_str_passed_through(): os.environ, {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, ) -def test_arguments_int_passed_through(): - """int arguments are stored as-is.""" +def test_arguments_int_serialized_to_json(): + """int arguments are serialized to JSON string.""" span_exporter, handler = _make_span_exporter_and_handler() invocation = handler.tool("counter") invocation.arguments = 42 invocation.stop() attrs = span_exporter.get_finished_spans()[0].attributes - assert attrs[GenAI.GEN_AI_TOOL_CALL_ARGUMENTS] == 42 + assert attrs[GenAI.GEN_AI_TOOL_CALL_ARGUMENTS] == "42" + + +@patch.dict( + os.environ, + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, +) +def test_unconverted_objects_serialized(): + class Model: + def model_dump(self): + return {"k": "v"} + + span_exporter, handler = _make_span_exporter_and_handler() + invocation = handler.tool("echo") + invocation.arguments = Model() + invocation.tool_result = Decimal("1.5") + invocation.stop() + + attrs = span_exporter.get_finished_spans()[0].attributes + assert attrs[GenAI.GEN_AI_TOOL_CALL_ARGUMENTS] == '{"k":"v"}' + assert attrs[GenAI.GEN_AI_TOOL_CALL_RESULT] == "1.5" + + +@patch.dict( + os.environ, + {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, +) +def test_non_finite_floats_not_recorded_as_invalid_json(): + span_exporter, handler = _make_span_exporter_and_handler() + invocation = handler.tool("echo") + invocation.arguments = {"a": 1, "b": float("nan")} + invocation.tool_result = float("inf") + invocation.stop() + + attrs = span_exporter.get_finished_spans()[0].attributes + assert attrs[GenAI.GEN_AI_TOOL_CALL_ARGUMENTS] == '{"a":1}' + assert GenAI.GEN_AI_TOOL_CALL_RESULT not in attrs @patch.dict( diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 2d07b8bec..79f3c4e01 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -6,10 +6,14 @@ import os import unittest from collections.abc import Mapping -from dataclasses import asdict +from dataclasses import asdict, dataclass +from datetime import date, datetime, timezone +from decimal import Decimal from enum import Enum -from typing import Any +from pathlib import PurePosixPath +from typing import Any, cast from unittest.mock import MagicMock, patch +from uuid import UUID from opentelemetry import trace from opentelemetry.sdk._logs import LoggerProvider @@ -62,6 +66,8 @@ get_content_capturing_mode, get_signature, image_from_url, + object_to_any_value, + tool_arguments_to_any_value, ) @@ -1570,6 +1576,66 @@ def test_inference_prompt_template_attributes(self): assert event_attrs["gen_ai.prompt.variable.style"] == "formal" assert event_attrs["gen_ai.prompt.variable.tags"] == '["a","b"]' + def test_inference_prompt_variables_safe_serialization(self): + with patch.dict( + os.environ, + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY", + }, + ): + handler = TelemetryHandler( + tracer_provider=self.tracer_provider, + logger_provider=self.logger_provider, + ) + invocation = handler.inference( + "test-provider", request_model="test-model" + ) + + invocation.prompt_variables = { + "plain_str": "hello", + "int_val": 42, + "none_val": None, + "valid_dict": {"clean": "data", "bad_leaf": object()}, + "bad_obj": object(), + } + invocation.stop() + + attrs = self.span_exporter.get_finished_spans()[-1].attributes + assert attrs["gen_ai.prompt.variable.plain_str"] == "hello" + assert attrs["gen_ai.prompt.variable.int_val"] == "42" + assert "gen_ai.prompt.variable.none_val" not in attrs + assert ( + attrs["gen_ai.prompt.variable.valid_dict"] + == '{"clean":"data"}' + ) + assert "gen_ai.prompt.variable.bad_obj" not in attrs + + @patch.dict( + os.environ, + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY"}, + ) + def test_inference_prompt_variables_self_referential_dataclass(self): + @dataclass + class Node: + name: str + parent: object = None + + node = Node(name="root") + node.parent = node + + handler = TelemetryHandler( + tracer_provider=self.tracer_provider, + logger_provider=self.logger_provider, + ) + invocation = handler.inference( + "test-provider", request_model="test-model" + ) + invocation.prompt_variables = {"node": node} + invocation.stop() + + attrs = self.span_exporter.get_finished_spans()[-1].attributes + assert attrs["gen_ai.prompt.variable.node"] == '{"name":"root"}' + class AnyNonNone: def __eq__(self, other): @@ -2026,3 +2092,218 @@ def sample_func(user_id: str | None = None): ) self.assertEqual(val, "fallback") mock_bind.assert_not_called() + + +class TestObjectToAnyValue(unittest.TestCase): + def test_primitives(self): + self.assertEqual(object_to_any_value("hello"), "hello") + self.assertEqual(object_to_any_value(""), "") + self.assertEqual(object_to_any_value(123), 123) + self.assertEqual(object_to_any_value(3.14), 3.14) + self.assertIs(object_to_any_value(True), True) + self.assertIs(object_to_any_value(False), False) + self.assertEqual(object_to_any_value(b"hello"), b"hello") + self.assertIsNone(object_to_any_value(None)) + + def test_rich_types(self): + class Status(Enum): + ACTIVE = "active" + + dt = datetime(2026, 9, 30, 15, 0, 0, tzinfo=timezone.utc) + self.assertEqual(object_to_any_value(dt), "2026-09-30T15:00:00+00:00") + u = UUID("12345678-1234-5678-1234-567812345678") + self.assertEqual( + object_to_any_value(u), "12345678-1234-5678-1234-567812345678" + ) + self.assertEqual(object_to_any_value(Status.ACTIVE), "active") + + def test_bytearray_copied_to_bytes(self): + raw = bytearray(b"hello") + sanitized = object_to_any_value(raw) + raw[0] = ord("j") + self.assertEqual(sanitized, b"hello") + + def test_models(self): + @dataclass + class SampleData: + name: str + age: int + + class FakePydantic: + def model_dump(self): + return {"field": "value", "sub": [1, 2]} + + class FakeModel: + def to_dict(self): + return {"key": "val"} + + self.assertEqual( + object_to_any_value(SampleData(name="Alice", age=30)), + {"name": "Alice", "age": 30}, + ) + self.assertEqual( + object_to_any_value(FakePydantic()), + {"field": "value", "sub": [1, 2]}, + ) + self.assertEqual(object_to_any_value(FakeModel()), {"key": "val"}) + + def test_plain_object_uses_dict(self): + class _Inner: + def __init__(self): + self.flag = True + + class _Plain: + def __init__(self): + self.city = "Paris" + self.inner = _Inner() + self.bad = object() + + self.assertEqual( + object_to_any_value(_Plain()), + {"city": "Paris", "inner": {"flag": True}}, + ) + + def test_collections_prune_invalid_entries(self): + self.assertEqual( + object_to_any_value( + { + "good": "data", + "bad_val": object(), + "callable_val": lambda: 1, + "nested": {"num": 42, "bad": object()}, + } + ), + {"good": "data", "nested": {"num": 42}}, + ) + self.assertEqual( + object_to_any_value( + [1, object(), "valid", lambda: None, [True, object()]] + ), + [1, "valid", [True]], + ) + + def test_set_converted_to_list(self): + sanitized = object_to_any_value({"b", "a", "c"}) + self.assertIsInstance(sanitized, list) + self.assertEqual(set(cast(list[str], sanitized)), {"a", "b", "c"}) + + def test_circular_reference_omitted(self): + circular_list: list[object] = [1] + circular_list.append(circular_list) + self.assertEqual(object_to_any_value(circular_list), [1]) + + circular_map: dict[str, object] = {"a": 1} + circular_map["self"] = circular_map + self.assertEqual(object_to_any_value(circular_map), {"a": 1}) + + def test_shared_non_circular_reference_kept(self): + shared = {"x": 1} + self.assertEqual( + object_to_any_value([shared, shared]), [{"x": 1}, {"x": 1}] + ) + + @patch("opentelemetry.util.genai.utils._MAX_DEPTH", 3) + def test_deep_structure_truncated(self): + self.assertEqual(object_to_any_value([[[["deep"]]]]), [[[]]]) + + def test_generators_not_consumed(self): + def gen(): + yield 1 + + generator = gen() + self.assertIsNone(object_to_any_value(generator)) + self.assertEqual(list(generator), [1]) + + def test_custom_str_used_as_fallback(self): + self.assertEqual(object_to_any_value(Decimal("1.5")), "1.5") + self.assertEqual( + object_to_any_value({"path": PurePosixPath("/tmp/a")}), + {"path": "/tmp/a"}, + ) + + def test_object_with_empty_dict_uses_str(self): + self.assertEqual(object_to_any_value(ValueError("boom")), "boom") + + def test_private_attributes_skipped(self): + class Secret: + def __init__(self, value: str): + self._value = value + + def __str__(self) -> str: + return "**********" + + class Credentials: + def __init__(self): + self.user = "bob" + self.password = Secret("hunter2") + self._token = "t0k3n" + + self.assertEqual(object_to_any_value(Secret("hunter2")), "**********") + self.assertEqual( + object_to_any_value(Credentials()), + {"user": "bob", "password": "**********"}, + ) + + def test_dataclass_uncopyable_field_dropped(self): + @dataclass + class WithGenerator: + name: str + items: object + + value = WithGenerator(name="a", items=(i for i in range(2))) + self.assertEqual(object_to_any_value(value), {"name": "a"}) + self.assertEqual(list(cast(Any, value.items)), [0, 1]) + + def test_model_non_any_value_fields_converted(self): + class Model: + def model_dump(self): + return {"price": Decimal("1.5"), "data": b"abc"} + + self.assertEqual( + object_to_any_value(Model()), {"price": "1.5", "data": b"abc"} + ) + + def test_unserializable_returns_none(self): + self.assertIsNone(object_to_any_value(object())) + self.assertIsNone(object_to_any_value(lambda: "bad")) + self.assertIsNone(object_to_any_value(int)) + self.assertIsNone(object_to_any_value(float("nan"))) + self.assertIsNone(object_to_any_value(float("inf"))) + self.assertIsNone(object_to_any_value(float("-inf"))) + self.assertIsNone(object_to_any_value({123: "bad_key"})) + self.assertIsNone(object_to_any_value({(1, 2): "bad_key"})) + + def test_raising_objects_return_none(self): + class RaisingMapping(Mapping[str, object]): + def __getitem__(self, key: str) -> object: + raise RuntimeError("boom") + + def __iter__(self): + return iter(["a"]) + + def __len__(self) -> int: + return 1 + + class RaisingDict: + @property + def __dict__(self): + raise RuntimeError("boom") + + self.assertIsNone(object_to_any_value(RaisingMapping())) + self.assertIsNone(object_to_any_value(RaisingDict())) + + +class TestToolArgumentsToAnyValue(unittest.TestCase): + def test_json_string_parsed(self): + self.assertEqual( + tool_arguments_to_any_value('{"city":"Paris"}'), {"city": "Paris"} + ) + + def test_non_json_string_kept(self): + self.assertEqual(tool_arguments_to_any_value("Paris"), "Paris") + + def test_object_converted(self): + self.assertEqual( + tool_arguments_to_any_value({"when": date(2026, 1, 1)}), + {"when": "2026-01-01"}, + )