diff --git a/src/langtrace_python_sdk/constants/instrumentation/common.py b/src/langtrace_python_sdk/constants/instrumentation/common.py index 565026da..500473ef 100644 --- a/src/langtrace_python_sdk/constants/instrumentation/common.py +++ b/src/langtrace_python_sdk/constants/instrumentation/common.py @@ -42,6 +42,7 @@ "MILVUS": "Milvus", "GRAPHLIT": "Graphlit", "PHIDATA": "Phidata", + "AGNO": "Agno", } LANGTRACE_ADDITIONAL_SPAN_ATTRIBUTES_KEY = "langtrace_additional_attributes" diff --git a/src/langtrace_python_sdk/instrumentation/__init__.py b/src/langtrace_python_sdk/instrumentation/__init__.py index 94f2461b..567cdd94 100644 --- a/src/langtrace_python_sdk/instrumentation/__init__.py +++ b/src/langtrace_python_sdk/instrumentation/__init__.py @@ -31,6 +31,7 @@ from .google_genai import GoogleGenaiInstrumentation from .graphlit import GraphlitInstrumentation from .phidata import PhiDataInstrumentation +from .agno import AgnoInstrumentation __all__ = [ "AnthropicInstrumentation", @@ -63,4 +64,5 @@ "CrewaiToolsInstrumentation", "GraphlitInstrumentation", "PhiDataInstrumentation", + "AgnoInstrumentation", ] diff --git a/src/langtrace_python_sdk/instrumentation/agno/__init__.py b/src/langtrace_python_sdk/instrumentation/agno/__init__.py new file mode 100644 index 00000000..6b6c9fe8 --- /dev/null +++ b/src/langtrace_python_sdk/instrumentation/agno/__init__.py @@ -0,0 +1,5 @@ +from .instrumentation import AgnoInstrumentation + +__all__ = [ + "AgnoInstrumentation", +] diff --git a/src/langtrace_python_sdk/instrumentation/agno/instrumentation.py b/src/langtrace_python_sdk/instrumentation/agno/instrumentation.py new file mode 100644 index 00000000..6a632ce9 --- /dev/null +++ b/src/langtrace_python_sdk/instrumentation/agno/instrumentation.py @@ -0,0 +1,80 @@ +""" +Copyright (c) 2024 Scale3 Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.trace import get_tracer +from wrapt import wrap_function_wrapper as _W +from typing import Collection +from importlib_metadata import version as v +from .patch import patch_agent, patch_memory + +class AgnoInstrumentation(BaseInstrumentor): + def instrumentation_dependencies(self) -> Collection[str]: + return ["agno >= 1.1.4"] + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, "", tracer_provider) + version = v("agno") + + try: + _W( + "agno.agent.agent", + "Agent.run", + patch_agent("Agent.run", version, tracer), + ) + _W( + "agno.agent.agent", + "Agent.arun", + patch_agent("Agent.arun", version, tracer), + ) + _W( + "agno.agent.agent", + "Agent._run", + patch_agent("Agent._run", version, tracer), + ) + _W( + "agno.agent.agent", + "Agent._arun", + patch_agent("Agent._arun", version, tracer), + ) + + _W( + "agno.memory.agent", + "AgentMemory.update_memory", + patch_memory("AgentMemory.update_memory", version, tracer), + ) + _W( + "agno.memory.agent", + "AgentMemory.aupdate_memory", + patch_memory("AgentMemory.aupdate_memory", version, tracer), + ) + _W( + "agno.memory.agent", + "AgentMemory.update_summary", + patch_memory("AgentMemory.update_summary", version, tracer), + ) + _W( + "agno.memory.agent", + "AgentMemory.aupdate_summary", + patch_memory("AgentMemory.aupdate_summary", version, tracer), + ) + + except Exception: + pass + + def _uninstrument(self, **kwargs): + pass \ No newline at end of file diff --git a/src/langtrace_python_sdk/instrumentation/agno/patch.py b/src/langtrace_python_sdk/instrumentation/agno/patch.py new file mode 100644 index 00000000..b5c93357 --- /dev/null +++ b/src/langtrace_python_sdk/instrumentation/agno/patch.py @@ -0,0 +1,261 @@ +import json +from importlib_metadata import version as v +from langtrace.trace_attributes import FrameworkSpanAttributes +from opentelemetry import baggage +from opentelemetry.trace import Span, SpanKind, Tracer +from opentelemetry.trace.status import Status, StatusCode +from typing import Dict, Any, Optional + +from langtrace_python_sdk.constants import LANGTRACE_SDK_NAME +from langtrace_python_sdk.constants.instrumentation.common import ( + LANGTRACE_ADDITIONAL_SPAN_ATTRIBUTES_KEY, + SERVICE_PROVIDERS, +) +from langtrace_python_sdk.utils import set_span_attribute +from langtrace_python_sdk.utils.llm import get_span_name, set_span_attributes +from langtrace_python_sdk.utils.misc import serialize_args, serialize_kwargs + +def _extract_metrics(metrics: Dict[str, Any]) -> Dict[str, Any]: + """Helper function to extract and format metrics""" + formatted_metrics = {} + + # Extract basic metrics + for key in ['time', 'time_to_first_token', 'input_tokens', 'output_tokens', + 'prompt_tokens', 'completion_tokens', 'total_tokens']: + if key in metrics: + formatted_metrics[key] = metrics[key] + + # Extract nested metric details if present + if 'prompt_tokens_details' in metrics: + formatted_metrics['prompt_tokens_details'] = metrics['prompt_tokens_details'] + if 'completion_tokens_details' in metrics: + formatted_metrics['completion_tokens_details'] = metrics['completion_tokens_details'] + if 'tool_call_times' in metrics: + formatted_metrics['tool_call_times'] = metrics['tool_call_times'] + + return formatted_metrics + +def patch_memory(operation_name, version, tracer: Tracer): + def traced_method(wrapped, instance, args, kwargs): + service_provider = SERVICE_PROVIDERS["AGNO"] + extra_attributes = baggage.get_baggage(LANGTRACE_ADDITIONAL_SPAN_ATTRIBUTES_KEY) + span_attributes = { + "langtrace.sdk.name": "langtrace-python-sdk", + "langtrace.service.name": service_provider, + "langtrace.service.type": "framework", + "langtrace.service.version": version, + "langtrace.version": v(LANGTRACE_SDK_NAME), + **(extra_attributes if extra_attributes is not None else {}), + } + + span_attributes.update({ + "agno.memory.type": type(instance).__name__, + "agno.memory.create_session_summary": str(instance.create_session_summary), + "agno.memory.create_user_memories": str(instance.create_user_memories), + "agno.memory.retrieval": str(instance.retrieval) + }) + + inputs = {} + if len(args) > 0: + inputs["args"] = serialize_args(*args) + if len(kwargs) > 0: + inputs["kwargs"] = serialize_kwargs(**kwargs) + span_attributes["agno.memory.inputs"] = json.dumps(inputs) + + attributes = FrameworkSpanAttributes(**span_attributes) + + with tracer.start_as_current_span( + get_span_name(operation_name), kind=SpanKind.CLIENT + ) as span: + try: + set_span_attributes(span, attributes) + result = wrapped(*args, **kwargs) + + if result is not None: + set_span_attribute(span, "agno.memory.output", str(result)) + + if instance.summary is not None: + set_span_attribute(span, "agno.memory.summary", str(instance.summary)) + if instance.memories is not None: + set_span_attribute(span, "agno.memory.memories_count", str(len(instance.memories))) + + span.set_status(Status(StatusCode.OK)) + return result + + except Exception as err: + span.record_exception(err) + span.set_status(Status(StatusCode.ERROR, str(err))) + raise + + return traced_method + +def patch_agent(operation_name, version, tracer: Tracer): + def traced_method(wrapped, instance, args, kwargs): + service_provider = SERVICE_PROVIDERS["AGNO"] + extra_attributes = baggage.get_baggage(LANGTRACE_ADDITIONAL_SPAN_ATTRIBUTES_KEY) + span_attributes = { + "langtrace.sdk.name": "langtrace-python-sdk", + "langtrace.service.name": service_provider, + "langtrace.service.type": "framework", + "langtrace.service.version": version, + "langtrace.version": v(LANGTRACE_SDK_NAME), + **(extra_attributes if extra_attributes is not None else {}), + } + + attributes = FrameworkSpanAttributes(**span_attributes) + + with tracer.start_as_current_span( + get_span_name(operation_name), kind=SpanKind.CLIENT + ) as span: + try: + set_span_attributes(span, attributes) + AgnoSpanAttributes(span=span, instance=instance) + result_generator = wrapped(*args, **kwargs) + + accumulated_content = "" + current_tool_call = None + response_metadata = None + seen_tool_calls = set() + + try: + for response in result_generator: + if not hasattr(response, 'to_dict'): + yield response + continue + + if not response_metadata: + response_metadata = { + "run_id": response.run_id, + "agent_id": response.agent_id, + "session_id": response.session_id, + "model": response.model, + "content_type": response.content_type, + } + for key, value in response_metadata.items(): + if value is not None: + set_span_attribute(span, f"agno.agent.{key}", str(value)) + + if response.content: + accumulated_content += response.content + set_span_attribute(span, "agno.agent.response", accumulated_content) + + if response.messages: + for msg in response.messages: + if msg.tool_calls: + for tool_call in msg.tool_calls: + tool_id = tool_call.get('id') + if tool_id and tool_id not in seen_tool_calls: + seen_tool_calls.add(tool_id) + tool_info = { + 'id': tool_id, + 'name': tool_call.get('function', {}).get('name'), + 'arguments': tool_call.get('function', {}).get('arguments'), + 'start_time': msg.created_at, + } + current_tool_call = tool_info + set_span_attribute(span, f"agno.agent.tool_call.{tool_id}", json.dumps(tool_info)) + + if msg.metrics: + metrics = _extract_metrics(msg.metrics) + role_prefix = f"agno.agent.metrics.{msg.role}" + for key, value in metrics.items(): + set_span_attribute(span, f"{role_prefix}.{key}", str(value)) + + if response.tools: + for tool in response.tools: + tool_id = tool.get('tool_call_id') + if tool_id and current_tool_call and current_tool_call['id'] == tool_id: + tool_result = { + **current_tool_call, + 'result': tool.get('content'), + 'error': tool.get('tool_call_error'), + 'end_time': tool.get('created_at'), + 'metrics': tool.get('metrics'), + } + set_span_attribute(span, f"agno.agent.tool_call.{tool_id}", json.dumps(tool_result)) + current_tool_call = None + + yield response + + except Exception as err: + span.record_exception(err) + span.set_status(Status(StatusCode.ERROR, str(err))) + raise + finally: + span.set_status(Status(StatusCode.OK)) + if len(seen_tool_calls) > 0: + span.set_attribute("agno.agent.total_tool_calls", len(seen_tool_calls)) + except Exception as err: + span.record_exception(err) + span.set_status(Status(StatusCode.ERROR, str(err))) + raise + + return traced_method + +class AgnoSpanAttributes: + span: Span + agent_data: dict + + def __init__(self, span: Span, instance) -> None: + self.span = span + self.instance = instance + self.agent_data = { + "memory": {}, + "model": {}, + "tools": [], + } + + self.run() + + def run(self): + instance_attrs = { + "agent_id": self.instance.agent_id, + "session_id": self.instance.session_id, + "name": self.instance.name, + "markdown": self.instance.markdown, + "reasoning": self.instance.reasoning, + "add_references": self.instance.add_references, + "show_tool_calls": self.instance.show_tool_calls, + "stream": self.instance.stream, + "stream_intermediate_steps": self.instance.stream_intermediate_steps, + } + + for key, value in instance_attrs.items(): + if value is not None: + set_span_attribute(self.span, f"agno.agent.{key}", str(value)) + + if self.instance.model: + model_attrs = { + "id": self.instance.model.id, + "name": self.instance.model.name, + "provider": self.instance.model.provider, + "structured_outputs": self.instance.model.structured_outputs, + "supports_structured_outputs": self.instance.model.supports_structured_outputs, + } + for key, value in model_attrs.items(): + if value is not None: + set_span_attribute(self.span, f"agno.agent.model.{key}", str(value)) + + if hasattr(self.instance.model, 'metrics') and self.instance.model.metrics: + metrics = _extract_metrics(self.instance.model.metrics) + set_span_attribute(self.span, "agno.agent.model.metrics", json.dumps(metrics)) + + if self.instance.tools: + tool_list = [] + for tool in self.instance.tools: + if hasattr(tool, "name"): + tool_list.append(tool.name) + elif hasattr(tool, "__name__"): + tool_list.append(tool.__name__) + set_span_attribute(self.span, "agno.agent.tools", str(tool_list)) + + if self.instance.memory: + memory_attrs = { + "create_session_summary": self.instance.memory.create_session_summary, + "create_user_memories": self.instance.memory.create_user_memories, + "update_session_summary_after_run": self.instance.memory.update_session_summary_after_run, + "update_user_memories_after_run": self.instance.memory.update_user_memories_after_run, + } + for key, value in memory_attrs.items(): + if value is not None: + set_span_attribute(self.span, f"agno.agent.memory.{key}", str(value)) diff --git a/src/langtrace_python_sdk/langtrace.py b/src/langtrace_python_sdk/langtrace.py index bf3fff52..64914aba 100644 --- a/src/langtrace_python_sdk/langtrace.py +++ b/src/langtrace_python_sdk/langtrace.py @@ -75,6 +75,7 @@ GoogleGenaiInstrumentation, GraphlitInstrumentation, PhiDataInstrumentation, + AgnoInstrumentation, ) from opentelemetry.util.re import parse_env_headers from sentry_sdk.types import Event, Hint @@ -325,6 +326,7 @@ def init( "google-genai": GoogleGenaiInstrumentation(), "graphlit-client": GraphlitInstrumentation(), "phidata": PhiDataInstrumentation(), + "agno": AgnoInstrumentation(), "mistralai": MistralInstrumentation(), "boto3": AWSBedrockInstrumentation(), "autogen": AutogenInstrumentation(), diff --git a/src/langtrace_python_sdk/version.py b/src/langtrace_python_sdk/version.py index 46f67e7f..32a78190 100644 --- a/src/langtrace_python_sdk/version.py +++ b/src/langtrace_python_sdk/version.py @@ -1 +1 @@ -__version__ = "3.7.0" +__version__ = "3.8.0"