Python: added args and results to function call span (#733)

* added args and results to function call span

* add ignores for mypy

* improved serialization

* also add None result

* improved handling

* log args when None

* slight tweak

* fix tests
This commit is contained in:
Eduard van Valkenburg
2025-09-15 20:28:01 +02:00
committed by GitHub
Unverified
parent ba9a1a61e1
commit db58a10a37
6 changed files with 123 additions and 80 deletions
+26 -14
View File
@@ -2,6 +2,7 @@
import asyncio
import inspect
import json
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Collection, MutableMapping, Sequence
from functools import wraps
@@ -31,6 +32,7 @@ from .telemetry import (
OtelAttr,
_capture_exception, # type: ignore
get_function_span,
get_function_span_attributes,
meter,
)
@@ -407,7 +409,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
if not isinstance(arguments, self.input_model):
raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}")
kwargs = arguments.model_dump(exclude_none=True)
if not OTEL_SETTINGS.ENABLED: # type: ignore
if not OTEL_SETTINGS.ENABLED: # type: ignore[name-defined]
logger.info(f"Function name: {self.name}")
logger.debug(f"Function arguments: {kwargs}")
res = self.__call__(**kwargs)
@@ -417,16 +419,19 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
return result # type: ignore[reportReturnType]
setup_telemetry()
with get_function_span(
function=self,
tool_call_id=tool_call_id,
) as span:
hist_attributes: dict[str, Any] = {
OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME: self.name,
OtelAttr.TOOL_CALL_ID: tool_call_id or "unknown",
}
attributes = get_function_span_attributes(self, tool_call_id=tool_call_id)
if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
attributes.update({
OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json()
if arguments
else json.dumps(kwargs)
if kwargs
else "None"
})
with get_function_span(attributes=attributes) as span:
attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] = self.name
logger.info(f"Function name: {self.name}")
if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore
if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
logger.debug(f"Function arguments: {kwargs}")
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
@@ -436,19 +441,26 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
end_time_stamp = perf_counter()
except Exception as exception:
end_time_stamp = perf_counter()
hist_attributes[OtelAttr.ERROR_TYPE] = type(exception).__name__
attributes[OtelAttr.ERROR_TYPE] = type(exception).__name__
_capture_exception(span=span, exception=exception, timestamp=time_ns())
logger.error(f"Function failed. Error: {exception}")
raise
else:
logger.info(f"Function {self.name} succeeded.")
if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore
logger.debug(f"Function result: {result or 'None'}")
if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
try:
json_result = json.dumps(result)
except (TypeError, OverflowError):
span.set_attribute(OtelAttr.TOOL_RESULT, "<non-serializable result>")
logger.debug("Function result: <non-serializable result>")
else:
span.set_attribute(OtelAttr.TOOL_RESULT, json_result)
logger.debug(f"Function result: {json_result}")
return result # type: ignore[reportReturnType]
finally:
duration = (end_time_stamp or perf_counter()) - start_time_stamp
span.set_attribute(OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, duration)
self._invocation_duration_histogram.record(duration, attributes=hist_attributes)
self._invocation_duration_histogram.record(duration, attributes=attributes)
logger.info("Function duration: %fs", duration)
def parameters(self) -> dict[str, Any]:
@@ -203,6 +203,8 @@ class OtelAttr(str, Enum):
TOOL_DESCRIPTION = "gen_ai.tool.description"
TOOL_NAME = "gen_ai.tool.name"
TOOL_TYPE = "gen_ai.tool.type"
TOOL_ARGUMENTS = "gen_ai.tool.call.arguments"
TOOL_RESULT = "gen_ai.tool.call.result"
# Agent attributes
AGENT_ID = "gen_ai.agent.id"
# Client attributes
@@ -885,18 +887,15 @@ def use_agent_telemetry(
# region Otel Helpers
def get_function_span(
function: "AIFunction[Any, Any]",
tool_call_id: str | None = None,
) -> "_AgnosticContextManager[Span]":
"""Starts a span for the given function.
def get_function_span_attributes(function: "AIFunction[Any, Any]", tool_call_id: str | None = None) -> dict[str, str]:
"""Get the span attributes for the given function.
Args:
function: The function for which to start the span.
function: The function for which to get the span attributes.
tool_call_id: The id of the tool_call that was requested.
Returns:
trace.Span: The started span as a context manager.
dict[str, str]: The span attributes.
"""
attributes: dict[str, str] = {
OtelAttr.OPERATION: OtelAttr.TOOL_EXECUTION_OPERATION,
@@ -906,9 +905,22 @@ def get_function_span(
}
if function.description:
attributes[OtelAttr.TOOL_DESCRIPTION] = function.description
return attributes
def get_function_span(
attributes: dict[str, str],
) -> "_AgnosticContextManager[Span]":
"""Starts a span for the given function.
Args:
attributes: The span attributes.
Returns:
trace.Span: The started span as a context manager.
"""
return tracer.start_as_current_span(
name=f"{OtelAttr.TOOL_EXECUTION_OPERATION} {function.name}",
name=f"{attributes[OtelAttr.OPERATION]} {attributes[OtelAttr.TOOL_NAME]}",
attributes=attributes,
set_status_on_exception=False,
end_on_exit=True,