mseep-agentops 0.4.18__py3-none-any.whl → 0.4.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. agentops/__init__.py +0 -0
  2. agentops/client/api/base.py +28 -30
  3. agentops/client/api/versions/v3.py +29 -25
  4. agentops/client/api/versions/v4.py +87 -46
  5. agentops/client/client.py +98 -29
  6. agentops/client/http/README.md +87 -0
  7. agentops/client/http/http_client.py +126 -172
  8. agentops/config.py +8 -2
  9. agentops/instrumentation/OpenTelemetry.md +133 -0
  10. agentops/instrumentation/README.md +167 -0
  11. agentops/instrumentation/__init__.py +13 -1
  12. agentops/instrumentation/agentic/ag2/__init__.py +18 -0
  13. agentops/instrumentation/agentic/ag2/instrumentor.py +922 -0
  14. agentops/instrumentation/agentic/agno/__init__.py +19 -0
  15. agentops/instrumentation/agentic/agno/attributes/__init__.py +20 -0
  16. agentops/instrumentation/agentic/agno/attributes/agent.py +250 -0
  17. agentops/instrumentation/agentic/agno/attributes/metrics.py +214 -0
  18. agentops/instrumentation/agentic/agno/attributes/storage.py +158 -0
  19. agentops/instrumentation/agentic/agno/attributes/team.py +195 -0
  20. agentops/instrumentation/agentic/agno/attributes/tool.py +210 -0
  21. agentops/instrumentation/agentic/agno/attributes/workflow.py +254 -0
  22. agentops/instrumentation/agentic/agno/instrumentor.py +1313 -0
  23. agentops/instrumentation/agentic/crewai/LICENSE +201 -0
  24. agentops/instrumentation/agentic/crewai/NOTICE.md +10 -0
  25. agentops/instrumentation/agentic/crewai/__init__.py +6 -0
  26. agentops/instrumentation/agentic/crewai/crewai_span_attributes.py +335 -0
  27. agentops/instrumentation/agentic/crewai/instrumentation.py +535 -0
  28. agentops/instrumentation/agentic/crewai/version.py +1 -0
  29. agentops/instrumentation/agentic/google_adk/__init__.py +19 -0
  30. agentops/instrumentation/agentic/google_adk/instrumentor.py +68 -0
  31. agentops/instrumentation/agentic/google_adk/patch.py +767 -0
  32. agentops/instrumentation/agentic/haystack/__init__.py +1 -0
  33. agentops/instrumentation/agentic/haystack/instrumentor.py +186 -0
  34. agentops/instrumentation/agentic/langgraph/__init__.py +3 -0
  35. agentops/instrumentation/agentic/langgraph/attributes.py +54 -0
  36. agentops/instrumentation/agentic/langgraph/instrumentation.py +598 -0
  37. agentops/instrumentation/agentic/langgraph/version.py +1 -0
  38. agentops/instrumentation/agentic/openai_agents/README.md +156 -0
  39. agentops/instrumentation/agentic/openai_agents/SPANS.md +145 -0
  40. agentops/instrumentation/agentic/openai_agents/TRACING_API.md +144 -0
  41. agentops/instrumentation/agentic/openai_agents/__init__.py +30 -0
  42. agentops/instrumentation/agentic/openai_agents/attributes/common.py +549 -0
  43. agentops/instrumentation/agentic/openai_agents/attributes/completion.py +172 -0
  44. agentops/instrumentation/agentic/openai_agents/attributes/model.py +58 -0
  45. agentops/instrumentation/agentic/openai_agents/attributes/tokens.py +275 -0
  46. agentops/instrumentation/agentic/openai_agents/exporter.py +469 -0
  47. agentops/instrumentation/agentic/openai_agents/instrumentor.py +107 -0
  48. agentops/instrumentation/agentic/openai_agents/processor.py +58 -0
  49. agentops/instrumentation/agentic/smolagents/README.md +88 -0
  50. agentops/instrumentation/agentic/smolagents/__init__.py +12 -0
  51. agentops/instrumentation/agentic/smolagents/attributes/agent.py +354 -0
  52. agentops/instrumentation/agentic/smolagents/attributes/model.py +205 -0
  53. agentops/instrumentation/agentic/smolagents/instrumentor.py +286 -0
  54. agentops/instrumentation/agentic/smolagents/stream_wrapper.py +258 -0
  55. agentops/instrumentation/agentic/xpander/__init__.py +15 -0
  56. agentops/instrumentation/agentic/xpander/context.py +112 -0
  57. agentops/instrumentation/agentic/xpander/instrumentor.py +877 -0
  58. agentops/instrumentation/agentic/xpander/trace_probe.py +86 -0
  59. agentops/instrumentation/agentic/xpander/version.py +3 -0
  60. agentops/instrumentation/common/README.md +65 -0
  61. agentops/instrumentation/common/attributes.py +1 -2
  62. agentops/instrumentation/providers/anthropic/__init__.py +24 -0
  63. agentops/instrumentation/providers/anthropic/attributes/__init__.py +23 -0
  64. agentops/instrumentation/providers/anthropic/attributes/common.py +64 -0
  65. agentops/instrumentation/providers/anthropic/attributes/message.py +541 -0
  66. agentops/instrumentation/providers/anthropic/attributes/tools.py +231 -0
  67. agentops/instrumentation/providers/anthropic/event_handler_wrapper.py +90 -0
  68. agentops/instrumentation/providers/anthropic/instrumentor.py +146 -0
  69. agentops/instrumentation/providers/anthropic/stream_wrapper.py +436 -0
  70. agentops/instrumentation/providers/google_genai/README.md +33 -0
  71. agentops/instrumentation/providers/google_genai/__init__.py +24 -0
  72. agentops/instrumentation/providers/google_genai/attributes/__init__.py +25 -0
  73. agentops/instrumentation/providers/google_genai/attributes/chat.py +125 -0
  74. agentops/instrumentation/providers/google_genai/attributes/common.py +88 -0
  75. agentops/instrumentation/providers/google_genai/attributes/model.py +284 -0
  76. agentops/instrumentation/providers/google_genai/instrumentor.py +170 -0
  77. agentops/instrumentation/providers/google_genai/stream_wrapper.py +238 -0
  78. agentops/instrumentation/providers/ibm_watsonx_ai/__init__.py +28 -0
  79. agentops/instrumentation/providers/ibm_watsonx_ai/attributes/__init__.py +27 -0
  80. agentops/instrumentation/providers/ibm_watsonx_ai/attributes/attributes.py +277 -0
  81. agentops/instrumentation/providers/ibm_watsonx_ai/attributes/common.py +104 -0
  82. agentops/instrumentation/providers/ibm_watsonx_ai/instrumentor.py +162 -0
  83. agentops/instrumentation/providers/ibm_watsonx_ai/stream_wrapper.py +302 -0
  84. agentops/instrumentation/providers/mem0/__init__.py +45 -0
  85. agentops/instrumentation/providers/mem0/common.py +377 -0
  86. agentops/instrumentation/providers/mem0/instrumentor.py +270 -0
  87. agentops/instrumentation/providers/mem0/memory.py +430 -0
  88. agentops/instrumentation/providers/openai/__init__.py +21 -0
  89. agentops/instrumentation/providers/openai/attributes/__init__.py +7 -0
  90. agentops/instrumentation/providers/openai/attributes/common.py +55 -0
  91. agentops/instrumentation/providers/openai/attributes/response.py +607 -0
  92. agentops/instrumentation/providers/openai/config.py +36 -0
  93. agentops/instrumentation/providers/openai/instrumentor.py +312 -0
  94. agentops/instrumentation/providers/openai/stream_wrapper.py +941 -0
  95. agentops/instrumentation/providers/openai/utils.py +44 -0
  96. agentops/instrumentation/providers/openai/v0.py +176 -0
  97. agentops/instrumentation/providers/openai/v0_wrappers.py +483 -0
  98. agentops/instrumentation/providers/openai/wrappers/__init__.py +30 -0
  99. agentops/instrumentation/providers/openai/wrappers/assistant.py +277 -0
  100. agentops/instrumentation/providers/openai/wrappers/chat.py +259 -0
  101. agentops/instrumentation/providers/openai/wrappers/completion.py +109 -0
  102. agentops/instrumentation/providers/openai/wrappers/embeddings.py +94 -0
  103. agentops/instrumentation/providers/openai/wrappers/image_gen.py +75 -0
  104. agentops/instrumentation/providers/openai/wrappers/responses.py +191 -0
  105. agentops/instrumentation/providers/openai/wrappers/shared.py +81 -0
  106. agentops/instrumentation/utilities/concurrent_futures/__init__.py +10 -0
  107. agentops/instrumentation/utilities/concurrent_futures/instrumentation.py +206 -0
  108. agentops/integration/callbacks/dspy/__init__.py +11 -0
  109. agentops/integration/callbacks/dspy/callback.py +471 -0
  110. agentops/integration/callbacks/langchain/README.md +59 -0
  111. agentops/integration/callbacks/langchain/__init__.py +15 -0
  112. agentops/integration/callbacks/langchain/callback.py +791 -0
  113. agentops/integration/callbacks/langchain/utils.py +54 -0
  114. agentops/legacy/crewai.md +121 -0
  115. agentops/logging/instrument_logging.py +4 -0
  116. agentops/sdk/README.md +220 -0
  117. agentops/sdk/core.py +75 -32
  118. agentops/sdk/descriptors/classproperty.py +28 -0
  119. agentops/sdk/exporters.py +152 -33
  120. agentops/semconv/README.md +125 -0
  121. agentops/semconv/span_kinds.py +0 -2
  122. agentops/validation.py +102 -63
  123. {mseep_agentops-0.4.18.dist-info → mseep_agentops-0.4.23.dist-info}/METADATA +30 -40
  124. mseep_agentops-0.4.23.dist-info/RECORD +178 -0
  125. {mseep_agentops-0.4.18.dist-info → mseep_agentops-0.4.23.dist-info}/WHEEL +1 -2
  126. mseep_agentops-0.4.18.dist-info/RECORD +0 -94
  127. mseep_agentops-0.4.18.dist-info/top_level.txt +0 -2
  128. tests/conftest.py +0 -10
  129. tests/unit/client/__init__.py +0 -1
  130. tests/unit/client/test_http_adapter.py +0 -221
  131. tests/unit/client/test_http_client.py +0 -206
  132. tests/unit/conftest.py +0 -54
  133. tests/unit/sdk/__init__.py +0 -1
  134. tests/unit/sdk/instrumentation_tester.py +0 -207
  135. tests/unit/sdk/test_attributes.py +0 -392
  136. tests/unit/sdk/test_concurrent_instrumentation.py +0 -468
  137. tests/unit/sdk/test_decorators.py +0 -763
  138. tests/unit/sdk/test_exporters.py +0 -241
  139. tests/unit/sdk/test_factory.py +0 -1188
  140. tests/unit/sdk/test_internal_span_processor.py +0 -397
  141. tests/unit/sdk/test_resource_attributes.py +0 -35
  142. tests/unit/test_config.py +0 -82
  143. tests/unit/test_context_manager.py +0 -777
  144. tests/unit/test_events.py +0 -27
  145. tests/unit/test_host_env.py +0 -54
  146. tests/unit/test_init_py.py +0 -501
  147. tests/unit/test_serialization.py +0 -433
  148. tests/unit/test_session.py +0 -676
  149. tests/unit/test_user_agent.py +0 -34
  150. tests/unit/test_validation.py +0 -405
  151. {tests → agentops/instrumentation/agentic/openai_agents/attributes}/__init__.py +0 -0
  152. /tests/unit/__init__.py → /agentops/instrumentation/providers/openai/attributes/tools.py +0 -0
  153. {mseep_agentops-0.4.18.dist-info → mseep_agentops-0.4.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,877 @@
1
+ """Xpander SDK instrumentation for AgentOps.
2
+
3
+ This module provides instrumentation for the Xpander SDK, which uses JSII to convert
4
+ TypeScript code to Python at runtime. The instrumentation tracks agent sessions,
5
+ tool executions, and LLM interactions.
6
+
7
+ MODIFIED VERSION: Using existing AgentOps utilities where possible while keeping
8
+ runtime-specific instrumentation logic that cannot be replaced.
9
+
10
+ REPLACEMENTS MADE:
11
+ ✅ Span creation: Using tracer.make_span() instead of manual span creation
12
+ ✅ Error handling: Using _finish_span_success/_finish_span_error utilities
13
+ ✅ Attribute management: Using existing SpanAttributeManager
14
+ ✅ Serialization: Using safe_serialize and model_to_dict utilities
15
+ ✅ Attribute setting: Using _update_span utility
16
+
17
+ RUNTIME-SPECIFIC LOGIC KEPT (Cannot be replaced):
18
+ ❌ Method wrapping: Runtime method creation requires custom hooks
19
+ ❌ Context persistence: XpanderContext must handle runtime object lifecycle
20
+ ❌ Agent detection: Custom logic for dynamically created agents
21
+ """
22
+
23
+ import logging
24
+ import time
25
+ import json
26
+ from typing import Any, Optional
27
+ from opentelemetry.metrics import Meter
28
+ from opentelemetry.trace import SpanKind as OTelSpanKind
29
+ from opentelemetry import trace
30
+
31
+ # Use existing AgentOps utilities
32
+ from agentops.instrumentation.common import (
33
+ CommonInstrumentor,
34
+ InstrumentorConfig,
35
+ StandardMetrics,
36
+ )
37
+ from agentops.instrumentation.common.span_management import SpanAttributeManager
38
+ from agentops.instrumentation.common.wrappers import _finish_span_success, _finish_span_error, _update_span
39
+ from agentops.helpers.serialization import safe_serialize, model_to_dict
40
+ from agentops.sdk.core import tracer
41
+ from agentops.instrumentation.agentic.xpander.context import XpanderContext
42
+ from agentops.semconv import SpanAttributes, SpanKind, ToolAttributes
43
+ from agentops.semconv.message import MessageAttributes
44
+
45
+ # Use existing OpenAI attribute extraction patterns (lazy import to avoid circular imports)
46
+ # from agentops.instrumentation.providers.openai.attributes.common import (
47
+ # get_response_attributes,
48
+ # )
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ _instruments = ("xpander-sdk >= 1.0.0",)
53
+
54
+
55
+ # Use existing AgentOps utility instead of custom implementation
56
+ def safe_set_attribute(span, key: str, value: Any) -> None:
57
+ """Set attribute on span using existing AgentOps utility."""
58
+ try:
59
+ _update_span(span, {key: value})
60
+ except Exception as e:
61
+ logger.warning(f"Failed to set attribute {key}: {e}")
62
+
63
+
64
+ class XpanderInstrumentor(CommonInstrumentor):
65
+ """Instrumentor for Xpander SDK interactions."""
66
+
67
+ def __init__(self, config: Optional[InstrumentorConfig] = None):
68
+ if config is None:
69
+ config = InstrumentorConfig(
70
+ library_name="xpander-sdk", library_version="1.0.0", dependencies=_instruments, metrics_enabled=True
71
+ )
72
+ super().__init__(config)
73
+ self._context = XpanderContext()
74
+ self._tracer = None
75
+ # Use existing AgentOps attribute manager
76
+ self._attribute_manager = SpanAttributeManager("xpander-service", "production")
77
+
78
+ def _get_session_id_from_agent(self, agent) -> str:
79
+ """Generate consistent session ID from agent."""
80
+ # First try to get memory_thread_id from agent context if available
81
+ if hasattr(agent, "memory_thread_id"):
82
+ return f"session_{agent.memory_thread_id}"
83
+
84
+ # Check for execution context
85
+ if hasattr(agent, "execution") and hasattr(agent.execution, "memory_thread_id"):
86
+ return f"session_{agent.execution.memory_thread_id}"
87
+
88
+ # Fallback to agent-based ID
89
+ agent_name = getattr(agent, "name", "unknown")
90
+ agent_id = getattr(agent, "id", "unknown")
91
+ return f"agent_{agent_name}_{agent_id}"
92
+
93
+ def _extract_session_id(self, execution, agent=None) -> str:
94
+ """Extract session ID from execution data."""
95
+ if isinstance(execution, dict):
96
+ if "memory_thread_id" in execution:
97
+ return f"session_{execution['memory_thread_id']}"
98
+ elif "thread_id" in execution:
99
+ return f"session_{execution['thread_id']}"
100
+ elif "session_id" in execution:
101
+ return f"session_{execution['session_id']}"
102
+
103
+ # Fallback to agent-based ID if available
104
+ if agent:
105
+ return self._get_session_id_from_agent(agent)
106
+
107
+ # Last resort fallback
108
+ return f"session_{int(time.time())}"
109
+
110
+ def _extract_tool_name(self, tool_call) -> str:
111
+ """Extract tool name from tool call."""
112
+ # Handle different tool call formats
113
+ if hasattr(tool_call, "function_name"):
114
+ return tool_call.function_name
115
+ elif hasattr(tool_call, "function") and hasattr(tool_call.function, "name"):
116
+ return tool_call.function.name
117
+ elif hasattr(tool_call, "name"):
118
+ return tool_call.name
119
+ elif isinstance(tool_call, dict):
120
+ if "function" in tool_call:
121
+ return tool_call["function"].get("name", "unknown")
122
+ elif "function_name" in tool_call:
123
+ return tool_call["function_name"]
124
+ elif "name" in tool_call:
125
+ return tool_call["name"]
126
+
127
+ # Try to extract from string representation
128
+ import re
129
+
130
+ patterns = [
131
+ r'function[\'"]\s*:\s*[\'"]([^\'"]+)[\'"]',
132
+ r'name[\'"]\s*:\s*[\'"]([^\'"]+)[\'"]',
133
+ r"([a-zA-Z_][a-zA-Z0-9_]*)\.tool",
134
+ r'function_name[\'"]\s*:\s*[\'"]([^\'"]+)[\'"]',
135
+ ]
136
+
137
+ tool_str = str(tool_call)
138
+ for pattern in patterns:
139
+ match = re.search(pattern, tool_str, re.IGNORECASE)
140
+ if match:
141
+ return match.group(1)
142
+
143
+ return "unknown"
144
+
145
+ def _extract_tool_params(self, tool_call) -> dict:
146
+ """Extract tool parameters from tool call."""
147
+ # Handle different parameter formats
148
+ if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
149
+ try:
150
+ args = tool_call.function.arguments
151
+ if isinstance(args, str):
152
+ return json.loads(args)
153
+ elif isinstance(args, dict):
154
+ return args
155
+ except (json.JSONDecodeError, AttributeError):
156
+ pass
157
+ elif hasattr(tool_call, "arguments"):
158
+ try:
159
+ args = tool_call.arguments
160
+ if isinstance(args, str):
161
+ return json.loads(args)
162
+ elif isinstance(args, dict):
163
+ return args
164
+ except (json.JSONDecodeError, AttributeError):
165
+ pass
166
+ elif isinstance(tool_call, dict):
167
+ if "function" in tool_call:
168
+ args = tool_call["function"].get("arguments", "{}")
169
+ try:
170
+ return json.loads(args) if isinstance(args, str) else args
171
+ except json.JSONDecodeError:
172
+ pass
173
+ elif "arguments" in tool_call:
174
+ args = tool_call["arguments"]
175
+ try:
176
+ return json.loads(args) if isinstance(args, str) else args
177
+ except json.JSONDecodeError:
178
+ pass
179
+
180
+ return {}
181
+
182
+ def _extract_llm_data_from_messages(self, messages) -> dict:
183
+ """Extract LLM metadata from messages."""
184
+ data = {}
185
+
186
+ if isinstance(messages, dict):
187
+ # Direct model and usage fields
188
+ if "model" in messages:
189
+ data["model"] = messages["model"]
190
+ if "usage" in messages:
191
+ data["usage"] = messages["usage"]
192
+
193
+ # Check in choices array (OpenAI format)
194
+ if "choices" in messages and messages["choices"]:
195
+ choice = messages["choices"][0]
196
+ if "message" in choice:
197
+ message = choice["message"]
198
+ if "model" in message:
199
+ data["model"] = message["model"]
200
+
201
+ elif isinstance(messages, list):
202
+ # Look for assistant messages with metadata
203
+ for msg in messages:
204
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
205
+ if "model" in msg:
206
+ data["model"] = msg["model"]
207
+ if "usage" in msg:
208
+ data["usage"] = msg["usage"]
209
+ break
210
+
211
+ # Try to extract from any nested structures
212
+ if not data and hasattr(messages, "__dict__"):
213
+ msg_dict = messages.__dict__
214
+ if "model" in msg_dict:
215
+ data["model"] = msg_dict["model"]
216
+ if "usage" in msg_dict:
217
+ data["usage"] = msg_dict["usage"]
218
+
219
+ return data
220
+
221
+ def _extract_and_set_openai_message_attributes(self, span, messages, result, agent=None):
222
+ """Extract and set OpenAI message attributes from messages and response."""
223
+ try:
224
+ # Manual extraction since we don't need the OpenAI module for this
225
+ # Try to get the agent's current message history for prompts
226
+ agent_messages = []
227
+ if agent and hasattr(agent, "messages"):
228
+ agent_messages = getattr(agent, "messages", [])
229
+ elif agent and hasattr(agent, "conversation_history"):
230
+ agent_messages = getattr(agent, "conversation_history", [])
231
+ elif agent and hasattr(agent, "history"):
232
+ agent_messages = getattr(agent, "history", [])
233
+
234
+ # Also try to extract messages from the messages parameter itself
235
+ if isinstance(messages, list):
236
+ # If messages is a list of messages, use it directly
237
+ agent_messages.extend(messages)
238
+ elif isinstance(messages, dict) and "messages" in messages:
239
+ # If messages contains a messages key
240
+ agent_messages.extend(messages.get("messages", []))
241
+
242
+ # Set prompt messages (input to LLM)
243
+ prompt_index = 0
244
+ for msg in agent_messages[-10:]: # Get last 10 messages to avoid huge context
245
+ if isinstance(msg, dict):
246
+ role = msg.get("role", "user")
247
+ content = msg.get("content", "")
248
+
249
+ # Handle different content formats
250
+ if content and isinstance(content, str) and content.strip():
251
+ safe_set_attribute(span, MessageAttributes.PROMPT_ROLE.format(i=prompt_index), role)
252
+ safe_set_attribute(
253
+ span, MessageAttributes.PROMPT_CONTENT.format(i=prompt_index), content[:2000]
254
+ )
255
+ prompt_index += 1
256
+ elif content and isinstance(content, list):
257
+ # Handle multi-modal content
258
+ content_str = str(content)[:2000]
259
+ safe_set_attribute(span, MessageAttributes.PROMPT_ROLE.format(i=prompt_index), role)
260
+ safe_set_attribute(span, MessageAttributes.PROMPT_CONTENT.format(i=prompt_index), content_str)
261
+ prompt_index += 1
262
+ elif hasattr(msg, "content"):
263
+ # Handle object with content attribute
264
+ content = getattr(msg, "content", "")
265
+ role = getattr(msg, "role", "user")
266
+ if content and isinstance(content, str) and content.strip():
267
+ safe_set_attribute(span, MessageAttributes.PROMPT_ROLE.format(i=prompt_index), role)
268
+ safe_set_attribute(
269
+ span, MessageAttributes.PROMPT_CONTENT.format(i=prompt_index), str(content)[:2000]
270
+ )
271
+ prompt_index += 1
272
+
273
+ # Set completion messages (response from LLM)
274
+ completion_index = 0
275
+ response_data = result if result else messages
276
+
277
+ # Handle different response formats
278
+ if isinstance(response_data, dict):
279
+ choices = response_data.get("choices", [])
280
+ for choice in choices:
281
+ message = choice.get("message", {})
282
+ role = message.get("role", "assistant")
283
+ content = message.get("content", "")
284
+
285
+ if content:
286
+ safe_set_attribute(span, MessageAttributes.COMPLETION_ROLE.format(i=completion_index), role)
287
+ safe_set_attribute(
288
+ span, MessageAttributes.COMPLETION_CONTENT.format(i=completion_index), content[:2000]
289
+ )
290
+
291
+ # Handle tool calls in the response
292
+ tool_calls = message.get("tool_calls", [])
293
+ for j, tool_call in enumerate(tool_calls):
294
+ tool_id = tool_call.get("id", "")
295
+ tool_name = tool_call.get("function", {}).get("name", "")
296
+ tool_args = tool_call.get("function", {}).get("arguments", "")
297
+
298
+ if tool_id:
299
+ safe_set_attribute(
300
+ span, MessageAttributes.COMPLETION_TOOL_CALL_ID.format(i=completion_index, j=j), tool_id
301
+ )
302
+ if tool_name:
303
+ safe_set_attribute(
304
+ span,
305
+ MessageAttributes.COMPLETION_TOOL_CALL_NAME.format(i=completion_index, j=j),
306
+ tool_name,
307
+ )
308
+ if tool_args:
309
+ safe_set_attribute(
310
+ span,
311
+ MessageAttributes.COMPLETION_TOOL_CALL_ARGUMENTS.format(i=completion_index, j=j),
312
+ tool_args[:500],
313
+ )
314
+ safe_set_attribute(
315
+ span,
316
+ MessageAttributes.COMPLETION_TOOL_CALL_TYPE.format(i=completion_index, j=j),
317
+ "function",
318
+ )
319
+
320
+ completion_index += 1
321
+ elif hasattr(response_data, "choices"):
322
+ # Handle response object with choices attribute
323
+ choices = getattr(response_data, "choices", [])
324
+ for choice in choices:
325
+ message = getattr(choice, "message", None)
326
+ if message:
327
+ role = getattr(message, "role", "assistant")
328
+ content = getattr(message, "content", "")
329
+
330
+ if content:
331
+ safe_set_attribute(span, MessageAttributes.COMPLETION_ROLE.format(i=completion_index), role)
332
+ safe_set_attribute(
333
+ span,
334
+ MessageAttributes.COMPLETION_CONTENT.format(i=completion_index),
335
+ str(content)[:2000],
336
+ )
337
+
338
+ # Handle tool calls
339
+ tool_calls = getattr(message, "tool_calls", [])
340
+ for j, tool_call in enumerate(tool_calls):
341
+ tool_id = getattr(tool_call, "id", "")
342
+ function = getattr(tool_call, "function", None)
343
+ if function:
344
+ tool_name = getattr(function, "name", "")
345
+ tool_args = getattr(function, "arguments", "")
346
+
347
+ if tool_id:
348
+ safe_set_attribute(
349
+ span,
350
+ MessageAttributes.COMPLETION_TOOL_CALL_ID.format(i=completion_index, j=j),
351
+ tool_id,
352
+ )
353
+ if tool_name:
354
+ safe_set_attribute(
355
+ span,
356
+ MessageAttributes.COMPLETION_TOOL_CALL_NAME.format(i=completion_index, j=j),
357
+ tool_name,
358
+ )
359
+ if tool_args:
360
+ safe_set_attribute(
361
+ span,
362
+ MessageAttributes.COMPLETION_TOOL_CALL_ARGUMENTS.format(
363
+ i=completion_index, j=j
364
+ ),
365
+ str(tool_args)[:500],
366
+ )
367
+ safe_set_attribute(
368
+ span,
369
+ MessageAttributes.COMPLETION_TOOL_CALL_TYPE.format(i=completion_index, j=j),
370
+ "function",
371
+ )
372
+
373
+ completion_index += 1
374
+
375
+ except Exception as e:
376
+ logger.error(f"Error extracting OpenAI message attributes: {e}")
377
+
378
+ def _wrap_init_task(self, original_method):
379
+ """Wrap init_task and add_task to create agent span hierarchy."""
380
+ instrumentor = self
381
+
382
+ def wrapper(self, execution=None, input=None, **kwargs):
383
+ # Normalize parameters - handle both add_task(input=...) and init_task(execution=...)
384
+ if execution is None and input is not None:
385
+ # add_task call with input parameter - normalize to execution format
386
+ if isinstance(input, str):
387
+ execution = {"input": {"text": input}}
388
+ else:
389
+ execution = {"input": input}
390
+ elif execution is None:
391
+ # Neither execution nor input provided - create empty execution
392
+ execution = {}
393
+
394
+ # Extract session ID and agent info
395
+ session_id = instrumentor._extract_session_id(execution)
396
+ agent_name = getattr(self, "name", "unknown")
397
+ agent_id = getattr(self, "id", "unknown")
398
+
399
+ # Check if session already exists
400
+ existing_session = instrumentor._context.get_session(session_id)
401
+ if existing_session:
402
+ # Session already exists, just continue
403
+ # Call with original parameters
404
+ if input is not None:
405
+ result = original_method(self, input=input, **kwargs)
406
+ else:
407
+ result = original_method(self, execution)
408
+ return result
409
+
410
+ # Extract task input
411
+ task_input = None
412
+ if isinstance(execution, dict):
413
+ if "input" in execution:
414
+ input_data = execution["input"]
415
+ if isinstance(input_data, dict) and "text" in input_data:
416
+ task_input = input_data["text"]
417
+ elif isinstance(input_data, str):
418
+ task_input = input_data
419
+
420
+ # Create top-level conversation/session span - this is the ROOT span
421
+ conversation_span_attributes = {
422
+ SpanAttributes.AGENTOPS_ENTITY_NAME: f"Session - {agent_name}",
423
+ "xpander.span.type": "session",
424
+ "xpander.session.name": f"Session - {agent_name}",
425
+ "xpander.agent.name": agent_name,
426
+ "xpander.agent.id": agent_id,
427
+ "xpander.session.id": session_id,
428
+ }
429
+ session_span, session_ctx, session_token = tracer.make_span(
430
+ operation_name=f"session.{agent_name}",
431
+ span_kind=SpanKind.AGENT, # Use AGENT kind for the root session span
432
+ attributes=conversation_span_attributes,
433
+ )
434
+
435
+ # Set task input on session span
436
+ if task_input:
437
+ safe_set_attribute(session_span, SpanAttributes.AGENTOPS_ENTITY_INPUT, task_input[:1000])
438
+ safe_set_attribute(session_span, "xpander.session.initial_input", task_input[:500])
439
+
440
+ # Create workflow span as child of session span (this will be the main execution span)
441
+ trace.set_span_in_context(session_span)
442
+ workflow_span_attributes = {
443
+ "xpander.span.type": "workflow",
444
+ "xpander.workflow.phase": "planning",
445
+ "xpander.agent.name": agent_name,
446
+ "xpander.agent.id": agent_id,
447
+ "xpander.session.id": session_id,
448
+ "agent.name": agent_name,
449
+ "agent.id": agent_id,
450
+ }
451
+ workflow_span, workflow_ctx, workflow_token = tracer.make_span(
452
+ operation_name=f"workflow.{agent_name}",
453
+ span_kind=SpanKind.WORKFLOW,
454
+ attributes=workflow_span_attributes,
455
+ )
456
+
457
+ # No separate agent span - workflow span contains all agent info
458
+
459
+ # Initialize workflow state with persistent spans
460
+ agent_info = {
461
+ "agent_name": agent_name,
462
+ "agent_id": agent_id,
463
+ "task_input": task_input,
464
+ "thread_id": execution.get("memory_thread_id") if isinstance(execution, dict) else None,
465
+ }
466
+ instrumentor._context.start_session(session_id, agent_info, workflow_span, None) # No agent span
467
+ # Store the session span as well
468
+ instrumentor._context.start_conversation(session_id, session_span)
469
+
470
+ try:
471
+ # Execute original method - don't end agent span here, it will be ended in retrieve_execution_result
472
+ # Call with original parameters
473
+ if input is not None:
474
+ result = original_method(self, input=input, **kwargs)
475
+ else:
476
+ result = original_method(self, execution)
477
+ return result
478
+ except Exception as e:
479
+ # Use existing AgentOps error handling utilities
480
+ _finish_span_error(workflow_span, e)
481
+ raise
482
+
483
+ return wrapper
484
+
485
+ def _wrap_run_tools(self, original_method):
486
+ """Wrap run_tools to create execution phase tool spans."""
487
+ instrumentor = self
488
+
489
+ def wrapper(self, tool_calls, payload_extension=None):
490
+ session_id = instrumentor._get_session_id_from_agent(self)
491
+ current_session = instrumentor._context.get_session(session_id)
492
+
493
+ # Update workflow state
494
+ step_num = (current_session.get("step_count", 0) + 1) if current_session else 1
495
+ instrumentor._context.update_session(
496
+ session_id,
497
+ {
498
+ "step_count": step_num,
499
+ "phase": "executing",
500
+ "tools_executed": (current_session.get("tools_executed", []) if current_session else [])
501
+ + [instrumentor._extract_tool_name(tc) for tc in tool_calls],
502
+ },
503
+ )
504
+
505
+ # Get current span context (should be the LLM span)
506
+ current_span = trace.get_current_span()
507
+
508
+ # Create execution phase span as child of current LLM span
509
+ execution_span_context = trace.set_span_in_context(current_span) if current_span else None
510
+
511
+ with instrumentor._tracer.start_as_current_span(
512
+ "xpander.execution",
513
+ kind=OTelSpanKind.INTERNAL,
514
+ context=execution_span_context,
515
+ attributes={
516
+ SpanAttributes.AGENTOPS_SPAN_KIND: SpanKind.TASK,
517
+ "xpander.span.type": "execution",
518
+ "xpander.workflow.phase": "executing",
519
+ "xpander.step.number": step_num,
520
+ "xpander.step.tool_count": len(tool_calls),
521
+ "xpander.session.id": session_id,
522
+ },
523
+ ) as execution_span:
524
+ # Execute tools and create individual tool spans
525
+ results = []
526
+ conversation_finished = False
527
+
528
+ for i, tool_call in enumerate(tool_calls):
529
+ tool_name = instrumentor._extract_tool_name(tool_call)
530
+ tool_params = instrumentor._extract_tool_params(tool_call)
531
+
532
+ # Check if this is the conversation finish tool
533
+ if tool_name == "xpfinish-agent-execution-finished":
534
+ conversation_finished = True
535
+
536
+ start_time = time.time()
537
+
538
+ # Create tool span as child of execution span
539
+ tool_span_context = trace.set_span_in_context(execution_span)
540
+
541
+ with instrumentor._tracer.start_as_current_span(
542
+ f"tool.{tool_name}",
543
+ kind=OTelSpanKind.CLIENT,
544
+ context=tool_span_context,
545
+ attributes={
546
+ SpanAttributes.AGENTOPS_SPAN_KIND: SpanKind.TOOL,
547
+ ToolAttributes.TOOL_NAME: tool_name,
548
+ ToolAttributes.TOOL_PARAMETERS: str(tool_params)[:500],
549
+ "xpander.span.type": "tool",
550
+ "xpander.workflow.phase": "executing",
551
+ "xpander.tool.step": step_num,
552
+ "xpander.tool.index": i,
553
+ },
554
+ ) as tool_span:
555
+ # Execute single tool
556
+ single_result = original_method(self, [tool_call], payload_extension)
557
+ results.extend(single_result)
558
+
559
+ # Record tool execution details
560
+ execution_time = time.time() - start_time
561
+ safe_set_attribute(tool_span, "xpander.tool.execution_time", execution_time)
562
+
563
+ # Add tool result if available
564
+ if single_result:
565
+ result_summary = f"Executed successfully with {len(single_result)} results"
566
+ safe_set_attribute(tool_span, "xpander.tool.result_summary", result_summary)
567
+
568
+ # Store actual result data using existing AgentOps utilities
569
+ try:
570
+ result_content = ""
571
+
572
+ for i, result_item in enumerate(single_result):
573
+ # Handle xpander_sdk.ToolCallResult objects specifically
574
+ if hasattr(result_item, "__class__") and "ToolCallResult" in str(type(result_item)):
575
+ # Extract the actual result content from ToolCallResult
576
+ try:
577
+ if hasattr(result_item, "result") and result_item.result is not None:
578
+ actual_result = result_item.result
579
+ if isinstance(actual_result, str):
580
+ result_content += actual_result[:1000] + "\n"
581
+ else:
582
+ result_content += safe_serialize(actual_result)[:1000] + "\n"
583
+ elif hasattr(result_item, "data") and result_item.data is not None:
584
+ result_content += safe_serialize(result_item.data)[:1000] + "\n"
585
+ else:
586
+ # Fallback: try to find any content attribute
587
+ for attr_name in ["content", "output", "value", "response"]:
588
+ if hasattr(result_item, attr_name):
589
+ attr_value = getattr(result_item, attr_name)
590
+ if attr_value is not None:
591
+ result_content += safe_serialize(attr_value)[:1000] + "\n"
592
+ break
593
+ else:
594
+ # If no content attributes found, indicate this
595
+ result_content += "ToolCallResult object (no extractable content)\n"
596
+ except Exception as attr_e:
597
+ logger.debug(f"Error extracting from ToolCallResult: {attr_e}")
598
+ result_content += "ToolCallResult object (extraction failed)\n"
599
+
600
+ # Handle regular objects and primitives
601
+ elif isinstance(result_item, (str, int, float, bool)):
602
+ result_content += str(result_item)[:1000] + "\n"
603
+ elif hasattr(result_item, "__dict__"):
604
+ # Convert objects to dict using existing utility
605
+ result_dict = model_to_dict(result_item)
606
+ result_content += safe_serialize(result_dict)[:1000] + "\n"
607
+ else:
608
+ # Use safe_serialize for consistent conversion
609
+ result_content += safe_serialize(result_item)[:1000] + "\n"
610
+
611
+ if result_content.strip():
612
+ final_content = result_content.strip()[:2000]
613
+ safe_set_attribute(tool_span, ToolAttributes.TOOL_RESULT, final_content)
614
+ else:
615
+ safe_set_attribute(
616
+ tool_span, ToolAttributes.TOOL_RESULT, "No extractable content found"
617
+ )
618
+
619
+ except Exception as e:
620
+ logger.error(f"Error setting tool result: {e}")
621
+ safe_set_attribute(
622
+ tool_span, ToolAttributes.TOOL_RESULT, f"Error capturing result: {e}"
623
+ )
624
+ else:
625
+ safe_set_attribute(tool_span, "xpander.tool.result_summary", "No results returned")
626
+
627
+ # If conversation is finished, mark for session closure
628
+ if conversation_finished:
629
+ # Since session span is now the conversation span, we need to close all spans
630
+ # when the conversation finishes
631
+ pass # Session closure will be handled in retrieve_execution_result
632
+
633
+ return results
634
+
635
+ return wrapper
636
+
637
+ def _wrap_add_messages(self, original_method):
638
+ """Wrap add_messages to create LLM spans with proper parent-child relationship."""
639
+ instrumentor = self
640
+
641
+ def wrapper(self, messages):
642
+ session_id = instrumentor._get_session_id_from_agent(self)
643
+ current_session = instrumentor._context.get_session(session_id)
644
+ current_phase = instrumentor._context.get_workflow_phase(session_id)
645
+ workflow_span = instrumentor._context.get_workflow_span(session_id)
646
+
647
+ # Create LLM span as child of workflow span (not conversation span)
648
+ # The hierarchy should be: session -> agent/workflow -> LLM -> execution -> tools
649
+ llm_span_context = trace.set_span_in_context(workflow_span) if workflow_span else None
650
+
651
+ # Call original method first to get the actual OpenAI response
652
+ result = original_method(self, messages)
653
+
654
+ # Now create a span that captures the LLM interaction with the actual response data
655
+ with instrumentor._tracer.start_as_current_span(
656
+ f"llm.{current_phase}",
657
+ kind=OTelSpanKind.CLIENT,
658
+ context=llm_span_context,
659
+ attributes={
660
+ SpanAttributes.AGENTOPS_SPAN_KIND: SpanKind.LLM,
661
+ "xpander.span.type": "llm",
662
+ "xpander.workflow.phase": current_phase,
663
+ "xpander.session.id": session_id,
664
+ },
665
+ ) as llm_span:
666
+ # Extract and set OpenAI message data from the messages and response
667
+ instrumentor._extract_and_set_openai_message_attributes(llm_span, messages, result, self)
668
+
669
+ # Extract and set LLM metadata from the result if possible
670
+ llm_data = instrumentor._extract_llm_data_from_messages(result if result else messages)
671
+ if llm_data:
672
+ if "model" in llm_data:
673
+ safe_set_attribute(llm_span, SpanAttributes.LLM_REQUEST_MODEL, llm_data["model"])
674
+ safe_set_attribute(llm_span, SpanAttributes.LLM_RESPONSE_MODEL, llm_data["model"])
675
+
676
+ if "usage" in llm_data:
677
+ usage = llm_data["usage"]
678
+ if "prompt_tokens" in usage:
679
+ safe_set_attribute(llm_span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, usage["prompt_tokens"])
680
+ if "completion_tokens" in usage:
681
+ safe_set_attribute(
682
+ llm_span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, usage["completion_tokens"]
683
+ )
684
+ if "total_tokens" in usage:
685
+ safe_set_attribute(llm_span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage["total_tokens"])
686
+ # Update workflow state
687
+ instrumentor._context.update_session(
688
+ session_id,
689
+ {
690
+ "total_tokens": (current_session.get("total_tokens", 0) if current_session else 0)
691
+ + usage["total_tokens"]
692
+ },
693
+ )
694
+
695
+ return result
696
+
697
+ return wrapper
698
+
699
+ def _wrap_is_finished(self, original_method):
700
+ """Wrap is_finished to track workflow completion."""
701
+ instrumentor = self
702
+
703
+ def wrapper(self):
704
+ result = original_method(self)
705
+
706
+ if result:
707
+ session_id = instrumentor._get_session_id_from_agent(self)
708
+
709
+ # Update session to finished state
710
+ instrumentor._context.update_session(session_id, {"phase": "finished", "end_time": time.time()})
711
+
712
+ return result
713
+
714
+ return wrapper
715
+
716
+ def _wrap_extract_tool_calls(self, original_method):
717
+ """Wrap extract_tool_calls to track tool planning."""
718
+
719
+ def wrapper(self, messages):
720
+ result = original_method(self, messages)
721
+ return result
722
+
723
+ return wrapper
724
+
725
+ def _wrap_report_execution_metrics(self, original_method):
726
+ """Wrap report_execution_metrics to track metrics."""
727
+
728
+ def wrapper(self, llm_tokens=None, ai_model=None):
729
+ result = original_method(self, llm_tokens, ai_model)
730
+ return result
731
+
732
+ return wrapper
733
+
734
+ def _wrap_retrieve_execution_result(self, original_method):
735
+ """Wrap retrieve_execution_result to finalize agent and workflow spans."""
736
+ instrumentor = self
737
+
738
+ def wrapper(self):
739
+ session_id = instrumentor._get_session_id_from_agent(self)
740
+ current_session = instrumentor._context.get_session(session_id)
741
+ workflow_span = instrumentor._context.get_workflow_span(session_id)
742
+ session_span = instrumentor._context.get_conversation_span(session_id) # This is now the root session span
743
+
744
+ try:
745
+ # Execute and capture result
746
+ result = original_method(self)
747
+
748
+ # Add workflow summary to the persistent workflow span
749
+ if workflow_span and current_session:
750
+ safe_set_attribute(
751
+ workflow_span, "xpander.workflow.total_steps", current_session.get("step_count", 0)
752
+ )
753
+ safe_set_attribute(
754
+ workflow_span, "xpander.workflow.total_tokens", current_session.get("total_tokens", 0)
755
+ )
756
+ safe_set_attribute(
757
+ workflow_span, "xpander.workflow.tools_used", len(current_session.get("tools_executed", []))
758
+ )
759
+
760
+ # Calculate total execution time
761
+ start_time = current_session.get("start_time", time.time())
762
+ execution_time = time.time() - start_time
763
+ safe_set_attribute(workflow_span, "xpander.workflow.execution_time", execution_time)
764
+ safe_set_attribute(workflow_span, "xpander.workflow.phase", "completed")
765
+
766
+ # Set result details on session and workflow spans
767
+ if result:
768
+ result_content = ""
769
+ if hasattr(result, "result"):
770
+ result_content = str(result.result)[:1000]
771
+
772
+ # Set on session span (root span)
773
+ if session_span and result_content:
774
+ safe_set_attribute(session_span, SpanAttributes.AGENTOPS_ENTITY_OUTPUT, result_content)
775
+ safe_set_attribute(session_span, "xpander.session.final_result", result_content)
776
+ if hasattr(result, "memory_thread_id"):
777
+ safe_set_attribute(session_span, "xpander.session.thread_id", result.memory_thread_id)
778
+
779
+ if workflow_span:
780
+ if result_content:
781
+ safe_set_attribute(workflow_span, "xpander.result.content", result_content)
782
+ if hasattr(result, "memory_thread_id"):
783
+ safe_set_attribute(workflow_span, "xpander.result.thread_id", result.memory_thread_id)
784
+
785
+ # Add session summary to session span
786
+ if session_span and current_session:
787
+ safe_set_attribute(
788
+ session_span, "xpander.session.total_steps", current_session.get("step_count", 0)
789
+ )
790
+ safe_set_attribute(
791
+ session_span, "xpander.session.total_tokens", current_session.get("total_tokens", 0)
792
+ )
793
+ safe_set_attribute(
794
+ session_span, "xpander.session.tools_used", len(current_session.get("tools_executed", []))
795
+ )
796
+
797
+ start_time = current_session.get("start_time", time.time())
798
+ execution_time = time.time() - start_time
799
+ safe_set_attribute(session_span, "xpander.session.execution_time", execution_time)
800
+
801
+ # Close all spans - session span should be closed last
802
+ if workflow_span:
803
+ _finish_span_success(workflow_span)
804
+ workflow_span.end()
805
+
806
+ if session_span:
807
+ _finish_span_success(session_span)
808
+ session_span.end()
809
+
810
+ return result
811
+
812
+ except Exception as e:
813
+ # Mark spans as failed and close them in proper order
814
+ if workflow_span:
815
+ _finish_span_error(workflow_span, e)
816
+ workflow_span.end()
817
+
818
+ if session_span:
819
+ _finish_span_error(session_span, e)
820
+ session_span.end()
821
+ raise
822
+ finally:
823
+ # Clean up session
824
+ instrumentor._context.end_session(session_id)
825
+
826
+ return wrapper
827
+
828
+ def _instrument(self, **kwargs):
829
+ """Instrument the Xpander SDK."""
830
+ try:
831
+ # Import xpander modules
832
+ from xpander_sdk import Agent
833
+
834
+ # Set up tracing using existing AgentOps tracer
835
+ self._tracer = tracer.get_tracer()
836
+ # Attribute manager already initialized in __init__
837
+
838
+ # Wrap Agent methods
839
+ Agent.add_task = self._wrap_init_task(Agent.add_task)
840
+ Agent.init_task = self._wrap_init_task(Agent.init_task) # Also wrap init_task for completeness
841
+ Agent.run_tools = self._wrap_run_tools(Agent.run_tools)
842
+ Agent.add_messages = self._wrap_add_messages(Agent.add_messages)
843
+ Agent.is_finished = self._wrap_is_finished(Agent.is_finished)
844
+ Agent.extract_tool_calls = self._wrap_extract_tool_calls(Agent.extract_tool_calls)
845
+ Agent.report_execution_metrics = self._wrap_report_execution_metrics(Agent.report_execution_metrics)
846
+ Agent.retrieve_execution_result = self._wrap_retrieve_execution_result(Agent.retrieve_execution_result)
847
+
848
+ except ImportError:
849
+ logger.debug("Xpander SDK not available")
850
+ except Exception as e:
851
+ logger.error(f"Failed to instrument Xpander SDK: {e}")
852
+
853
+ def _uninstrument(self, **kwargs):
854
+ """Uninstrument the Xpander SDK."""
855
+ pass
856
+
857
+ def _create_metrics(self, meter: Meter) -> StandardMetrics:
858
+ """Create metrics for Xpander instrumentation."""
859
+ return StandardMetrics(
860
+ requests_active=meter.create_up_down_counter(
861
+ name="xpander_requests_active",
862
+ description="Number of active Xpander requests",
863
+ ),
864
+ requests_duration=meter.create_histogram(
865
+ name="xpander_requests_duration",
866
+ description="Duration of Xpander requests",
867
+ unit="s",
868
+ ),
869
+ requests_total=meter.create_counter(
870
+ name="xpander_requests_total",
871
+ description="Total number of Xpander requests",
872
+ ),
873
+ requests_error=meter.create_counter(
874
+ name="xpander_requests_error",
875
+ description="Number of Xpander request errors",
876
+ ),
877
+ )