hud-python 0.2.3__py3-none-any.whl → 0.2.5__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.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/__init__.py +22 -2
- hud/adapters/claude/adapter.py +9 -2
- hud/adapters/claude/tests/__init__.py +1 -0
- hud/adapters/claude/tests/test_adapter.py +519 -0
- hud/adapters/common/types.py +5 -1
- hud/adapters/operator/adapter.py +4 -0
- hud/adapters/operator/tests/__init__.py +1 -0
- hud/adapters/operator/tests/test_adapter.py +370 -0
- hud/agent/__init__.py +4 -0
- hud/agent/base.py +18 -2
- hud/agent/claude.py +20 -17
- hud/agent/claude_plays_pokemon.py +282 -0
- hud/agent/langchain.py +12 -7
- hud/agent/misc/__init__.py +3 -0
- hud/agent/misc/response_agent.py +80 -0
- hud/agent/operator.py +27 -19
- hud/agent/tests/__init__.py +1 -0
- hud/agent/tests/test_base.py +202 -0
- hud/env/docker_client.py +28 -18
- hud/env/environment.py +33 -17
- hud/env/local_docker_client.py +83 -42
- hud/env/remote_client.py +1 -3
- hud/env/remote_docker_client.py +72 -15
- hud/exceptions.py +12 -0
- hud/gym.py +71 -53
- hud/job.py +52 -7
- hud/settings.py +6 -0
- hud/task.py +45 -33
- hud/taskset.py +44 -4
- hud/telemetry/__init__.py +21 -0
- hud/telemetry/_trace.py +173 -0
- hud/telemetry/context.py +193 -0
- hud/telemetry/exporter.py +417 -0
- hud/telemetry/instrumentation/__init__.py +3 -0
- hud/telemetry/instrumentation/mcp.py +498 -0
- hud/telemetry/instrumentation/registry.py +59 -0
- hud/telemetry/mcp_models.py +331 -0
- hud/telemetry/tests/__init__.py +1 -0
- hud/telemetry/tests/test_context.py +203 -0
- hud/telemetry/tests/test_trace.py +270 -0
- hud/types.py +10 -26
- hud/utils/common.py +22 -2
- hud/utils/misc.py +53 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +7 -0
- {hud_python-0.2.3.dist-info → hud_python-0.2.5.dist-info}/METADATA +90 -22
- hud_python-0.2.5.dist-info/RECORD +84 -0
- hud_python-0.2.3.dist-info/RECORD +0 -62
- {hud_python-0.2.3.dist-info → hud_python-0.2.5.dist-info}/WHEEL +0 -0
- {hud_python-0.2.3.dist-info → hud_python-0.2.5.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable
|
|
10
|
+
|
|
11
|
+
# from dataclasses import dataclass # Likely unused now
|
|
12
|
+
from mcp.shared.message import SessionMessage # type: ignore
|
|
13
|
+
|
|
14
|
+
# MCP type imports for type checking and attribute access safety
|
|
15
|
+
from mcp.types import ( # type: ignore
|
|
16
|
+
JSONRPCError,
|
|
17
|
+
JSONRPCNotification,
|
|
18
|
+
JSONRPCRequest,
|
|
19
|
+
JSONRPCResponse,
|
|
20
|
+
)
|
|
21
|
+
from wrapt import register_post_import_hook, wrap_function_wrapper
|
|
22
|
+
|
|
23
|
+
from hud.telemetry.context import (
|
|
24
|
+
buffer_mcp_call,
|
|
25
|
+
create_notification_record,
|
|
26
|
+
create_request_record,
|
|
27
|
+
create_response_record,
|
|
28
|
+
get_current_task_run_id,
|
|
29
|
+
)
|
|
30
|
+
from hud.telemetry.mcp_models import DirectionType, MCPCallType, MCPManualTestCall, StatusType
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Ensure no OTel imports remain
|
|
35
|
+
# from opentelemetry import context as otel_context, propagate # Should be removed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCPInstrumentor:
|
|
39
|
+
"""
|
|
40
|
+
Instrumentor for MCP calls.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self._installed = False
|
|
45
|
+
|
|
46
|
+
def install(self) -> None:
|
|
47
|
+
"""Install instrumentation for MCP"""
|
|
48
|
+
logger.debug("MCPInstrumentor: install() called")
|
|
49
|
+
if self._installed:
|
|
50
|
+
logger.debug("MCP instrumentation already installed")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
# Updated hooks: removed _transport_wrapper and _base_session_init_wrapper related hooks
|
|
54
|
+
# if they become no-ops. For now, let's assume _transport_wrapper might still be used,
|
|
55
|
+
# but simplified. _base_session_init_wrapper hook is removed.
|
|
56
|
+
hooks = [
|
|
57
|
+
("mcp.client.sse", "sse_client", self._transport_wrapper),
|
|
58
|
+
("mcp.server.sse", "SseServerTransport.connect_sse", self._transport_wrapper),
|
|
59
|
+
("mcp.client.stdio", "stdio_client", self._transport_wrapper),
|
|
60
|
+
("mcp.server.stdio", "stdio_server", self._transport_wrapper),
|
|
61
|
+
("mcp.shared.session", "BaseSession._receive_loop", self._receive_loop_wrapper),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def create_hook(module: str, func_name: str, wrapper: Any) -> Any:
|
|
65
|
+
return lambda _: wrap_function_wrapper(module, func_name, wrapper)
|
|
66
|
+
|
|
67
|
+
for module, func_name, wrapper in hooks:
|
|
68
|
+
logger.debug(
|
|
69
|
+
"MCPInstrumentor: Registering post-import hook for %s.%s", module, func_name
|
|
70
|
+
)
|
|
71
|
+
register_post_import_hook(
|
|
72
|
+
create_hook(module, func_name, wrapper),
|
|
73
|
+
module,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Removed hook for BaseSession.__init__ (it was for _base_session_init_wrapper)
|
|
77
|
+
|
|
78
|
+
logger.debug(
|
|
79
|
+
"MCPInstrumentor: Attempting immediate instrumentation of already imported modules."
|
|
80
|
+
)
|
|
81
|
+
for module, func_name, wrapper in hooks:
|
|
82
|
+
try:
|
|
83
|
+
mod = __import__(module, fromlist=[func_name.split(".")[0]])
|
|
84
|
+
target_obj = mod
|
|
85
|
+
parts = func_name.split(".")
|
|
86
|
+
for i, part in enumerate(parts):
|
|
87
|
+
if hasattr(target_obj, part):
|
|
88
|
+
if i == len(parts) - 1:
|
|
89
|
+
target_obj = getattr(target_obj, part)
|
|
90
|
+
else:
|
|
91
|
+
target_obj = getattr(target_obj, part)
|
|
92
|
+
else:
|
|
93
|
+
target_obj = None
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if target_obj and callable(target_obj):
|
|
97
|
+
logger.debug("MCPInstrumentor: Wrapping %s.%s", module, func_name)
|
|
98
|
+
wrap_function_wrapper(module, func_name, wrapper)
|
|
99
|
+
else:
|
|
100
|
+
logger.debug(
|
|
101
|
+
"Function %s not found in %s during immediate instrumentation attempt",
|
|
102
|
+
func_name,
|
|
103
|
+
module,
|
|
104
|
+
)
|
|
105
|
+
except ImportError:
|
|
106
|
+
logger.debug("Module %s not imported yet, post-import hook will cover it", module)
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.warning("Failed to immediately wrap %s.%s: %s", module, func_name, e)
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# Import only for testing availability, don't store reference
|
|
112
|
+
__import__("mcp.shared.session")
|
|
113
|
+
wrap_function_wrapper(
|
|
114
|
+
"mcp.shared.session",
|
|
115
|
+
"BaseSession.send_request",
|
|
116
|
+
self._send_request_telemetry_wrapper,
|
|
117
|
+
)
|
|
118
|
+
wrap_function_wrapper(
|
|
119
|
+
"mcp.shared.session",
|
|
120
|
+
"BaseSession.send_notification",
|
|
121
|
+
self._send_notification_telemetry_wrapper,
|
|
122
|
+
)
|
|
123
|
+
logger.debug("Successfully wrapped BaseSession methods for telemetry")
|
|
124
|
+
except ImportError:
|
|
125
|
+
logger.debug("mcp.shared.session not imported yet, post-import hook will cover it")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning("Failed to wrap BaseSession methods: %s", e)
|
|
128
|
+
|
|
129
|
+
self._installed = True
|
|
130
|
+
logger.debug("MCP instrumentation installed (simplified)")
|
|
131
|
+
|
|
132
|
+
@asynccontextmanager
|
|
133
|
+
async def _transport_wrapper(
|
|
134
|
+
self, wrapped: Callable[..., Any], instance: Any, args: Any, kwargs: Any
|
|
135
|
+
) -> AsyncGenerator[Any, None]:
|
|
136
|
+
"""Wrap transport functions. Simplified: passes through original streams."""
|
|
137
|
+
original_func_name = f"{wrapped.__module__}.{wrapped.__name__}"
|
|
138
|
+
logger.debug(
|
|
139
|
+
"MCPInstrumentor: _transport_wrapper called for %s (passthrough)", original_func_name
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# No OTel context or HUD Task ID manipulation here anymore for transport layer itself.
|
|
143
|
+
# Higher level wrappers (_send_request, _receive_loop) will handle HUD Task ID.
|
|
144
|
+
|
|
145
|
+
async with wrapped(*args, **kwargs) as (read_stream, write_stream):
|
|
146
|
+
logger.debug("_transport_wrapper: Yielding original streams for %s", original_func_name)
|
|
147
|
+
yield read_stream, write_stream # Pass original streams directly
|
|
148
|
+
logger.debug(
|
|
149
|
+
"_transport_wrapper: Exited original stream context for %s", original_func_name
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def _receive_loop_wrapper(
|
|
153
|
+
self,
|
|
154
|
+
wrapped: Callable[[Any, Any, Any, Any], Awaitable[Any]],
|
|
155
|
+
instance: Any,
|
|
156
|
+
args: Any,
|
|
157
|
+
kwargs: Any,
|
|
158
|
+
) -> Any:
|
|
159
|
+
"""
|
|
160
|
+
Wrap _receive_loop to capture responses and handled incoming messages.
|
|
161
|
+
"""
|
|
162
|
+
logger.debug("MCPInstrumentor: _receive_loop_wrapper called")
|
|
163
|
+
|
|
164
|
+
orig_handle_incoming = instance._handle_incoming
|
|
165
|
+
|
|
166
|
+
async def handle_incoming_with_telemetry(req_or_msg: Any) -> Any:
|
|
167
|
+
start_time = time.time()
|
|
168
|
+
hud_task_run_id = get_current_task_run_id()
|
|
169
|
+
method_name = "unknown_method"
|
|
170
|
+
message_id = None
|
|
171
|
+
is_response_or_error_flag = False
|
|
172
|
+
call_type_override = MCPCallType.HANDLE_INCOMING
|
|
173
|
+
|
|
174
|
+
actual_message_root = None
|
|
175
|
+
# SessionMessage
|
|
176
|
+
if hasattr(req_or_msg, "message") and hasattr(req_or_msg.message, "root"):
|
|
177
|
+
actual_message_root = req_or_msg.message.root
|
|
178
|
+
elif hasattr(req_or_msg, "root"): # e.g. RequestResponder
|
|
179
|
+
actual_message_root = req_or_msg.root
|
|
180
|
+
elif isinstance(
|
|
181
|
+
req_or_msg, JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError
|
|
182
|
+
):
|
|
183
|
+
actual_message_root = req_or_msg
|
|
184
|
+
|
|
185
|
+
if actual_message_root:
|
|
186
|
+
if isinstance(actual_message_root, JSONRPCRequest):
|
|
187
|
+
method_name = actual_message_root.method
|
|
188
|
+
message_id = actual_message_root.id
|
|
189
|
+
# call_type_override can remain HANDLE_INCOMING or be more specific if desired
|
|
190
|
+
elif isinstance(actual_message_root, JSONRPCNotification):
|
|
191
|
+
method_name = actual_message_root.method
|
|
192
|
+
message_id = None # Notifications don't have IDs
|
|
193
|
+
# call_type_override can remain HANDLE_INCOMING
|
|
194
|
+
elif isinstance(actual_message_root, JSONRPCResponse | JSONRPCError):
|
|
195
|
+
# This case implies _handle_incoming is processing a response/error directly
|
|
196
|
+
# (e.g. an error encountered while trying to send/route a response previously)
|
|
197
|
+
message_id = actual_message_root.id
|
|
198
|
+
method_name = f"internal_response_handling_for_id_{message_id}"
|
|
199
|
+
is_response_or_error_flag = True
|
|
200
|
+
# Treat as receiving a response internally
|
|
201
|
+
call_type_override = MCPCallType.RECEIVE_RESPONSE
|
|
202
|
+
else:
|
|
203
|
+
# Fallback for other types, if any, that might appear here
|
|
204
|
+
if hasattr(actual_message_root, "method"):
|
|
205
|
+
method_name = actual_message_root.method
|
|
206
|
+
if hasattr(actual_message_root, "id"):
|
|
207
|
+
message_id = actual_message_root.id
|
|
208
|
+
|
|
209
|
+
record_data_base = {
|
|
210
|
+
"method": method_name,
|
|
211
|
+
"direction": DirectionType.RECEIVED,
|
|
212
|
+
"call_type": call_type_override,
|
|
213
|
+
"message_id": message_id,
|
|
214
|
+
"is_response_or_error": is_response_or_error_flag, # For MCPRequestCall model
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
if hud_task_run_id:
|
|
219
|
+
create_request_record(
|
|
220
|
+
**record_data_base,
|
|
221
|
+
status=StatusType.STARTED,
|
|
222
|
+
start_time=start_time,
|
|
223
|
+
# request_data might be populated if needed for HANDLE_INCOMING
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
result = await orig_handle_incoming(req_or_msg)
|
|
227
|
+
|
|
228
|
+
if hud_task_run_id:
|
|
229
|
+
create_request_record(
|
|
230
|
+
**record_data_base,
|
|
231
|
+
status=StatusType.COMPLETED,
|
|
232
|
+
start_time=start_time,
|
|
233
|
+
end_time=time.time(),
|
|
234
|
+
duration=time.time() - start_time,
|
|
235
|
+
# result_data might be populated if needed
|
|
236
|
+
)
|
|
237
|
+
return result
|
|
238
|
+
except Exception as e:
|
|
239
|
+
if hud_task_run_id:
|
|
240
|
+
create_request_record(
|
|
241
|
+
**record_data_base,
|
|
242
|
+
status=StatusType.ERROR,
|
|
243
|
+
start_time=start_time,
|
|
244
|
+
end_time=time.time(),
|
|
245
|
+
duration=time.time() - start_time,
|
|
246
|
+
error=str(e),
|
|
247
|
+
error_type=type(e).__name__,
|
|
248
|
+
)
|
|
249
|
+
raise
|
|
250
|
+
|
|
251
|
+
instance._handle_incoming = handle_incoming_with_telemetry
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
logger.debug("MCPInstrumentor: Entering instrumented _receive_loop section")
|
|
255
|
+
# The original logic for distinguishing request/notification from response for
|
|
256
|
+
# telemetry:
|
|
257
|
+
# Ensure streams are context managed
|
|
258
|
+
async with instance._read_stream, instance._write_stream:
|
|
259
|
+
async for message in instance._read_stream:
|
|
260
|
+
hud_task_run_id = get_current_task_run_id() # Get ID for each message
|
|
261
|
+
|
|
262
|
+
if isinstance(message, Exception):
|
|
263
|
+
await instance._handle_incoming(message) # Will be wrapped
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Ensure we are dealing with SessionMessage
|
|
267
|
+
if (
|
|
268
|
+
not isinstance(message, SessionMessage)
|
|
269
|
+
or not hasattr(message, "message")
|
|
270
|
+
or not hasattr(message.message, "root")
|
|
271
|
+
):
|
|
272
|
+
logger.warning(
|
|
273
|
+
"Unexpected message type in _receive_loop: %s", type(message)
|
|
274
|
+
)
|
|
275
|
+
await instance._handle_incoming(
|
|
276
|
+
RuntimeError(f"Unknown message type: {message}")
|
|
277
|
+
)
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
msg_root = message.message.root
|
|
281
|
+
|
|
282
|
+
if isinstance(msg_root, JSONRPCRequest | JSONRPCNotification):
|
|
283
|
+
# Let the (wrapped) _handle_incoming deal with these and record telemetry
|
|
284
|
+
await instance._handle_incoming(message)
|
|
285
|
+
elif isinstance(msg_root, JSONRPCResponse | JSONRPCError):
|
|
286
|
+
# This is a direct response/error, record it here
|
|
287
|
+
logger.debug(
|
|
288
|
+
"MCPInstrumentor: Capturing direct response/error ID: %s", msg_root.id
|
|
289
|
+
)
|
|
290
|
+
if hud_task_run_id:
|
|
291
|
+
is_error = isinstance(msg_root, JSONRPCError)
|
|
292
|
+
error_message = None
|
|
293
|
+
error_code = None
|
|
294
|
+
if is_error and hasattr(msg_root, "error"):
|
|
295
|
+
error_message = getattr(msg_root.error, "message", None)
|
|
296
|
+
error_code = str(getattr(msg_root.error, "code", None))
|
|
297
|
+
|
|
298
|
+
create_response_record(
|
|
299
|
+
method=f"response_to_id_{msg_root.id}", # Consistent method naming
|
|
300
|
+
related_request_id=msg_root.id,
|
|
301
|
+
response_id=msg_root.id,
|
|
302
|
+
is_error=is_error,
|
|
303
|
+
response_data=msg_root.model_dump(exclude_none=True),
|
|
304
|
+
error=error_message,
|
|
305
|
+
error_type=error_code,
|
|
306
|
+
direction=DirectionType.RECEIVED,
|
|
307
|
+
call_type=MCPCallType.RECEIVE_RESPONSE,
|
|
308
|
+
timestamp=time.time(), # Add timestamp here for accuracy
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Original logic to pass response to waiting stream
|
|
312
|
+
stream = instance._response_streams.pop(msg_root.id, None)
|
|
313
|
+
if stream:
|
|
314
|
+
await stream.send(msg_root)
|
|
315
|
+
else:
|
|
316
|
+
# This case should ideally be handled by _handle_incoming if it's an
|
|
317
|
+
# unsolicited response
|
|
318
|
+
logger.warning(
|
|
319
|
+
"Received response/error with unknown request ID %s and no "
|
|
320
|
+
"response stream.",
|
|
321
|
+
msg_root.id,
|
|
322
|
+
)
|
|
323
|
+
# Potentially pass to _handle_incoming if that's desired for unroutable
|
|
324
|
+
# responses
|
|
325
|
+
await instance._handle_incoming(message) # Let wrapped handler decide
|
|
326
|
+
else:
|
|
327
|
+
logger.warning(
|
|
328
|
+
"Unknown message root type in _receive_loop: %s", type(msg_root)
|
|
329
|
+
)
|
|
330
|
+
await instance._handle_incoming(message) # Let wrapped handler decide
|
|
331
|
+
|
|
332
|
+
logger.debug("MCPInstrumentor: Exiting instrumented _receive_loop section")
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error("Error in instrumented receive loop: %s, falling back to original", e)
|
|
335
|
+
instance._handle_incoming = orig_handle_incoming
|
|
336
|
+
# Re-raising to ensure original behavior if full loop fails, or call wrapped directly
|
|
337
|
+
# For simplicity now, we just log and let finally restore.
|
|
338
|
+
raise
|
|
339
|
+
finally:
|
|
340
|
+
instance._handle_incoming = orig_handle_incoming
|
|
341
|
+
logger.debug("MCPInstrumentor: Restored original _handle_incoming")
|
|
342
|
+
|
|
343
|
+
async def _send_request_telemetry_wrapper(
|
|
344
|
+
self,
|
|
345
|
+
wrapped: Callable[[Any, Any, Any, Any], Awaitable[Any]],
|
|
346
|
+
instance: Any,
|
|
347
|
+
args: Any,
|
|
348
|
+
kwargs: Any,
|
|
349
|
+
) -> Any:
|
|
350
|
+
start_time = time.time()
|
|
351
|
+
hud_task_run_id = get_current_task_run_id()
|
|
352
|
+
method_name = "unknown_method"
|
|
353
|
+
request_obj_data = None
|
|
354
|
+
request_id = None
|
|
355
|
+
|
|
356
|
+
if args and len(args) > 0:
|
|
357
|
+
request_session_msg = args[0] # Assuming SessionMessage
|
|
358
|
+
if hasattr(request_session_msg, "message") and hasattr(
|
|
359
|
+
request_session_msg.message, "root"
|
|
360
|
+
):
|
|
361
|
+
actual_request = request_session_msg.message.root
|
|
362
|
+
if hasattr(actual_request, "method"):
|
|
363
|
+
method_name = actual_request.method
|
|
364
|
+
if hasattr(actual_request, "id"):
|
|
365
|
+
request_id = actual_request.id
|
|
366
|
+
if hasattr(actual_request, "model_dump"):
|
|
367
|
+
try:
|
|
368
|
+
request_obj_data = actual_request.model_dump(exclude_none=True)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
logger.warning(
|
|
371
|
+
"Could not dump request data for %s: %s",
|
|
372
|
+
method_name,
|
|
373
|
+
e,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
record_data_base = {
|
|
377
|
+
"method": method_name,
|
|
378
|
+
"direction": DirectionType.SENT,
|
|
379
|
+
"call_type": MCPCallType.SEND_REQUEST, # More specific type
|
|
380
|
+
"request_id": request_id,
|
|
381
|
+
"message_id": request_id,
|
|
382
|
+
"request_data": request_obj_data,
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try:
|
|
386
|
+
if hud_task_run_id:
|
|
387
|
+
create_request_record(
|
|
388
|
+
**record_data_base, status=StatusType.STARTED, start_time=start_time
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
result = await wrapped(*args, **kwargs) # result here is usually None for send_request
|
|
392
|
+
|
|
393
|
+
if hud_task_run_id:
|
|
394
|
+
# For send_request, the 'result' is the response future, not the response itself.
|
|
395
|
+
# Completion means the request was successfully sent to the transport.
|
|
396
|
+
# The actual response is captured by _receive_loop_wrapper.
|
|
397
|
+
create_request_record(
|
|
398
|
+
**record_data_base,
|
|
399
|
+
status=StatusType.COMPLETED, # Meaning: successfully dispatched
|
|
400
|
+
start_time=start_time,
|
|
401
|
+
end_time=time.time(),
|
|
402
|
+
duration=time.time() - start_time,
|
|
403
|
+
)
|
|
404
|
+
return result
|
|
405
|
+
except Exception as e:
|
|
406
|
+
if hud_task_run_id:
|
|
407
|
+
create_request_record(
|
|
408
|
+
**record_data_base,
|
|
409
|
+
status=StatusType.ERROR,
|
|
410
|
+
start_time=start_time,
|
|
411
|
+
end_time=time.time(),
|
|
412
|
+
duration=time.time() - start_time,
|
|
413
|
+
error=str(e),
|
|
414
|
+
error_type=type(e).__name__,
|
|
415
|
+
)
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
async def _send_notification_telemetry_wrapper(
|
|
419
|
+
self,
|
|
420
|
+
wrapped: Callable[[Any, Any, Any, Any], Awaitable[Any]],
|
|
421
|
+
instance: Any,
|
|
422
|
+
args: Any,
|
|
423
|
+
kwargs: Any,
|
|
424
|
+
) -> Any:
|
|
425
|
+
start_time = time.time()
|
|
426
|
+
hud_task_run_id = get_current_task_run_id()
|
|
427
|
+
method_name = "unknown_method"
|
|
428
|
+
notification_obj_data = None
|
|
429
|
+
|
|
430
|
+
if args and len(args) > 0:
|
|
431
|
+
notification_session_msg = args[0] # Assuming SessionMessage
|
|
432
|
+
if hasattr(notification_session_msg, "message") and hasattr(
|
|
433
|
+
notification_session_msg.message, "root"
|
|
434
|
+
):
|
|
435
|
+
actual_notification = notification_session_msg.message.root
|
|
436
|
+
if hasattr(actual_notification, "method"):
|
|
437
|
+
method_name = actual_notification.method
|
|
438
|
+
if hasattr(actual_notification, "model_dump"):
|
|
439
|
+
try:
|
|
440
|
+
notification_obj_data = actual_notification.model_dump(exclude_none=True)
|
|
441
|
+
except Exception as e:
|
|
442
|
+
logger.warning(
|
|
443
|
+
"Could not dump notification data for %s: %s",
|
|
444
|
+
method_name,
|
|
445
|
+
e,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
record_data_base = {
|
|
449
|
+
"method": method_name,
|
|
450
|
+
"direction": DirectionType.SENT,
|
|
451
|
+
"call_type": MCPCallType.SEND_NOTIFICATION, # More specific type
|
|
452
|
+
"notification_data": notification_obj_data,
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
if hud_task_run_id:
|
|
457
|
+
create_notification_record(
|
|
458
|
+
**record_data_base, status=StatusType.STARTED, start_time=start_time
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
result = await wrapped(*args, **kwargs) # Usually None
|
|
462
|
+
|
|
463
|
+
if hud_task_run_id:
|
|
464
|
+
create_notification_record(
|
|
465
|
+
**record_data_base,
|
|
466
|
+
status=StatusType.COMPLETED,
|
|
467
|
+
start_time=start_time,
|
|
468
|
+
end_time=time.time(),
|
|
469
|
+
duration=time.time() - start_time,
|
|
470
|
+
)
|
|
471
|
+
return result
|
|
472
|
+
except Exception as e:
|
|
473
|
+
if hud_task_run_id:
|
|
474
|
+
create_notification_record(
|
|
475
|
+
**record_data_base,
|
|
476
|
+
status=StatusType.ERROR,
|
|
477
|
+
start_time=start_time,
|
|
478
|
+
end_time=time.time(),
|
|
479
|
+
duration=time.time() - start_time,
|
|
480
|
+
error=str(e),
|
|
481
|
+
error_type=type(e).__name__,
|
|
482
|
+
)
|
|
483
|
+
raise
|
|
484
|
+
|
|
485
|
+
def record_manual_test(self, **kwargs: Any) -> bool:
|
|
486
|
+
"""Record a manual test telemetry entry"""
|
|
487
|
+
hud_task_run_id = get_current_task_run_id()
|
|
488
|
+
if not hud_task_run_id:
|
|
489
|
+
logger.warning("Manual test not recorded: No active task_run_id")
|
|
490
|
+
return False
|
|
491
|
+
try:
|
|
492
|
+
record = MCPManualTestCall.create(task_run_id=hud_task_run_id, **kwargs)
|
|
493
|
+
buffer_mcp_call(record) # buffer_mcp_call handles Pydantic model directly
|
|
494
|
+
logger.debug("Manual test recorded: %s", record.model_dump(exclude_none=True))
|
|
495
|
+
return True
|
|
496
|
+
except Exception as e:
|
|
497
|
+
logger.warning("Manual test not recorded: %s", e)
|
|
498
|
+
return False
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InstrumentorRegistry:
|
|
10
|
+
"""Registry for telemetry instrumentors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._instrumentors: dict[str, Any] = {}
|
|
14
|
+
|
|
15
|
+
def register(self, name: str, instrumentor: Any) -> None:
|
|
16
|
+
"""Register an instrumentor.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
name: Name of the instrumentor
|
|
20
|
+
instrumentor: The instrumentor instance
|
|
21
|
+
"""
|
|
22
|
+
self._instrumentors[name] = instrumentor
|
|
23
|
+
logger.debug("Registered instrumentor: %s", name)
|
|
24
|
+
|
|
25
|
+
def _safe_install(self, name: str, instrumentor: Any) -> tuple[str, Exception | None]:
|
|
26
|
+
"""Safely install an instrumentor and return result."""
|
|
27
|
+
try:
|
|
28
|
+
instrumentor.install()
|
|
29
|
+
return name, None
|
|
30
|
+
except Exception as e:
|
|
31
|
+
return name, e
|
|
32
|
+
|
|
33
|
+
def install_all(self) -> None:
|
|
34
|
+
"""Install all registered instrumentors."""
|
|
35
|
+
# Use map to apply safe installation to all instrumentors
|
|
36
|
+
installation_results = [
|
|
37
|
+
self._safe_install(name, instrumentor)
|
|
38
|
+
for name, instrumentor in self._instrumentors.items()
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
# Process results
|
|
42
|
+
for name, error in installation_results:
|
|
43
|
+
if error is None:
|
|
44
|
+
logger.debug("Installed instrumentor: %s", name)
|
|
45
|
+
else:
|
|
46
|
+
logger.warning("Failed to install instrumentor %s: %s", name, error)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Create a singleton registry
|
|
50
|
+
registry = InstrumentorRegistry()
|
|
51
|
+
|
|
52
|
+
# Try to register MCP instrumentor if available
|
|
53
|
+
try:
|
|
54
|
+
from .mcp import MCPInstrumentor
|
|
55
|
+
|
|
56
|
+
registry.register("mcp", MCPInstrumentor())
|
|
57
|
+
logger.debug("MCP instrumentor registered")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.debug("Could not register MCP instrumentor: %s", e)
|