hud-python 0.4.1__py3-none-any.whl → 0.4.3__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 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/telemetry/instrument.py
CHANGED
|
@@ -1,379 +1,379 @@
|
|
|
1
|
-
"""General-purpose instrumentation decorator for HUD telemetry.
|
|
2
|
-
|
|
3
|
-
This module provides the instrument() decorator that users can use
|
|
4
|
-
to instrument any function with OpenTelemetry spans.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import asyncio
|
|
10
|
-
import functools
|
|
11
|
-
import inspect
|
|
12
|
-
import json
|
|
13
|
-
import logging
|
|
14
|
-
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
15
|
-
|
|
16
|
-
import pydantic_core
|
|
17
|
-
from opentelemetry import trace
|
|
18
|
-
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
19
|
-
|
|
20
|
-
from hud.otel import configure_telemetry, is_telemetry_configured
|
|
21
|
-
from hud.otel.context import get_current_task_run_id
|
|
22
|
-
|
|
23
|
-
if TYPE_CHECKING:
|
|
24
|
-
from collections.abc import Awaitable, Callable
|
|
25
|
-
from typing import ParamSpec
|
|
26
|
-
|
|
27
|
-
P = ParamSpec("P")
|
|
28
|
-
R = TypeVar("R")
|
|
29
|
-
|
|
30
|
-
logger = logging.getLogger(__name__)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _serialize_value(value: Any, max_items: int = 10) -> Any:
|
|
34
|
-
"""Serialize a value for span attributes.
|
|
35
|
-
|
|
36
|
-
Uses pydantic_core.to_json for robust serialization of complex objects.
|
|
37
|
-
|
|
38
|
-
Args:
|
|
39
|
-
value: The value to serialize
|
|
40
|
-
max_items: Maximum number of items for collections
|
|
41
|
-
|
|
42
|
-
Returns:
|
|
43
|
-
JSON-serializable version of the value
|
|
44
|
-
"""
|
|
45
|
-
# Simple types pass through
|
|
46
|
-
if isinstance(value, str | int | float | bool | type(None)):
|
|
47
|
-
return value
|
|
48
|
-
|
|
49
|
-
# For collections, we need to limit size first
|
|
50
|
-
if isinstance(value, list | tuple):
|
|
51
|
-
value = value[:max_items] if len(value) > max_items else value
|
|
52
|
-
elif isinstance(value, dict) and len(value) > max_items:
|
|
53
|
-
value = dict(list(value.items())[:max_items])
|
|
54
|
-
|
|
55
|
-
# Use pydantic_core for serialization - it handles:
|
|
56
|
-
# - Pydantic models (via model_dump)
|
|
57
|
-
# - Dataclasses (via asdict)
|
|
58
|
-
# - Bytes (encodes to string)
|
|
59
|
-
# - Custom objects (via __dict__ or repr)
|
|
60
|
-
# - Complex nested structures
|
|
61
|
-
try:
|
|
62
|
-
# Convert to JSON bytes then back to Python objects
|
|
63
|
-
# This ensures we get JSON-serializable types
|
|
64
|
-
json_bytes = pydantic_core.to_json(value, fallback=str)
|
|
65
|
-
return json.loads(json_bytes)
|
|
66
|
-
except Exception:
|
|
67
|
-
# Fallback if pydantic_core fails somehow
|
|
68
|
-
return f"<{type(value).__name__}>"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@overload
|
|
72
|
-
def instrument(
|
|
73
|
-
func: None = None,
|
|
74
|
-
*,
|
|
75
|
-
name: str | None = None,
|
|
76
|
-
span_type: str = "function",
|
|
77
|
-
attributes: dict[str, Any] | None = None,
|
|
78
|
-
record_args: bool = True,
|
|
79
|
-
record_result: bool = True,
|
|
80
|
-
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
81
|
-
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
@overload
|
|
85
|
-
def instrument(
|
|
86
|
-
func: Callable[P, R],
|
|
87
|
-
*,
|
|
88
|
-
name: str | None = None,
|
|
89
|
-
span_type: str = "function",
|
|
90
|
-
attributes: dict[str, Any] | None = None,
|
|
91
|
-
record_args: bool = True,
|
|
92
|
-
record_result: bool = True,
|
|
93
|
-
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
94
|
-
) -> Callable[P, R]: ...
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@overload
|
|
98
|
-
def instrument(
|
|
99
|
-
func: Callable[P, Awaitable[R]],
|
|
100
|
-
*,
|
|
101
|
-
name: str | None = None,
|
|
102
|
-
span_type: str = "function",
|
|
103
|
-
attributes: dict[str, Any] | None = None,
|
|
104
|
-
record_args: bool = True,
|
|
105
|
-
record_result: bool = True,
|
|
106
|
-
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
107
|
-
) -> Callable[P, Awaitable[R]]: ...
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def instrument(
|
|
111
|
-
func: Callable[..., Any] | None = None,
|
|
112
|
-
*,
|
|
113
|
-
name: str | None = None,
|
|
114
|
-
span_type: str = "function",
|
|
115
|
-
attributes: dict[str, Any] | None = None,
|
|
116
|
-
record_args: bool = True,
|
|
117
|
-
record_result: bool = True,
|
|
118
|
-
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
119
|
-
) -> Callable[..., Any]:
|
|
120
|
-
"""Instrument a function to emit OpenTelemetry spans.
|
|
121
|
-
|
|
122
|
-
This decorator wraps any function to automatically create spans for
|
|
123
|
-
observability. It works with both sync and async functions.
|
|
124
|
-
|
|
125
|
-
Args:
|
|
126
|
-
func: The function to instrument (when used without parentheses)
|
|
127
|
-
name: Custom span name (defaults to fully qualified function name)
|
|
128
|
-
span_type: The category for this span (e.g., "agent", "mcp", "database", "validation")
|
|
129
|
-
attributes: Additional attributes to attach to every span
|
|
130
|
-
record_args: Whether to record function arguments in the request field
|
|
131
|
-
record_result: Whether to record function result in the result field
|
|
132
|
-
span_kind: OpenTelemetry span kind (INTERNAL, CLIENT, SERVER, etc.)
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
The instrumented function that emits spans
|
|
136
|
-
|
|
137
|
-
Examples:
|
|
138
|
-
# Basic usage - defaults to category="function"
|
|
139
|
-
@hud.instrument
|
|
140
|
-
async def process_data(items: list[str]) -> dict:
|
|
141
|
-
return {"count": len(items)}
|
|
142
|
-
|
|
143
|
-
# Custom category
|
|
144
|
-
@hud.instrument(
|
|
145
|
-
span_type="database", # This becomes category="database"
|
|
146
|
-
record_args=True,
|
|
147
|
-
record_result=True
|
|
148
|
-
)
|
|
149
|
-
async def query_users(filter: dict) -> list[User]:
|
|
150
|
-
return await db.find(filter)
|
|
151
|
-
|
|
152
|
-
# Agent instrumentation
|
|
153
|
-
@hud.instrument(
|
|
154
|
-
span_type="agent", # category="agent" gets special handling
|
|
155
|
-
record_args=False, # Don't record large message arrays
|
|
156
|
-
record_result=True
|
|
157
|
-
)
|
|
158
|
-
async def get_model_response(self, messages: list) -> Response:
|
|
159
|
-
return await self.model.complete(messages)
|
|
160
|
-
|
|
161
|
-
# Instrument third-party functions
|
|
162
|
-
import requests
|
|
163
|
-
requests.get = hud.instrument(
|
|
164
|
-
span_type="http", # category="http"
|
|
165
|
-
span_kind=SpanKind.CLIENT
|
|
166
|
-
)(requests.get)
|
|
167
|
-
|
|
168
|
-
# Conditional instrumentation
|
|
169
|
-
if settings.enable_db_tracing:
|
|
170
|
-
db.query = hud.instrument(db.query)
|
|
171
|
-
"""
|
|
172
|
-
# Don't configure telemetry at decoration time - wait until first call
|
|
173
|
-
# This allows users to configure alternative backends before importing agents
|
|
174
|
-
|
|
175
|
-
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
176
|
-
# Check if already instrumented
|
|
177
|
-
if hasattr(func, "_hud_instrumented"):
|
|
178
|
-
logger.debug("Function %s already instrumented, skipping", func.__name__)
|
|
179
|
-
return func
|
|
180
|
-
|
|
181
|
-
# Get function metadata
|
|
182
|
-
func_module = getattr(func, "__module__", "unknown")
|
|
183
|
-
func_name = getattr(func, "__name__", "unknown")
|
|
184
|
-
func_qualname = getattr(func, "__qualname__", func_name)
|
|
185
|
-
|
|
186
|
-
# Determine span name
|
|
187
|
-
span_name = name or f"{func_module}.{func_qualname}"
|
|
188
|
-
|
|
189
|
-
# Get function signature for argument parsing
|
|
190
|
-
try:
|
|
191
|
-
sig = inspect.signature(func)
|
|
192
|
-
except (ValueError, TypeError):
|
|
193
|
-
sig = None
|
|
194
|
-
|
|
195
|
-
@functools.wraps(func)
|
|
196
|
-
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
197
|
-
# Ensure telemetry is configured (lazy initialization)
|
|
198
|
-
# Only configure with defaults if user hasn't configured it yet
|
|
199
|
-
if not is_telemetry_configured():
|
|
200
|
-
configure_telemetry()
|
|
201
|
-
|
|
202
|
-
tracer = trace.get_tracer("hud-sdk")
|
|
203
|
-
|
|
204
|
-
# Build span attributes
|
|
205
|
-
span_attrs = {
|
|
206
|
-
"category": span_type, # span_type IS the category
|
|
207
|
-
"function.module": func_module,
|
|
208
|
-
"function.name": func_name,
|
|
209
|
-
"function.qualname": func_qualname,
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
# Add custom attributes
|
|
213
|
-
if attributes:
|
|
214
|
-
span_attrs.update(attributes)
|
|
215
|
-
|
|
216
|
-
# Add current task_run_id if available
|
|
217
|
-
task_run_id = get_current_task_run_id()
|
|
218
|
-
if task_run_id:
|
|
219
|
-
span_attrs["hud.task_run_id"] = task_run_id
|
|
220
|
-
|
|
221
|
-
# Record function arguments if requested
|
|
222
|
-
if record_args and sig:
|
|
223
|
-
try:
|
|
224
|
-
bound_args = sig.bind(*args, **kwargs)
|
|
225
|
-
bound_args.apply_defaults()
|
|
226
|
-
|
|
227
|
-
# Serialize arguments (with safety limits)
|
|
228
|
-
args_dict = {}
|
|
229
|
-
for param_name, value in bound_args.arguments.items():
|
|
230
|
-
try:
|
|
231
|
-
# Skip 'self' and 'cls' parameters
|
|
232
|
-
if param_name in ("self", "cls"):
|
|
233
|
-
continue
|
|
234
|
-
|
|
235
|
-
args_dict[param_name] = _serialize_value(value)
|
|
236
|
-
except Exception:
|
|
237
|
-
args_dict[param_name] = "<serialization_error>"
|
|
238
|
-
|
|
239
|
-
if args_dict:
|
|
240
|
-
args_json = json.dumps(args_dict)
|
|
241
|
-
span_attrs["function.arguments"] = args_json
|
|
242
|
-
# Always set generic request field for consistency
|
|
243
|
-
span_attrs["request"] = args_json
|
|
244
|
-
except Exception as e:
|
|
245
|
-
logger.debug("Failed to record function arguments: %s", e)
|
|
246
|
-
|
|
247
|
-
with tracer.start_as_current_span(
|
|
248
|
-
span_name,
|
|
249
|
-
kind=span_kind,
|
|
250
|
-
attributes=span_attrs,
|
|
251
|
-
) as span:
|
|
252
|
-
try:
|
|
253
|
-
# Execute the function
|
|
254
|
-
result = await func(*args, **kwargs)
|
|
255
|
-
|
|
256
|
-
# Record result if requested
|
|
257
|
-
if record_result:
|
|
258
|
-
try:
|
|
259
|
-
serialized = _serialize_value(result)
|
|
260
|
-
result_json = json.dumps(serialized)
|
|
261
|
-
span.set_attribute("function.result", result_json)
|
|
262
|
-
# Always set generic result field for consistency
|
|
263
|
-
span.set_attribute("result", result_json)
|
|
264
|
-
|
|
265
|
-
# Also set result type for complex objects
|
|
266
|
-
if not isinstance(
|
|
267
|
-
result, str | int | float | bool | type(None) | list | tuple | dict
|
|
268
|
-
):
|
|
269
|
-
span.set_attribute("function.result_type", type(result).__name__)
|
|
270
|
-
except Exception as e:
|
|
271
|
-
logger.debug("Failed to record function result: %s", e)
|
|
272
|
-
|
|
273
|
-
span.set_status(Status(StatusCode.OK))
|
|
274
|
-
return result
|
|
275
|
-
|
|
276
|
-
except Exception as e:
|
|
277
|
-
# Record exception and set error status
|
|
278
|
-
span.record_exception(e)
|
|
279
|
-
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
280
|
-
raise
|
|
281
|
-
|
|
282
|
-
@functools.wraps(func)
|
|
283
|
-
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
284
|
-
# Ensure telemetry is configured (lazy initialization)
|
|
285
|
-
# Only configure with defaults if user hasn't configured it yet
|
|
286
|
-
if not is_telemetry_configured():
|
|
287
|
-
configure_telemetry()
|
|
288
|
-
|
|
289
|
-
tracer = trace.get_tracer("hud-sdk")
|
|
290
|
-
|
|
291
|
-
# Build span attributes (same as async)
|
|
292
|
-
span_attrs = {
|
|
293
|
-
"category": span_type, # span_type IS the category
|
|
294
|
-
"function.module": func_module,
|
|
295
|
-
"function.name": func_name,
|
|
296
|
-
"function.qualname": func_qualname,
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if attributes:
|
|
300
|
-
span_attrs.update(attributes)
|
|
301
|
-
|
|
302
|
-
task_run_id = get_current_task_run_id()
|
|
303
|
-
if task_run_id:
|
|
304
|
-
span_attrs["hud.task_run_id"] = task_run_id
|
|
305
|
-
|
|
306
|
-
# Record function arguments if requested
|
|
307
|
-
if record_args and sig:
|
|
308
|
-
try:
|
|
309
|
-
bound_args = sig.bind(*args, **kwargs)
|
|
310
|
-
bound_args.apply_defaults()
|
|
311
|
-
|
|
312
|
-
args_dict = {}
|
|
313
|
-
for param_name, value in bound_args.arguments.items():
|
|
314
|
-
try:
|
|
315
|
-
if param_name in ("self", "cls"):
|
|
316
|
-
continue
|
|
317
|
-
|
|
318
|
-
args_dict[param_name] = _serialize_value(value)
|
|
319
|
-
except Exception:
|
|
320
|
-
args_dict[param_name] = "<serialization_error>"
|
|
321
|
-
|
|
322
|
-
if args_dict:
|
|
323
|
-
args_json = json.dumps(args_dict)
|
|
324
|
-
span_attrs["function.arguments"] = args_json
|
|
325
|
-
# Always set generic request field for consistency
|
|
326
|
-
span_attrs["request"] = args_json
|
|
327
|
-
except Exception as e:
|
|
328
|
-
logger.debug("Failed to record function arguments: %s", e)
|
|
329
|
-
|
|
330
|
-
with tracer.start_as_current_span(
|
|
331
|
-
span_name,
|
|
332
|
-
kind=span_kind,
|
|
333
|
-
attributes=span_attrs,
|
|
334
|
-
) as span:
|
|
335
|
-
try:
|
|
336
|
-
# Execute the function
|
|
337
|
-
result = func(*args, **kwargs)
|
|
338
|
-
|
|
339
|
-
# Record result if requested
|
|
340
|
-
if record_result:
|
|
341
|
-
try:
|
|
342
|
-
serialized = _serialize_value(result)
|
|
343
|
-
result_json = json.dumps(serialized)
|
|
344
|
-
span.set_attribute("function.result", result_json)
|
|
345
|
-
# Always set generic result field for consistency
|
|
346
|
-
span.set_attribute("result", result_json)
|
|
347
|
-
|
|
348
|
-
# Also set result type for complex objects
|
|
349
|
-
if not isinstance(
|
|
350
|
-
result, str | int | float | bool | type(None) | list | tuple | dict
|
|
351
|
-
):
|
|
352
|
-
span.set_attribute("function.result_type", type(result).__name__)
|
|
353
|
-
except Exception as e:
|
|
354
|
-
logger.debug("Failed to record function result: %s", e)
|
|
355
|
-
|
|
356
|
-
span.set_status(Status(StatusCode.OK))
|
|
357
|
-
return result
|
|
358
|
-
|
|
359
|
-
except Exception as e:
|
|
360
|
-
span.record_exception(e)
|
|
361
|
-
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
362
|
-
raise
|
|
363
|
-
|
|
364
|
-
# Choose wrapper based on function type
|
|
365
|
-
wrapper = async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
|
366
|
-
|
|
367
|
-
# Mark as instrumented
|
|
368
|
-
wrapper._hud_instrumented = True # type: ignore[attr-defined]
|
|
369
|
-
wrapper._hud_original = func # type: ignore[attr-defined]
|
|
370
|
-
|
|
371
|
-
return wrapper
|
|
372
|
-
|
|
373
|
-
# Handle usage with or without parentheses
|
|
374
|
-
if func is None:
|
|
375
|
-
# Called with arguments: @instrument(name="foo")
|
|
376
|
-
return decorator
|
|
377
|
-
else:
|
|
378
|
-
# Called without arguments: @instrument
|
|
379
|
-
return decorator(func)
|
|
1
|
+
"""General-purpose instrumentation decorator for HUD telemetry.
|
|
2
|
+
|
|
3
|
+
This module provides the instrument() decorator that users can use
|
|
4
|
+
to instrument any function with OpenTelemetry spans.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
15
|
+
|
|
16
|
+
import pydantic_core
|
|
17
|
+
from opentelemetry import trace
|
|
18
|
+
from opentelemetry.trace import SpanKind, Status, StatusCode
|
|
19
|
+
|
|
20
|
+
from hud.otel import configure_telemetry, is_telemetry_configured
|
|
21
|
+
from hud.otel.context import get_current_task_run_id
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from typing import ParamSpec
|
|
26
|
+
|
|
27
|
+
P = ParamSpec("P")
|
|
28
|
+
R = TypeVar("R")
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _serialize_value(value: Any, max_items: int = 10) -> Any:
|
|
34
|
+
"""Serialize a value for span attributes.
|
|
35
|
+
|
|
36
|
+
Uses pydantic_core.to_json for robust serialization of complex objects.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
value: The value to serialize
|
|
40
|
+
max_items: Maximum number of items for collections
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
JSON-serializable version of the value
|
|
44
|
+
"""
|
|
45
|
+
# Simple types pass through
|
|
46
|
+
if isinstance(value, str | int | float | bool | type(None)):
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
# For collections, we need to limit size first
|
|
50
|
+
if isinstance(value, list | tuple):
|
|
51
|
+
value = value[:max_items] if len(value) > max_items else value
|
|
52
|
+
elif isinstance(value, dict) and len(value) > max_items:
|
|
53
|
+
value = dict(list(value.items())[:max_items])
|
|
54
|
+
|
|
55
|
+
# Use pydantic_core for serialization - it handles:
|
|
56
|
+
# - Pydantic models (via model_dump)
|
|
57
|
+
# - Dataclasses (via asdict)
|
|
58
|
+
# - Bytes (encodes to string)
|
|
59
|
+
# - Custom objects (via __dict__ or repr)
|
|
60
|
+
# - Complex nested structures
|
|
61
|
+
try:
|
|
62
|
+
# Convert to JSON bytes then back to Python objects
|
|
63
|
+
# This ensures we get JSON-serializable types
|
|
64
|
+
json_bytes = pydantic_core.to_json(value, fallback=str)
|
|
65
|
+
return json.loads(json_bytes)
|
|
66
|
+
except Exception:
|
|
67
|
+
# Fallback if pydantic_core fails somehow
|
|
68
|
+
return f"<{type(value).__name__}>"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@overload
|
|
72
|
+
def instrument(
|
|
73
|
+
func: None = None,
|
|
74
|
+
*,
|
|
75
|
+
name: str | None = None,
|
|
76
|
+
span_type: str = "function",
|
|
77
|
+
attributes: dict[str, Any] | None = None,
|
|
78
|
+
record_args: bool = True,
|
|
79
|
+
record_result: bool = True,
|
|
80
|
+
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
81
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@overload
|
|
85
|
+
def instrument(
|
|
86
|
+
func: Callable[P, R],
|
|
87
|
+
*,
|
|
88
|
+
name: str | None = None,
|
|
89
|
+
span_type: str = "function",
|
|
90
|
+
attributes: dict[str, Any] | None = None,
|
|
91
|
+
record_args: bool = True,
|
|
92
|
+
record_result: bool = True,
|
|
93
|
+
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
94
|
+
) -> Callable[P, R]: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def instrument(
|
|
99
|
+
func: Callable[P, Awaitable[R]],
|
|
100
|
+
*,
|
|
101
|
+
name: str | None = None,
|
|
102
|
+
span_type: str = "function",
|
|
103
|
+
attributes: dict[str, Any] | None = None,
|
|
104
|
+
record_args: bool = True,
|
|
105
|
+
record_result: bool = True,
|
|
106
|
+
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
107
|
+
) -> Callable[P, Awaitable[R]]: ...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def instrument(
|
|
111
|
+
func: Callable[..., Any] | None = None,
|
|
112
|
+
*,
|
|
113
|
+
name: str | None = None,
|
|
114
|
+
span_type: str = "function",
|
|
115
|
+
attributes: dict[str, Any] | None = None,
|
|
116
|
+
record_args: bool = True,
|
|
117
|
+
record_result: bool = True,
|
|
118
|
+
span_kind: SpanKind = SpanKind.INTERNAL,
|
|
119
|
+
) -> Callable[..., Any]:
|
|
120
|
+
"""Instrument a function to emit OpenTelemetry spans.
|
|
121
|
+
|
|
122
|
+
This decorator wraps any function to automatically create spans for
|
|
123
|
+
observability. It works with both sync and async functions.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
func: The function to instrument (when used without parentheses)
|
|
127
|
+
name: Custom span name (defaults to fully qualified function name)
|
|
128
|
+
span_type: The category for this span (e.g., "agent", "mcp", "database", "validation")
|
|
129
|
+
attributes: Additional attributes to attach to every span
|
|
130
|
+
record_args: Whether to record function arguments in the request field
|
|
131
|
+
record_result: Whether to record function result in the result field
|
|
132
|
+
span_kind: OpenTelemetry span kind (INTERNAL, CLIENT, SERVER, etc.)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
The instrumented function that emits spans
|
|
136
|
+
|
|
137
|
+
Examples:
|
|
138
|
+
# Basic usage - defaults to category="function"
|
|
139
|
+
@hud.instrument
|
|
140
|
+
async def process_data(items: list[str]) -> dict:
|
|
141
|
+
return {"count": len(items)}
|
|
142
|
+
|
|
143
|
+
# Custom category
|
|
144
|
+
@hud.instrument(
|
|
145
|
+
span_type="database", # This becomes category="database"
|
|
146
|
+
record_args=True,
|
|
147
|
+
record_result=True
|
|
148
|
+
)
|
|
149
|
+
async def query_users(filter: dict) -> list[User]:
|
|
150
|
+
return await db.find(filter)
|
|
151
|
+
|
|
152
|
+
# Agent instrumentation
|
|
153
|
+
@hud.instrument(
|
|
154
|
+
span_type="agent", # category="agent" gets special handling
|
|
155
|
+
record_args=False, # Don't record large message arrays
|
|
156
|
+
record_result=True
|
|
157
|
+
)
|
|
158
|
+
async def get_model_response(self, messages: list) -> Response:
|
|
159
|
+
return await self.model.complete(messages)
|
|
160
|
+
|
|
161
|
+
# Instrument third-party functions
|
|
162
|
+
import requests
|
|
163
|
+
requests.get = hud.instrument(
|
|
164
|
+
span_type="http", # category="http"
|
|
165
|
+
span_kind=SpanKind.CLIENT
|
|
166
|
+
)(requests.get)
|
|
167
|
+
|
|
168
|
+
# Conditional instrumentation
|
|
169
|
+
if settings.enable_db_tracing:
|
|
170
|
+
db.query = hud.instrument(db.query)
|
|
171
|
+
"""
|
|
172
|
+
# Don't configure telemetry at decoration time - wait until first call
|
|
173
|
+
# This allows users to configure alternative backends before importing agents
|
|
174
|
+
|
|
175
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
176
|
+
# Check if already instrumented
|
|
177
|
+
if hasattr(func, "_hud_instrumented"):
|
|
178
|
+
logger.debug("Function %s already instrumented, skipping", func.__name__)
|
|
179
|
+
return func
|
|
180
|
+
|
|
181
|
+
# Get function metadata
|
|
182
|
+
func_module = getattr(func, "__module__", "unknown")
|
|
183
|
+
func_name = getattr(func, "__name__", "unknown")
|
|
184
|
+
func_qualname = getattr(func, "__qualname__", func_name)
|
|
185
|
+
|
|
186
|
+
# Determine span name
|
|
187
|
+
span_name = name or f"{func_module}.{func_qualname}"
|
|
188
|
+
|
|
189
|
+
# Get function signature for argument parsing
|
|
190
|
+
try:
|
|
191
|
+
sig = inspect.signature(func)
|
|
192
|
+
except (ValueError, TypeError):
|
|
193
|
+
sig = None
|
|
194
|
+
|
|
195
|
+
@functools.wraps(func)
|
|
196
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
197
|
+
# Ensure telemetry is configured (lazy initialization)
|
|
198
|
+
# Only configure with defaults if user hasn't configured it yet
|
|
199
|
+
if not is_telemetry_configured():
|
|
200
|
+
configure_telemetry()
|
|
201
|
+
|
|
202
|
+
tracer = trace.get_tracer("hud-sdk")
|
|
203
|
+
|
|
204
|
+
# Build span attributes
|
|
205
|
+
span_attrs = {
|
|
206
|
+
"category": span_type, # span_type IS the category
|
|
207
|
+
"function.module": func_module,
|
|
208
|
+
"function.name": func_name,
|
|
209
|
+
"function.qualname": func_qualname,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
# Add custom attributes
|
|
213
|
+
if attributes:
|
|
214
|
+
span_attrs.update(attributes)
|
|
215
|
+
|
|
216
|
+
# Add current task_run_id if available
|
|
217
|
+
task_run_id = get_current_task_run_id()
|
|
218
|
+
if task_run_id:
|
|
219
|
+
span_attrs["hud.task_run_id"] = task_run_id
|
|
220
|
+
|
|
221
|
+
# Record function arguments if requested
|
|
222
|
+
if record_args and sig:
|
|
223
|
+
try:
|
|
224
|
+
bound_args = sig.bind(*args, **kwargs)
|
|
225
|
+
bound_args.apply_defaults()
|
|
226
|
+
|
|
227
|
+
# Serialize arguments (with safety limits)
|
|
228
|
+
args_dict = {}
|
|
229
|
+
for param_name, value in bound_args.arguments.items():
|
|
230
|
+
try:
|
|
231
|
+
# Skip 'self' and 'cls' parameters
|
|
232
|
+
if param_name in ("self", "cls"):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
args_dict[param_name] = _serialize_value(value)
|
|
236
|
+
except Exception:
|
|
237
|
+
args_dict[param_name] = "<serialization_error>"
|
|
238
|
+
|
|
239
|
+
if args_dict:
|
|
240
|
+
args_json = json.dumps(args_dict)
|
|
241
|
+
span_attrs["function.arguments"] = args_json
|
|
242
|
+
# Always set generic request field for consistency
|
|
243
|
+
span_attrs["request"] = args_json
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.debug("Failed to record function arguments: %s", e)
|
|
246
|
+
|
|
247
|
+
with tracer.start_as_current_span(
|
|
248
|
+
span_name,
|
|
249
|
+
kind=span_kind,
|
|
250
|
+
attributes=span_attrs,
|
|
251
|
+
) as span:
|
|
252
|
+
try:
|
|
253
|
+
# Execute the function
|
|
254
|
+
result = await func(*args, **kwargs)
|
|
255
|
+
|
|
256
|
+
# Record result if requested
|
|
257
|
+
if record_result:
|
|
258
|
+
try:
|
|
259
|
+
serialized = _serialize_value(result)
|
|
260
|
+
result_json = json.dumps(serialized)
|
|
261
|
+
span.set_attribute("function.result", result_json)
|
|
262
|
+
# Always set generic result field for consistency
|
|
263
|
+
span.set_attribute("result", result_json)
|
|
264
|
+
|
|
265
|
+
# Also set result type for complex objects
|
|
266
|
+
if not isinstance(
|
|
267
|
+
result, str | int | float | bool | type(None) | list | tuple | dict
|
|
268
|
+
):
|
|
269
|
+
span.set_attribute("function.result_type", type(result).__name__)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.debug("Failed to record function result: %s", e)
|
|
272
|
+
|
|
273
|
+
span.set_status(Status(StatusCode.OK))
|
|
274
|
+
return result
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
# Record exception and set error status
|
|
278
|
+
span.record_exception(e)
|
|
279
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
280
|
+
raise
|
|
281
|
+
|
|
282
|
+
@functools.wraps(func)
|
|
283
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
284
|
+
# Ensure telemetry is configured (lazy initialization)
|
|
285
|
+
# Only configure with defaults if user hasn't configured it yet
|
|
286
|
+
if not is_telemetry_configured():
|
|
287
|
+
configure_telemetry()
|
|
288
|
+
|
|
289
|
+
tracer = trace.get_tracer("hud-sdk")
|
|
290
|
+
|
|
291
|
+
# Build span attributes (same as async)
|
|
292
|
+
span_attrs = {
|
|
293
|
+
"category": span_type, # span_type IS the category
|
|
294
|
+
"function.module": func_module,
|
|
295
|
+
"function.name": func_name,
|
|
296
|
+
"function.qualname": func_qualname,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if attributes:
|
|
300
|
+
span_attrs.update(attributes)
|
|
301
|
+
|
|
302
|
+
task_run_id = get_current_task_run_id()
|
|
303
|
+
if task_run_id:
|
|
304
|
+
span_attrs["hud.task_run_id"] = task_run_id
|
|
305
|
+
|
|
306
|
+
# Record function arguments if requested
|
|
307
|
+
if record_args and sig:
|
|
308
|
+
try:
|
|
309
|
+
bound_args = sig.bind(*args, **kwargs)
|
|
310
|
+
bound_args.apply_defaults()
|
|
311
|
+
|
|
312
|
+
args_dict = {}
|
|
313
|
+
for param_name, value in bound_args.arguments.items():
|
|
314
|
+
try:
|
|
315
|
+
if param_name in ("self", "cls"):
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
args_dict[param_name] = _serialize_value(value)
|
|
319
|
+
except Exception:
|
|
320
|
+
args_dict[param_name] = "<serialization_error>"
|
|
321
|
+
|
|
322
|
+
if args_dict:
|
|
323
|
+
args_json = json.dumps(args_dict)
|
|
324
|
+
span_attrs["function.arguments"] = args_json
|
|
325
|
+
# Always set generic request field for consistency
|
|
326
|
+
span_attrs["request"] = args_json
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.debug("Failed to record function arguments: %s", e)
|
|
329
|
+
|
|
330
|
+
with tracer.start_as_current_span(
|
|
331
|
+
span_name,
|
|
332
|
+
kind=span_kind,
|
|
333
|
+
attributes=span_attrs,
|
|
334
|
+
) as span:
|
|
335
|
+
try:
|
|
336
|
+
# Execute the function
|
|
337
|
+
result = func(*args, **kwargs)
|
|
338
|
+
|
|
339
|
+
# Record result if requested
|
|
340
|
+
if record_result:
|
|
341
|
+
try:
|
|
342
|
+
serialized = _serialize_value(result)
|
|
343
|
+
result_json = json.dumps(serialized)
|
|
344
|
+
span.set_attribute("function.result", result_json)
|
|
345
|
+
# Always set generic result field for consistency
|
|
346
|
+
span.set_attribute("result", result_json)
|
|
347
|
+
|
|
348
|
+
# Also set result type for complex objects
|
|
349
|
+
if not isinstance(
|
|
350
|
+
result, str | int | float | bool | type(None) | list | tuple | dict
|
|
351
|
+
):
|
|
352
|
+
span.set_attribute("function.result_type", type(result).__name__)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
logger.debug("Failed to record function result: %s", e)
|
|
355
|
+
|
|
356
|
+
span.set_status(Status(StatusCode.OK))
|
|
357
|
+
return result
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
span.record_exception(e)
|
|
361
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
|
362
|
+
raise
|
|
363
|
+
|
|
364
|
+
# Choose wrapper based on function type
|
|
365
|
+
wrapper = async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
|
366
|
+
|
|
367
|
+
# Mark as instrumented
|
|
368
|
+
wrapper._hud_instrumented = True # type: ignore[attr-defined]
|
|
369
|
+
wrapper._hud_original = func # type: ignore[attr-defined]
|
|
370
|
+
|
|
371
|
+
return wrapper
|
|
372
|
+
|
|
373
|
+
# Handle usage with or without parentheses
|
|
374
|
+
if func is None:
|
|
375
|
+
# Called with arguments: @instrument(name="foo")
|
|
376
|
+
return decorator
|
|
377
|
+
else:
|
|
378
|
+
# Called without arguments: @instrument
|
|
379
|
+
return decorator(func)
|