Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}`.
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -248,27 +248,25 @@ 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(
invocation: ToolInvocation,
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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 (
Expand All @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -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"


Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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}]
Expand Down Expand Up @@ -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}]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"`.
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
Loading
Loading