diff --git a/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/292.changed b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/292.changed new file mode 100644 index 000000000..c7d03f45f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-langchain/.changelog/292.changed @@ -0,0 +1 @@ +Avoid redundant JSON encoding of tool arguments. 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 1ef791053..3aff26b6c 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 from typing import Any, Optional, cast from uuid import UUID @@ -40,6 +39,7 @@ Text, ToolCallRequest, ) +from opentelemetry.util.genai.utils import gen_ai_json_dumps class OpenTelemetryLangChainCallbackHandler(BaseCallbackHandler): @@ -431,19 +431,22 @@ def on_tool_start( if serialized is not None: 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 tool_invocation = self._telemetry_handler.tool( name=name, tool_description=description, tool_type="function" ) - tool_invocation.arguments = arguments + if self._telemetry_handler.should_capture_content(): + arguments: Any + if inputs is not None: + arguments = inputs + else: + arguments = input_str + + tool_invocation.arguments = ( + arguments + if isinstance(arguments, str) + else gen_ai_json_dumps(arguments) + ) + self._invocation_manager.add_invocation_state( run_id, parent_run_id, tool_invocation ) @@ -460,7 +463,13 @@ def on_tool_end( if not isinstance(tool_invocation, ToolInvocation): return tool_invocation.tool_call_id = getattr(output, "tool_call_id", None) - tool_invocation.tool_result = getattr(output, "content", None) + raw_result = getattr(output, "content", output) + if self._telemetry_handler.should_capture_content(): + tool_invocation.tool_result = ( + raw_result + if isinstance(raw_result, str) + else gen_ai_json_dumps(raw_result) + ) tool_invocation.stop() if not tool_invocation.span.is_recording(): self._invocation_manager.delete_invocation_state(run_id=run_id) 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 2e3dd977b..27f8f603a 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 @@ -49,6 +49,7 @@ GenAIInvocation, ToolInvocation, ) +from opentelemetry.util.genai.utils import gen_ai_json_dumps # Non-semconv attribute: surfaces the workflow name on the workflow span # so callers can query/filter by it. util-genai's WorkflowInvocation @@ -126,9 +127,11 @@ def on_span_end(self, span: Span[Any]) -> None: span.span_data, FunctionSpanData ): output = span.span_data.output - if output is not None: + if self._handler.should_capture_content() and output is not None: invocation.tool_result = ( - output if isinstance(output, str) else str(output) + output + if isinstance(output, str) + else gen_ai_json_dumps(output) ) invocation.stop() 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 322f0f041..38f9a0f73 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,7 +3,6 @@ import functools import inspect -import json from typing import Any, Callable, Optional, Union from google.genai.types import ( @@ -13,6 +12,7 @@ ) from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.utils import gen_ai_json_dumps ToolFunction = Callable[..., Any] @@ -84,12 +84,12 @@ async def wrapped_function(*args, **kwargs): ) as tool_invocation: # Do this before calling the tool in case that crashes. if tool_invocation.should_capture_content_on_span: - tool_invocation.arguments = json.dumps( + tool_invocation.arguments = gen_ai_json_dumps( _get_function_args(tool_function, args, kwargs) ) result = await tool_function(*args, **kwargs) if tool_invocation.should_capture_content_on_span: - tool_invocation.tool_result = json.dumps( + tool_invocation.tool_result = gen_ai_json_dumps( _to_otel_value(result) ) return result @@ -103,12 +103,12 @@ def wrapped_function(*args, **kwargs): ) as tool_invocation: # Do this before calling the tool in case that crashes. if tool_invocation.should_capture_content_on_span: - tool_invocation.arguments = json.dumps( + tool_invocation.arguments = gen_ai_json_dumps( _get_function_args(tool_function, args, kwargs) ) result = tool_function(*args, **kwargs) if tool_invocation.should_capture_content_on_span: - tool_invocation.tool_result = json.dumps( + tool_invocation.tool_result = gen_ai_json_dumps( _to_otel_value(result) ) return result diff --git a/util/opentelemetry-util-genai/.changelog/292.changed b/util/opentelemetry-util-genai/.changelog/292.changed new file mode 100644 index 000000000..bb04d3a89 --- /dev/null +++ b/util/opentelemetry-util-genai/.changelog/292.changed @@ -0,0 +1 @@ +Update ``ToolInvocation`` arguments and results to accept ``AnyValue`` attribute types directly. 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 6af59b3ca..a4bfd2624 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 @@ -11,23 +11,8 @@ from opentelemetry.util.genai._invocation import Error, GenAIInvocation from opentelemetry.util.genai.completion_hook import CompletionHook from opentelemetry.util.genai.metrics import InvocationMetricsRecorder -from opentelemetry.util.genai.utils import ( - gen_ai_json_dumps, - should_capture_content_on_spans, -) -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) +from opentelemetry.util.genai.utils import should_capture_content_on_spans +from opentelemetry.util.types import AnyValue class ToolInvocation(GenAIInvocation): @@ -75,18 +60,18 @@ def __init__( ) self.should_capture_content_on_span = should_capture_content_on_spans() self.name = name - self.tool_result: AnyValue | None = None + self.tool_result: AnyValue = None # Since arguments and tool_result can be expensive to serialize, # it's recommended to check the content capture flag in the # instrumentation library before assigning these attributes # to the invocation. - self.arguments: AnyValue | None = None + self.arguments: AnyValue = None self.tool_call_id = tool_call_id self.tool_type = tool_type self.tool_description = tool_description self._start(self._get_base_attributes()) - def _get_base_attributes(self) -> dict[str, AttributeValue]: + def _get_base_attributes(self) -> dict[str, AnyValue]: """Return sampling-relevant attributes available at span creation time.""" optional_attrs = ( (GenAI.GEN_AI_TOOL_NAME, self.name), @@ -99,8 +84,8 @@ def _get_base_attributes(self) -> dict[str, AttributeValue]: **{k: v for k, v in optional_attrs if v is not None}, } - def _get_metric_attributes(self) -> dict[str, AttributeValue]: - attrs: dict[str, AttributeValue] = { + def _get_metric_attributes(self) -> dict[str, AnyValue]: + attrs: dict[str, AnyValue] = { GenAI.GEN_AI_OPERATION_NAME: self._operation_name, } attrs.update(self.metric_attributes) @@ -116,20 +101,18 @@ def _apply_finish(self, error: Error | None = None) -> None: (GenAI.GEN_AI_TOOL_DESCRIPTION, self.tool_description), ( GenAI.GEN_AI_TOOL_CALL_ARGUMENTS, - _any_value_to_attribute_value(self.arguments) + self.arguments if self.should_capture_content_on_span - and self.arguments is not None else None, ), ( GenAI.GEN_AI_TOOL_CALL_RESULT, - _any_value_to_attribute_value(self.tool_result) + self.tool_result if self.should_capture_content_on_span - and self.tool_result is not None else None, ), ) - attributes: dict[str, AttributeValue] = { + attributes: dict[str, AnyValue] = { 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/tests/test_toolcall.py b/util/opentelemetry-util-genai/tests/test_toolcall.py index 6277b9681..be095860a 100644 --- a/util/opentelemetry-util-genai/tests/test_toolcall.py +++ b/util/opentelemetry-util-genai/tests/test_toolcall.py @@ -29,7 +29,6 @@ ServerToolCallResponse, ToolCallRequest, ) -from opentelemetry.util.genai.utils import gen_ai_json_dumps def _make_handler() -> TelemetryHandler: @@ -208,24 +207,6 @@ def _make_span_exporter_and_handler(): return span_exporter, handler -@patch.dict( - os.environ, - {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, -) -def test_arguments_dict_serialized_to_json(): - """dict arguments are JSON-serialized onto the span attribute.""" - span_exporter, handler = _make_span_exporter_and_handler() - invocation = handler.tool("get_weather") - invocation.arguments = {"location": "Paris", "unit": "celsius"} - invocation.stop() - - attrs = span_exporter.get_finished_spans()[0].attributes - assert GenAI.GEN_AI_TOOL_CALL_ARGUMENTS in attrs - assert attrs[GenAI.GEN_AI_TOOL_CALL_ARGUMENTS] == gen_ai_json_dumps( - {"location": "Paris", "unit": "celsius"} - ) - - @patch.dict( os.environ, {OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "SPAN_ONLY"}, @@ -275,7 +256,7 @@ def test_arguments_omitted_when_content_capture_disabled(): """arguments must not appear on the span when content capture is off.""" span_exporter, handler = _make_span_exporter_and_handler() invocation = handler.tool("get_weather") - invocation.arguments = {"location": "Paris"} + invocation.arguments = "abasfasf" invocation.stop() attrs = span_exporter.get_finished_spans()[0].attributes