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.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/otel/context.py CHANGED
@@ -1,536 +1,536 @@
1
- """OpenTelemetry context utilities for HUD telemetry.
2
-
3
- This module provides internal utilities for managing OpenTelemetry contexts.
4
- User-facing APIs are in hud.telemetry.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import contextvars
10
- import logging
11
- from contextlib import contextmanager
12
- from typing import TYPE_CHECKING, Any
13
-
14
- from opentelemetry import baggage, context
15
- from opentelemetry import trace as otel_trace
16
- from opentelemetry.trace import Status, StatusCode
17
-
18
- if TYPE_CHECKING:
19
- from collections.abc import Generator
20
- from types import TracebackType
21
-
22
- from hud.settings import settings
23
- from hud.shared import make_request, make_request_sync
24
- from hud.utils.async_utils import fire_and_forget
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
- # Context variables for task tracking
29
- current_task_run_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
30
- "current_task_run_id", default=None
31
- )
32
- is_root_trace_var: contextvars.ContextVar[bool] = contextvars.ContextVar(
33
- "is_root_trace", default=False
34
- )
35
-
36
- # Step counters for different types
37
- current_base_mcp_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
38
- "current_base_mcp_steps", default=0
39
- )
40
- current_mcp_tool_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
41
- "current_mcp_tool_steps", default=0
42
- )
43
- current_agent_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
44
- "current_agent_steps", default=0
45
- )
46
-
47
- # Keys for OpenTelemetry baggage
48
- TASK_RUN_ID_KEY = "hud.task_run_id"
49
- IS_ROOT_TRACE_KEY = "hud.is_root_trace"
50
- BASE_MCP_STEPS_KEY = "hud.base_mcp_steps"
51
- MCP_TOOL_STEPS_KEY = "hud.mcp_tool_steps"
52
- AGENT_STEPS_KEY = "hud.agent_steps"
53
-
54
-
55
- def set_current_task_run_id(task_run_id: str | None) -> contextvars.Token:
56
- """Set the current task run ID."""
57
- return current_task_run_id.set(task_run_id)
58
-
59
-
60
- def get_current_task_run_id() -> str | None:
61
- """Get current task_run_id from either contextvars or OTel baggage."""
62
- # First try OTel baggage
63
- task_run_id = baggage.get_baggage(TASK_RUN_ID_KEY)
64
- if task_run_id and isinstance(task_run_id, str):
65
- return task_run_id
66
-
67
- # Fallback to contextvars
68
- return current_task_run_id.get()
69
-
70
-
71
- def is_root_trace() -> bool:
72
- """Check if current context is a root trace."""
73
- # First try OTel baggage
74
- is_root = baggage.get_baggage(IS_ROOT_TRACE_KEY)
75
- if isinstance(is_root, str):
76
- return is_root.lower() == "true"
77
-
78
- # Fallback to contextvars
79
- return is_root_trace_var.get()
80
-
81
-
82
- def get_base_mcp_steps() -> int:
83
- """Get current base MCP step count from either contextvars or OTel baggage."""
84
- # First try OTel baggage
85
- step_count = baggage.get_baggage(BASE_MCP_STEPS_KEY)
86
- if step_count and isinstance(step_count, str):
87
- try:
88
- return int(step_count)
89
- except ValueError:
90
- pass
91
-
92
- # Fallback to contextvars
93
- return current_base_mcp_steps.get()
94
-
95
-
96
- def get_mcp_tool_steps() -> int:
97
- """Get current MCP tool step count from either contextvars or OTel baggage."""
98
- # First try OTel baggage
99
- step_count = baggage.get_baggage(MCP_TOOL_STEPS_KEY)
100
- if step_count and isinstance(step_count, str):
101
- try:
102
- return int(step_count)
103
- except ValueError:
104
- pass
105
-
106
- # Fallback to contextvars
107
- return current_mcp_tool_steps.get()
108
-
109
-
110
- def get_agent_steps() -> int:
111
- """Get current agent step count from either contextvars or OTel baggage."""
112
- # First try OTel baggage
113
- step_count = baggage.get_baggage(AGENT_STEPS_KEY)
114
- if step_count and isinstance(step_count, str):
115
- try:
116
- return int(step_count)
117
- except ValueError:
118
- pass
119
-
120
- # Fallback to contextvars
121
- return current_agent_steps.get()
122
-
123
-
124
- def increment_base_mcp_steps() -> int:
125
- """Increment the base MCP step count and update baggage.
126
-
127
- Returns:
128
- The new base MCP step count after incrementing
129
- """
130
- current = get_base_mcp_steps()
131
- new_count = current + 1
132
-
133
- # Update contextvar
134
- current_base_mcp_steps.set(new_count)
135
-
136
- # Update baggage for propagation
137
- ctx = baggage.set_baggage(BASE_MCP_STEPS_KEY, str(new_count))
138
- context.attach(ctx)
139
-
140
- # Update current span if one exists
141
- span = otel_trace.get_current_span()
142
- if span and span.is_recording():
143
- span.set_attribute("hud.base_mcp_steps", new_count)
144
-
145
- return new_count
146
-
147
-
148
- def increment_mcp_tool_steps() -> int:
149
- """Increment the MCP tool step count and update baggage.
150
-
151
- Returns:
152
- The new MCP tool step count after incrementing
153
- """
154
- current = get_mcp_tool_steps()
155
- new_count = current + 1
156
-
157
- # Update contextvar
158
- current_mcp_tool_steps.set(new_count)
159
-
160
- # Update baggage for propagation
161
- ctx = baggage.set_baggage(MCP_TOOL_STEPS_KEY, str(new_count))
162
- context.attach(ctx)
163
-
164
- # Update current span if one exists
165
- span = otel_trace.get_current_span()
166
- if span and span.is_recording():
167
- span.set_attribute("hud.mcp_tool_steps", new_count)
168
-
169
- return new_count
170
-
171
-
172
- def increment_agent_steps() -> int:
173
- """Increment the agent step count and update baggage.
174
-
175
- Returns:
176
- The new agent step count after incrementing
177
- """
178
- current = get_agent_steps()
179
- new_count = current + 1
180
-
181
- # Update contextvar
182
- current_agent_steps.set(new_count)
183
-
184
- # Update baggage for propagation
185
- ctx = baggage.set_baggage(AGENT_STEPS_KEY, str(new_count))
186
- context.attach(ctx)
187
-
188
- # Update current span if one exists
189
- span = otel_trace.get_current_span()
190
- if span and span.is_recording():
191
- span.set_attribute("hud.agent_steps", new_count)
192
-
193
- return new_count
194
-
195
-
196
- @contextmanager
197
- def span_context(
198
- name: str,
199
- attributes: dict[str, Any] | None = None,
200
- kind: otel_trace.SpanKind = otel_trace.SpanKind.INTERNAL,
201
- ) -> Generator[otel_trace.Span, None, None]:
202
- """Create a child span within the current trace context.
203
-
204
- This is a simple wrapper around OpenTelemetry's span creation that
205
- ensures the span inherits the current HUD context (task_run_id, etc).
206
-
207
- Args:
208
- name: Name for the span
209
- attributes: Additional attributes to add to the span
210
- kind: OpenTelemetry span kind
211
-
212
- Example:
213
- with span_context("process_data", {"items": 100}) as span:
214
- # Process data...
215
- span.set_attribute("processed", True)
216
- """
217
- tracer = otel_trace.get_tracer("hud-sdk")
218
-
219
- # Current task_run_id will be added by HudEnrichmentProcessor
220
- with tracer.start_as_current_span(
221
- name,
222
- attributes=attributes,
223
- kind=kind,
224
- ) as span:
225
- yield span
226
-
227
-
228
- async def _update_task_status_async(
229
- task_run_id: str,
230
- status: str,
231
- job_id: str | None = None,
232
- error_message: str | None = None,
233
- trace_name: str | None = None,
234
- task_id: str | None = None,
235
- ) -> None:
236
- """Async task status update."""
237
- if not settings.telemetry_enabled:
238
- return
239
-
240
- try:
241
- data: dict[str, Any] = {"status": status}
242
- if job_id:
243
- data["job_id"] = job_id
244
- if error_message:
245
- data["error_message"] = error_message
246
-
247
- # Build metadata with trace name and step counts
248
- metadata = {}
249
- if trace_name:
250
- metadata["trace_name"] = trace_name
251
-
252
- # Include all three step counts in metadata
253
- metadata["base_mcp_steps"] = get_base_mcp_steps()
254
- metadata["mcp_tool_steps"] = get_mcp_tool_steps()
255
- metadata["agent_steps"] = get_agent_steps()
256
-
257
- if metadata:
258
- data["metadata"] = metadata
259
-
260
- if task_id:
261
- data["task_id"] = task_id
262
-
263
- await make_request(
264
- method="POST",
265
- url=f"{settings.hud_telemetry_url}/trace/{task_run_id}/status",
266
- json=data,
267
- api_key=settings.api_key,
268
- )
269
- logger.debug("Updated task %s status to %s", task_run_id, status)
270
- except Exception as e:
271
- # Suppress warnings about interpreter shutdown
272
- if "interpreter shutdown" not in str(e):
273
- logger.warning("Failed to update task status: %s", e)
274
-
275
-
276
- def _fire_and_forget_status_update(
277
- task_run_id: str,
278
- status: str,
279
- job_id: str | None = None,
280
- error_message: str | None = None,
281
- trace_name: str | None = None,
282
- task_id: str | None = None,
283
- ) -> None:
284
- """Fire and forget status update - works in any context including Jupyter."""
285
- fire_and_forget(
286
- _update_task_status_async(task_run_id, status, job_id, error_message, trace_name, task_id),
287
- f"update task {task_run_id} status to {status}",
288
- )
289
-
290
-
291
- def _update_task_status_sync(
292
- task_run_id: str,
293
- status: str,
294
- job_id: str | None = None,
295
- error_message: str | None = None,
296
- trace_name: str | None = None,
297
- task_id: str | None = None,
298
- ) -> None:
299
- """Synchronous task status update."""
300
- if not settings.telemetry_enabled:
301
- return
302
-
303
- try:
304
- data: dict[str, Any] = {"status": status}
305
- if job_id:
306
- data["job_id"] = job_id
307
- if error_message:
308
- data["error_message"] = error_message
309
-
310
- # Build metadata with trace name and step counts
311
- metadata = {}
312
- if trace_name:
313
- metadata["trace_name"] = trace_name
314
-
315
- # Include all three step counts in metadata
316
- metadata["base_mcp_steps"] = get_base_mcp_steps()
317
- metadata["mcp_tool_steps"] = get_mcp_tool_steps()
318
- metadata["agent_steps"] = get_agent_steps()
319
-
320
- if metadata:
321
- data["metadata"] = metadata
322
-
323
- if task_id:
324
- data["task_id"] = task_id
325
-
326
- make_request_sync(
327
- method="POST",
328
- url=f"{settings.hud_telemetry_url}/trace/{task_run_id}/status",
329
- json=data,
330
- api_key=settings.api_key,
331
- )
332
- logger.debug("Updated task %s status to %s", task_run_id, status)
333
- except Exception as e:
334
- # Suppress warnings about interpreter shutdown
335
- if "interpreter shutdown" not in str(e):
336
- logger.warning("Failed to update task status: %s", e)
337
-
338
-
339
- def _print_trace_url(task_run_id: str) -> None:
340
- """Print the trace URL in a colorful box."""
341
- # Only print HUD URL if HUD telemetry is enabled and has API key
342
- if not (settings.telemetry_enabled and settings.api_key):
343
- return
344
-
345
- url = f"https://app.hud.so/trace/{task_run_id}"
346
- header = "🚀 See your agent live at:"
347
-
348
- # ANSI color codes
349
- DIM = "\033[90m" # Dim/Gray for border (visible on both light and dark terminals)
350
- GOLD = "\033[33m" # Gold/Yellow for URL
351
- RESET = "\033[0m"
352
- BOLD = "\033[1m"
353
-
354
- # Calculate box width based on the longest line
355
- box_width = max(len(url), len(header)) + 6
356
-
357
- # Box drawing characters
358
- top_border = "╔" + "═" * (box_width - 2) + "╗"
359
- bottom_border = "╚" + "═" * (box_width - 2) + "╝"
360
- divider = "╟" + "─" * (box_width - 2) + "╢"
361
-
362
- # Center the content
363
- header_padding = (box_width - len(header) - 2) // 2
364
- url_padding = (box_width - len(url) - 2) // 2
365
-
366
- # Print the box
367
- print(f"\n{DIM}{top_border}{RESET}") # noqa: T201
368
- print( # noqa: T201
369
- f"{DIM}║{RESET}{' ' * header_padding}{header}{' ' * (box_width - len(header) - header_padding - 3)}{DIM}║{RESET}" # noqa: E501
370
- )
371
- print(f"{DIM}{divider}{RESET}") # noqa: T201
372
- print( # noqa: T201
373
- f"{DIM}║{RESET}{' ' * url_padding}{BOLD}{GOLD}{url}{RESET}{' ' * (box_width - len(url) - url_padding - 2)}{DIM}║{RESET}" # noqa: E501
374
- )
375
- print(f"{DIM}{bottom_border}{RESET}\n") # noqa: T201
376
-
377
-
378
- def _print_trace_complete_url(task_run_id: str, error_occurred: bool = False) -> None:
379
- """Print the trace completion URL with appropriate messaging."""
380
- # Only print HUD URL if HUD telemetry is enabled and has API key
381
- if not (settings.telemetry_enabled and settings.api_key):
382
- return
383
-
384
- url = f"https://app.hud.so/trace/{task_run_id}"
385
-
386
- # ANSI color codes
387
- GREEN = "\033[92m"
388
- RED = "\033[91m"
389
- GOLD = "\033[33m"
390
- RESET = "\033[0m"
391
- DIM = "\033[2m"
392
- BOLD = "\033[1m"
393
-
394
- if error_occurred:
395
- print( # noqa: T201
396
- f"\n{RED}✗ Trace errored!{RESET} {DIM}More error details available at:{RESET} {BOLD}{GOLD}{url}{RESET}\n" # noqa: E501
397
- )
398
- else:
399
- print(f"\n{GREEN}✓ Trace complete!{RESET} {DIM}View at:{RESET} {BOLD}{GOLD}{url}{RESET}\n") # noqa: T201
400
-
401
-
402
- class trace:
403
- """Internal OpenTelemetry trace context manager.
404
-
405
- This is the implementation class. Users should use hud.trace() instead.
406
- """
407
-
408
- def __init__(
409
- self,
410
- task_run_id: str,
411
- is_root: bool = True,
412
- span_name: str = "hud.task",
413
- attributes: dict[str, Any] | None = None,
414
- job_id: str | None = None,
415
- task_id: str | None = None,
416
- ) -> None:
417
- self.task_run_id = task_run_id
418
- self.job_id = job_id
419
- self.task_id = task_id
420
- self.is_root = is_root
421
- self.span_name = span_name
422
- self.attributes = attributes or {}
423
- self._span: otel_trace.Span | None = None
424
- self._span_manager: Any | None = None
425
- self._otel_token: object | None = None
426
- self._task_run_token = None
427
- self._root_token = None
428
-
429
- def __enter__(self) -> str:
430
- """Enter the trace context and return the task_run_id."""
431
- # Set context variables
432
- self._task_run_token = set_current_task_run_id(self.task_run_id)
433
- self._root_token = is_root_trace_var.set(self.is_root)
434
-
435
- # Set OpenTelemetry baggage for propagation
436
- ctx = baggage.set_baggage(TASK_RUN_ID_KEY, self.task_run_id)
437
- ctx = baggage.set_baggage(IS_ROOT_TRACE_KEY, str(self.is_root), context=ctx)
438
- if self.job_id:
439
- ctx = baggage.set_baggage("hud.job_id", self.job_id, context=ctx)
440
- if self.task_id:
441
- ctx = baggage.set_baggage("hud.task_id", self.task_id, context=ctx)
442
- self._otel_token = context.attach(ctx)
443
-
444
- # Start a span as current
445
- tracer = otel_trace.get_tracer("hud-sdk")
446
- span_attrs = {
447
- "hud.task_run_id": self.task_run_id,
448
- "hud.is_root_trace": self.is_root,
449
- **self.attributes,
450
- }
451
- if self.job_id:
452
- span_attrs["hud.job_id"] = self.job_id
453
- if self.task_id:
454
- span_attrs["hud.task_id"] = self.task_id
455
-
456
- # Use start_as_current_span context manager
457
- self._span_manager = tracer.start_as_current_span(
458
- self.span_name,
459
- attributes=span_attrs,
460
- )
461
- self._span = self._span_manager.__enter__()
462
-
463
- # Update task status to running if root (only for HUD backend)
464
- if self.is_root and settings.telemetry_enabled and settings.api_key:
465
- _fire_and_forget_status_update(
466
- self.task_run_id,
467
- "running",
468
- job_id=self.job_id,
469
- trace_name=self.span_name,
470
- task_id=self.task_id,
471
- )
472
- # Print the nice trace URL box (only if not part of a job)
473
- if not self.job_id:
474
- _print_trace_url(self.task_run_id)
475
-
476
- logger.debug("Started HUD trace context for task_run_id=%s", self.task_run_id)
477
- return self.task_run_id
478
-
479
- def __exit__(
480
- self,
481
- exc_type: type[BaseException] | None,
482
- exc_val: BaseException | None,
483
- exc_tb: TracebackType | None,
484
- ) -> None:
485
- """Exit the trace context."""
486
- # Update task status if root (only for HUD backend)
487
- if self.is_root and settings.telemetry_enabled and settings.api_key:
488
- if exc_type is not None:
489
- # Use synchronous update to ensure it completes before process exit
490
- _update_task_status_sync(
491
- self.task_run_id,
492
- "error",
493
- job_id=self.job_id,
494
- error_message=str(exc_val),
495
- trace_name=self.span_name,
496
- task_id=self.task_id,
497
- )
498
- # Print error completion message (only if not part of a job)
499
- if not self.job_id:
500
- _print_trace_complete_url(self.task_run_id, error_occurred=True)
501
- else:
502
- # Use synchronous update to ensure it completes before process exit
503
- _update_task_status_sync(
504
- self.task_run_id,
505
- "completed",
506
- job_id=self.job_id,
507
- trace_name=self.span_name,
508
- task_id=self.task_id,
509
- )
510
- # Print success completion message (only if not part of a job)
511
- if not self.job_id:
512
- _print_trace_complete_url(self.task_run_id, error_occurred=False)
513
-
514
- # End the span
515
- if self._span and self._span_manager is not None:
516
- if exc_type is not None and exc_val is not None:
517
- self._span.record_exception(exc_val)
518
- self._span.set_status(Status(StatusCode.ERROR, str(exc_val)))
519
- else:
520
- self._span.set_status(Status(StatusCode.OK))
521
- self._span_manager.__exit__(exc_type, exc_val, exc_tb)
522
-
523
- # Detach OpenTelemetry context
524
- if self._otel_token is not None:
525
- try:
526
- context.detach(self._otel_token) # type: ignore[arg-type]
527
- except Exception:
528
- logger.warning("Failed to detach OpenTelemetry context")
529
-
530
- # Reset context variables
531
- if self._task_run_token is not None:
532
- current_task_run_id.reset(self._task_run_token) # type: ignore
533
- if self._root_token is not None:
534
- is_root_trace_var.reset(self._root_token) # type: ignore
535
-
536
- logger.debug("Ended HUD trace context for task_run_id=%s", self.task_run_id)
1
+ """OpenTelemetry context utilities for HUD telemetry.
2
+
3
+ This module provides internal utilities for managing OpenTelemetry contexts.
4
+ User-facing APIs are in hud.telemetry.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextvars
10
+ import logging
11
+ from contextlib import contextmanager
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from opentelemetry import baggage, context
15
+ from opentelemetry import trace as otel_trace
16
+ from opentelemetry.trace import Status, StatusCode
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Generator
20
+ from types import TracebackType
21
+
22
+ from hud.settings import settings
23
+ from hud.shared import make_request, make_request_sync
24
+ from hud.utils.async_utils import fire_and_forget
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Context variables for task tracking
29
+ current_task_run_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
30
+ "current_task_run_id", default=None
31
+ )
32
+ is_root_trace_var: contextvars.ContextVar[bool] = contextvars.ContextVar(
33
+ "is_root_trace", default=False
34
+ )
35
+
36
+ # Step counters for different types
37
+ current_base_mcp_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
38
+ "current_base_mcp_steps", default=0
39
+ )
40
+ current_mcp_tool_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
41
+ "current_mcp_tool_steps", default=0
42
+ )
43
+ current_agent_steps: contextvars.ContextVar[int] = contextvars.ContextVar(
44
+ "current_agent_steps", default=0
45
+ )
46
+
47
+ # Keys for OpenTelemetry baggage
48
+ TASK_RUN_ID_KEY = "hud.task_run_id"
49
+ IS_ROOT_TRACE_KEY = "hud.is_root_trace"
50
+ BASE_MCP_STEPS_KEY = "hud.base_mcp_steps"
51
+ MCP_TOOL_STEPS_KEY = "hud.mcp_tool_steps"
52
+ AGENT_STEPS_KEY = "hud.agent_steps"
53
+
54
+
55
+ def set_current_task_run_id(task_run_id: str | None) -> contextvars.Token:
56
+ """Set the current task run ID."""
57
+ return current_task_run_id.set(task_run_id)
58
+
59
+
60
+ def get_current_task_run_id() -> str | None:
61
+ """Get current task_run_id from either contextvars or OTel baggage."""
62
+ # First try OTel baggage
63
+ task_run_id = baggage.get_baggage(TASK_RUN_ID_KEY)
64
+ if task_run_id and isinstance(task_run_id, str):
65
+ return task_run_id
66
+
67
+ # Fallback to contextvars
68
+ return current_task_run_id.get()
69
+
70
+
71
+ def is_root_trace() -> bool:
72
+ """Check if current context is a root trace."""
73
+ # First try OTel baggage
74
+ is_root = baggage.get_baggage(IS_ROOT_TRACE_KEY)
75
+ if isinstance(is_root, str):
76
+ return is_root.lower() == "true"
77
+
78
+ # Fallback to contextvars
79
+ return is_root_trace_var.get()
80
+
81
+
82
+ def get_base_mcp_steps() -> int:
83
+ """Get current base MCP step count from either contextvars or OTel baggage."""
84
+ # First try OTel baggage
85
+ step_count = baggage.get_baggage(BASE_MCP_STEPS_KEY)
86
+ if step_count and isinstance(step_count, str):
87
+ try:
88
+ return int(step_count)
89
+ except ValueError:
90
+ pass
91
+
92
+ # Fallback to contextvars
93
+ return current_base_mcp_steps.get()
94
+
95
+
96
+ def get_mcp_tool_steps() -> int:
97
+ """Get current MCP tool step count from either contextvars or OTel baggage."""
98
+ # First try OTel baggage
99
+ step_count = baggage.get_baggage(MCP_TOOL_STEPS_KEY)
100
+ if step_count and isinstance(step_count, str):
101
+ try:
102
+ return int(step_count)
103
+ except ValueError:
104
+ pass
105
+
106
+ # Fallback to contextvars
107
+ return current_mcp_tool_steps.get()
108
+
109
+
110
+ def get_agent_steps() -> int:
111
+ """Get current agent step count from either contextvars or OTel baggage."""
112
+ # First try OTel baggage
113
+ step_count = baggage.get_baggage(AGENT_STEPS_KEY)
114
+ if step_count and isinstance(step_count, str):
115
+ try:
116
+ return int(step_count)
117
+ except ValueError:
118
+ pass
119
+
120
+ # Fallback to contextvars
121
+ return current_agent_steps.get()
122
+
123
+
124
+ def increment_base_mcp_steps() -> int:
125
+ """Increment the base MCP step count and update baggage.
126
+
127
+ Returns:
128
+ The new base MCP step count after incrementing
129
+ """
130
+ current = get_base_mcp_steps()
131
+ new_count = current + 1
132
+
133
+ # Update contextvar
134
+ current_base_mcp_steps.set(new_count)
135
+
136
+ # Update baggage for propagation
137
+ ctx = baggage.set_baggage(BASE_MCP_STEPS_KEY, str(new_count))
138
+ context.attach(ctx)
139
+
140
+ # Update current span if one exists
141
+ span = otel_trace.get_current_span()
142
+ if span and span.is_recording():
143
+ span.set_attribute("hud.base_mcp_steps", new_count)
144
+
145
+ return new_count
146
+
147
+
148
+ def increment_mcp_tool_steps() -> int:
149
+ """Increment the MCP tool step count and update baggage.
150
+
151
+ Returns:
152
+ The new MCP tool step count after incrementing
153
+ """
154
+ current = get_mcp_tool_steps()
155
+ new_count = current + 1
156
+
157
+ # Update contextvar
158
+ current_mcp_tool_steps.set(new_count)
159
+
160
+ # Update baggage for propagation
161
+ ctx = baggage.set_baggage(MCP_TOOL_STEPS_KEY, str(new_count))
162
+ context.attach(ctx)
163
+
164
+ # Update current span if one exists
165
+ span = otel_trace.get_current_span()
166
+ if span and span.is_recording():
167
+ span.set_attribute("hud.mcp_tool_steps", new_count)
168
+
169
+ return new_count
170
+
171
+
172
+ def increment_agent_steps() -> int:
173
+ """Increment the agent step count and update baggage.
174
+
175
+ Returns:
176
+ The new agent step count after incrementing
177
+ """
178
+ current = get_agent_steps()
179
+ new_count = current + 1
180
+
181
+ # Update contextvar
182
+ current_agent_steps.set(new_count)
183
+
184
+ # Update baggage for propagation
185
+ ctx = baggage.set_baggage(AGENT_STEPS_KEY, str(new_count))
186
+ context.attach(ctx)
187
+
188
+ # Update current span if one exists
189
+ span = otel_trace.get_current_span()
190
+ if span and span.is_recording():
191
+ span.set_attribute("hud.agent_steps", new_count)
192
+
193
+ return new_count
194
+
195
+
196
+ @contextmanager
197
+ def span_context(
198
+ name: str,
199
+ attributes: dict[str, Any] | None = None,
200
+ kind: otel_trace.SpanKind = otel_trace.SpanKind.INTERNAL,
201
+ ) -> Generator[otel_trace.Span, None, None]:
202
+ """Create a child span within the current trace context.
203
+
204
+ This is a simple wrapper around OpenTelemetry's span creation that
205
+ ensures the span inherits the current HUD context (task_run_id, etc).
206
+
207
+ Args:
208
+ name: Name for the span
209
+ attributes: Additional attributes to add to the span
210
+ kind: OpenTelemetry span kind
211
+
212
+ Example:
213
+ with span_context("process_data", {"items": 100}) as span:
214
+ # Process data...
215
+ span.set_attribute("processed", True)
216
+ """
217
+ tracer = otel_trace.get_tracer("hud-sdk")
218
+
219
+ # Current task_run_id will be added by HudEnrichmentProcessor
220
+ with tracer.start_as_current_span(
221
+ name,
222
+ attributes=attributes,
223
+ kind=kind,
224
+ ) as span:
225
+ yield span
226
+
227
+
228
+ async def _update_task_status_async(
229
+ task_run_id: str,
230
+ status: str,
231
+ job_id: str | None = None,
232
+ error_message: str | None = None,
233
+ trace_name: str | None = None,
234
+ task_id: str | None = None,
235
+ ) -> None:
236
+ """Async task status update."""
237
+ if not settings.telemetry_enabled:
238
+ return
239
+
240
+ try:
241
+ data: dict[str, Any] = {"status": status}
242
+ if job_id:
243
+ data["job_id"] = job_id
244
+ if error_message:
245
+ data["error_message"] = error_message
246
+
247
+ # Build metadata with trace name and step counts
248
+ metadata = {}
249
+ if trace_name:
250
+ metadata["trace_name"] = trace_name
251
+
252
+ # Include all three step counts in metadata
253
+ metadata["base_mcp_steps"] = get_base_mcp_steps()
254
+ metadata["mcp_tool_steps"] = get_mcp_tool_steps()
255
+ metadata["agent_steps"] = get_agent_steps()
256
+
257
+ if metadata:
258
+ data["metadata"] = metadata
259
+
260
+ if task_id:
261
+ data["task_id"] = task_id
262
+
263
+ await make_request(
264
+ method="POST",
265
+ url=f"{settings.hud_telemetry_url}/trace/{task_run_id}/status",
266
+ json=data,
267
+ api_key=settings.api_key,
268
+ )
269
+ logger.debug("Updated task %s status to %s", task_run_id, status)
270
+ except Exception as e:
271
+ # Suppress warnings about interpreter shutdown
272
+ if "interpreter shutdown" not in str(e):
273
+ logger.warning("Failed to update task status: %s", e)
274
+
275
+
276
+ def _fire_and_forget_status_update(
277
+ task_run_id: str,
278
+ status: str,
279
+ job_id: str | None = None,
280
+ error_message: str | None = None,
281
+ trace_name: str | None = None,
282
+ task_id: str | None = None,
283
+ ) -> None:
284
+ """Fire and forget status update - works in any context including Jupyter."""
285
+ fire_and_forget(
286
+ _update_task_status_async(task_run_id, status, job_id, error_message, trace_name, task_id),
287
+ f"update task {task_run_id} status to {status}",
288
+ )
289
+
290
+
291
+ def _update_task_status_sync(
292
+ task_run_id: str,
293
+ status: str,
294
+ job_id: str | None = None,
295
+ error_message: str | None = None,
296
+ trace_name: str | None = None,
297
+ task_id: str | None = None,
298
+ ) -> None:
299
+ """Synchronous task status update."""
300
+ if not settings.telemetry_enabled:
301
+ return
302
+
303
+ try:
304
+ data: dict[str, Any] = {"status": status}
305
+ if job_id:
306
+ data["job_id"] = job_id
307
+ if error_message:
308
+ data["error_message"] = error_message
309
+
310
+ # Build metadata with trace name and step counts
311
+ metadata = {}
312
+ if trace_name:
313
+ metadata["trace_name"] = trace_name
314
+
315
+ # Include all three step counts in metadata
316
+ metadata["base_mcp_steps"] = get_base_mcp_steps()
317
+ metadata["mcp_tool_steps"] = get_mcp_tool_steps()
318
+ metadata["agent_steps"] = get_agent_steps()
319
+
320
+ if metadata:
321
+ data["metadata"] = metadata
322
+
323
+ if task_id:
324
+ data["task_id"] = task_id
325
+
326
+ make_request_sync(
327
+ method="POST",
328
+ url=f"{settings.hud_telemetry_url}/trace/{task_run_id}/status",
329
+ json=data,
330
+ api_key=settings.api_key,
331
+ )
332
+ logger.debug("Updated task %s status to %s", task_run_id, status)
333
+ except Exception as e:
334
+ # Suppress warnings about interpreter shutdown
335
+ if "interpreter shutdown" not in str(e):
336
+ logger.warning("Failed to update task status: %s", e)
337
+
338
+
339
+ def _print_trace_url(task_run_id: str) -> None:
340
+ """Print the trace URL in a colorful box."""
341
+ # Only print HUD URL if HUD telemetry is enabled and has API key
342
+ if not (settings.telemetry_enabled and settings.api_key):
343
+ return
344
+
345
+ url = f"https://app.hud.so/trace/{task_run_id}"
346
+ header = "🚀 See your agent live at:"
347
+
348
+ # ANSI color codes
349
+ DIM = "\033[90m" # Dim/Gray for border (visible on both light and dark terminals)
350
+ GOLD = "\033[33m" # Gold/Yellow for URL
351
+ RESET = "\033[0m"
352
+ BOLD = "\033[1m"
353
+
354
+ # Calculate box width based on the longest line
355
+ box_width = max(len(url), len(header)) + 6
356
+
357
+ # Box drawing characters
358
+ top_border = "╔" + "═" * (box_width - 2) + "╗"
359
+ bottom_border = "╚" + "═" * (box_width - 2) + "╝"
360
+ divider = "╟" + "─" * (box_width - 2) + "╢"
361
+
362
+ # Center the content
363
+ header_padding = (box_width - len(header) - 2) // 2
364
+ url_padding = (box_width - len(url) - 2) // 2
365
+
366
+ # Print the box
367
+ print(f"\n{DIM}{top_border}{RESET}") # noqa: T201
368
+ print( # noqa: T201
369
+ f"{DIM}║{RESET}{' ' * header_padding}{header}{' ' * (box_width - len(header) - header_padding - 3)}{DIM}║{RESET}" # noqa: E501
370
+ )
371
+ print(f"{DIM}{divider}{RESET}") # noqa: T201
372
+ print( # noqa: T201
373
+ f"{DIM}║{RESET}{' ' * url_padding}{BOLD}{GOLD}{url}{RESET}{' ' * (box_width - len(url) - url_padding - 2)}{DIM}║{RESET}" # noqa: E501
374
+ )
375
+ print(f"{DIM}{bottom_border}{RESET}\n") # noqa: T201
376
+
377
+
378
+ def _print_trace_complete_url(task_run_id: str, error_occurred: bool = False) -> None:
379
+ """Print the trace completion URL with appropriate messaging."""
380
+ # Only print HUD URL if HUD telemetry is enabled and has API key
381
+ if not (settings.telemetry_enabled and settings.api_key):
382
+ return
383
+
384
+ url = f"https://app.hud.so/trace/{task_run_id}"
385
+
386
+ # ANSI color codes
387
+ GREEN = "\033[92m"
388
+ RED = "\033[91m"
389
+ GOLD = "\033[33m"
390
+ RESET = "\033[0m"
391
+ DIM = "\033[2m"
392
+ BOLD = "\033[1m"
393
+
394
+ if error_occurred:
395
+ print( # noqa: T201
396
+ f"\n{RED}✗ Trace errored!{RESET} {DIM}More error details available at:{RESET} {BOLD}{GOLD}{url}{RESET}\n" # noqa: E501
397
+ )
398
+ else:
399
+ print(f"\n{GREEN}✓ Trace complete!{RESET} {DIM}View at:{RESET} {BOLD}{GOLD}{url}{RESET}\n") # noqa: T201
400
+
401
+
402
+ class trace:
403
+ """Internal OpenTelemetry trace context manager.
404
+
405
+ This is the implementation class. Users should use hud.trace() instead.
406
+ """
407
+
408
+ def __init__(
409
+ self,
410
+ task_run_id: str,
411
+ is_root: bool = True,
412
+ span_name: str = "hud.task",
413
+ attributes: dict[str, Any] | None = None,
414
+ job_id: str | None = None,
415
+ task_id: str | None = None,
416
+ ) -> None:
417
+ self.task_run_id = task_run_id
418
+ self.job_id = job_id
419
+ self.task_id = task_id
420
+ self.is_root = is_root
421
+ self.span_name = span_name
422
+ self.attributes = attributes or {}
423
+ self._span: otel_trace.Span | None = None
424
+ self._span_manager: Any | None = None
425
+ self._otel_token: object | None = None
426
+ self._task_run_token = None
427
+ self._root_token = None
428
+
429
+ def __enter__(self) -> str:
430
+ """Enter the trace context and return the task_run_id."""
431
+ # Set context variables
432
+ self._task_run_token = set_current_task_run_id(self.task_run_id)
433
+ self._root_token = is_root_trace_var.set(self.is_root)
434
+
435
+ # Set OpenTelemetry baggage for propagation
436
+ ctx = baggage.set_baggage(TASK_RUN_ID_KEY, self.task_run_id)
437
+ ctx = baggage.set_baggage(IS_ROOT_TRACE_KEY, str(self.is_root), context=ctx)
438
+ if self.job_id:
439
+ ctx = baggage.set_baggage("hud.job_id", self.job_id, context=ctx)
440
+ if self.task_id:
441
+ ctx = baggage.set_baggage("hud.task_id", self.task_id, context=ctx)
442
+ self._otel_token = context.attach(ctx)
443
+
444
+ # Start a span as current
445
+ tracer = otel_trace.get_tracer("hud-sdk")
446
+ span_attrs = {
447
+ "hud.task_run_id": self.task_run_id,
448
+ "hud.is_root_trace": self.is_root,
449
+ **self.attributes,
450
+ }
451
+ if self.job_id:
452
+ span_attrs["hud.job_id"] = self.job_id
453
+ if self.task_id:
454
+ span_attrs["hud.task_id"] = self.task_id
455
+
456
+ # Use start_as_current_span context manager
457
+ self._span_manager = tracer.start_as_current_span(
458
+ self.span_name,
459
+ attributes=span_attrs,
460
+ )
461
+ self._span = self._span_manager.__enter__()
462
+
463
+ # Update task status to running if root (only for HUD backend)
464
+ if self.is_root and settings.telemetry_enabled and settings.api_key:
465
+ _fire_and_forget_status_update(
466
+ self.task_run_id,
467
+ "running",
468
+ job_id=self.job_id,
469
+ trace_name=self.span_name,
470
+ task_id=self.task_id,
471
+ )
472
+ # Print the nice trace URL box (only if not part of a job)
473
+ if not self.job_id:
474
+ _print_trace_url(self.task_run_id)
475
+
476
+ logger.debug("Started HUD trace context for task_run_id=%s", self.task_run_id)
477
+ return self.task_run_id
478
+
479
+ def __exit__(
480
+ self,
481
+ exc_type: type[BaseException] | None,
482
+ exc_val: BaseException | None,
483
+ exc_tb: TracebackType | None,
484
+ ) -> None:
485
+ """Exit the trace context."""
486
+ # Update task status if root (only for HUD backend)
487
+ if self.is_root and settings.telemetry_enabled and settings.api_key:
488
+ if exc_type is not None:
489
+ # Use synchronous update to ensure it completes before process exit
490
+ _update_task_status_sync(
491
+ self.task_run_id,
492
+ "error",
493
+ job_id=self.job_id,
494
+ error_message=str(exc_val),
495
+ trace_name=self.span_name,
496
+ task_id=self.task_id,
497
+ )
498
+ # Print error completion message (only if not part of a job)
499
+ if not self.job_id:
500
+ _print_trace_complete_url(self.task_run_id, error_occurred=True)
501
+ else:
502
+ # Use synchronous update to ensure it completes before process exit
503
+ _update_task_status_sync(
504
+ self.task_run_id,
505
+ "completed",
506
+ job_id=self.job_id,
507
+ trace_name=self.span_name,
508
+ task_id=self.task_id,
509
+ )
510
+ # Print success completion message (only if not part of a job)
511
+ if not self.job_id:
512
+ _print_trace_complete_url(self.task_run_id, error_occurred=False)
513
+
514
+ # End the span
515
+ if self._span and self._span_manager is not None:
516
+ if exc_type is not None and exc_val is not None:
517
+ self._span.record_exception(exc_val)
518
+ self._span.set_status(Status(StatusCode.ERROR, str(exc_val)))
519
+ else:
520
+ self._span.set_status(Status(StatusCode.OK))
521
+ self._span_manager.__exit__(exc_type, exc_val, exc_tb)
522
+
523
+ # Detach OpenTelemetry context
524
+ if self._otel_token is not None:
525
+ try:
526
+ context.detach(self._otel_token) # type: ignore[arg-type]
527
+ except Exception:
528
+ logger.warning("Failed to detach OpenTelemetry context")
529
+
530
+ # Reset context variables
531
+ if self._task_run_token is not None:
532
+ current_task_run_id.reset(self._task_run_token) # type: ignore
533
+ if self._root_token is not None:
534
+ is_root_trace_var.reset(self._root_token) # type: ignore
535
+
536
+ logger.debug("Ended HUD trace context for task_run_id=%s", self.task_run_id)