hexdag 0.5.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
@@ -0,0 +1,690 @@
1
+ """Core observer implementations for common use cases.
2
+
3
+ This module provides ready-to-use observer implementations that demonstrate
4
+ best practices and common patterns for event observation in hexDAG.
5
+
6
+ All observers follow these principles:
7
+ - READ-ONLY: Observers never modify execution or state
8
+ - FAULT-ISOLATED: Observer failures don't affect pipeline execution
9
+ - ASYNC-FIRST: All observers support async operation
10
+ - TYPE-SAFE: Proper type hints and Pydantic validation where applicable
11
+ - EVENT FILTERING: Use event_types at registration for performance
12
+ - FRAMEWORK FEATURES: Leverage built-in event taxonomy and helpers
13
+ """
14
+
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field
17
+ from typing import Any
18
+
19
+ from hexdag.core.logging import get_logger
20
+ from hexdag.core.orchestration.events.events import (
21
+ Event,
22
+ NodeCompleted,
23
+ NodeFailed,
24
+ NodeStarted,
25
+ PipelineCompleted,
26
+ PipelineStarted,
27
+ )
28
+ from hexdag.core.orchestration.events.observers.models import (
29
+ Alert,
30
+ AlertSeverity,
31
+ AlertType,
32
+ NodeMetrics,
33
+ )
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ # ==============================================================================
39
+ # METRICS AND MONITORING OBSERVERS
40
+ # ==============================================================================
41
+
42
+
43
+ class PerformanceMetricsObserver:
44
+ """Observer that collects comprehensive performance metrics.
45
+
46
+ This observer tracks:
47
+ - Node execution counts and timings
48
+ - Success/failure rates
49
+ - Average, min, max execution times per node
50
+ - Total pipeline duration
51
+
52
+ Uses consolidated NodeMetrics dataclass following the HandlerEntry pattern
53
+ for efficient storage and computation.
54
+
55
+ Example
56
+ -------
57
+ >>> from hexdag.builtin.adapters.local import LocalObserverManager # doctest: +SKIP
58
+ >>> from hexdag.core.orchestration.events import ( # doctest: +SKIP
59
+ ... PerformanceMetricsObserver,
60
+ ... ALL_EXECUTION_EVENTS,
61
+ ... )
62
+ >>> observer_manager = LocalObserverManager() # doctest: +SKIP
63
+ >>> metrics = PerformanceMetricsObserver() # doctest: +SKIP
64
+ >>> # Register with event filtering for ~90% performance improvement
65
+ >>> observer_manager.register( # doctest: +SKIP
66
+ ... metrics.handle,
67
+ ... event_types=ALL_EXECUTION_EVENTS
68
+ ... ) # doctest: +SKIP
69
+ >>> # ... run pipeline ...
70
+ >>> print(metrics.get_summary()) # doctest: +SKIP
71
+ """
72
+
73
+ def __init__(self) -> None:
74
+ """Initialize the performance metrics observer."""
75
+ # Consolidated storage using NodeMetrics dataclass
76
+ self.metrics: dict[str, NodeMetrics] = {}
77
+ self.total_nodes = 0
78
+ self.total_duration_ms = 0.0
79
+ self.pipeline_start_times: dict[str, float] = {}
80
+ self.pipeline_end_times: dict[str, float] = {}
81
+
82
+ async def handle(self, event: Event) -> None:
83
+ """Handle performance-related events.
84
+
85
+ Note: Should be registered with event_types=ALL_EXECUTION_EVENTS
86
+ for optimal performance. Event filtering at registration provides
87
+ ~90% reduction in unnecessary handler invocations.
88
+
89
+ Parameters
90
+ ----------
91
+ event : Event
92
+ The event to process
93
+ """
94
+ if isinstance(event, PipelineStarted):
95
+ self.pipeline_start_times[event.name] = event.timestamp.timestamp()
96
+
97
+ elif isinstance(event, NodeStarted):
98
+ if event.name not in self.metrics:
99
+ self.metrics[event.name] = NodeMetrics()
100
+ self.metrics[event.name].executions += 1
101
+ self.total_nodes += 1
102
+
103
+ elif isinstance(event, NodeCompleted):
104
+ if event.name not in self.metrics:
105
+ self.metrics[event.name] = NodeMetrics()
106
+ self.metrics[event.name].timings.append(event.duration_ms)
107
+ self.total_duration_ms += event.duration_ms
108
+
109
+ elif isinstance(event, NodeFailed):
110
+ if event.name not in self.metrics:
111
+ self.metrics[event.name] = NodeMetrics()
112
+ self.metrics[event.name].failures += 1
113
+
114
+ elif isinstance(event, PipelineCompleted):
115
+ self.pipeline_end_times[event.name] = event.timestamp.timestamp()
116
+
117
+ def get_summary(self) -> dict[str, Any]:
118
+ """Generate comprehensive metrics summary.
119
+
120
+ Returns
121
+ -------
122
+ dict[str, Any]
123
+ Dictionary containing performance metrics including:
124
+ - total_nodes_executed: Total number of nodes executed
125
+ - unique_nodes: Number of unique node types
126
+ - total_duration_ms: Total execution time across all nodes
127
+ - average_timings_ms: Average execution time per node
128
+ - min_timings_ms: Minimum execution time per node
129
+ - max_timings_ms: Maximum execution time per node
130
+ - node_executions: Execution count per node
131
+ - failures: Failure count per node
132
+ - success_rates: Success rate per node (percentage)
133
+ - total_failures: Total failures across all nodes
134
+ - overall_success_rate: Overall success rate percentage
135
+ """
136
+ avg_timings, min_timings, max_timings = {}, {}, {}
137
+ node_executions, failures, success_rates = {}, {}, {}
138
+
139
+ for node, m in self.metrics.items():
140
+ avg_timings[node] = m.average_ms
141
+ min_timings[node] = m.min_ms
142
+ max_timings[node] = m.max_ms
143
+ node_executions[node] = m.executions
144
+ failures[node] = m.failures
145
+ success_rates[node] = m.success_rate
146
+
147
+ total_failures = sum(failures.values())
148
+ overall_success_rate = (
149
+ (self.total_nodes - total_failures) / self.total_nodes * 100
150
+ if self.total_nodes > 0
151
+ else 0.0
152
+ )
153
+
154
+ return {
155
+ "total_nodes_executed": self.total_nodes,
156
+ "unique_nodes": len(self.metrics),
157
+ "total_duration_ms": self.total_duration_ms,
158
+ "average_timings_ms": avg_timings,
159
+ "min_timings_ms": min_timings,
160
+ "max_timings_ms": max_timings,
161
+ "node_executions": node_executions,
162
+ "failures": failures,
163
+ "success_rates": success_rates,
164
+ "total_failures": total_failures,
165
+ "overall_success_rate": overall_success_rate,
166
+ }
167
+
168
+ def reset(self) -> None:
169
+ """Reset all metrics to initial state."""
170
+ self.metrics.clear()
171
+ self.total_nodes = 0
172
+ self.total_duration_ms = 0.0
173
+ self.pipeline_start_times.clear()
174
+ self.pipeline_end_times.clear()
175
+
176
+
177
+ class AlertingObserver:
178
+ """Observer that triggers alerts based on configurable thresholds.
179
+
180
+ This observer monitors execution and triggers alerts when:
181
+ - Node execution exceeds time threshold (slow node alert)
182
+ - Node fails (failure alert)
183
+ - Custom conditions are met via callback
184
+
185
+ Uses typed Alert dataclass for type safety and validation.
186
+
187
+ Parameters
188
+ ----------
189
+ slow_threshold_ms : float
190
+ Threshold in milliseconds for slow node alert (default: 1000.0)
191
+ on_alert : callable, optional
192
+ Callback function(Alert) called when alert is triggered
193
+
194
+ Example
195
+ -------
196
+ >>> from hexdag.core.orchestration.events import NODE_LIFECYCLE_EVENTS
197
+ >>> def handle_alert(alert: Alert):
198
+ ... print(f"ALERT: {alert.message}")
199
+ ... # Send to monitoring system, etc.
200
+ >>> alerting = AlertingObserver(slow_threshold_ms=500.0, on_alert=handle_alert)
201
+ >>> # Register with event filtering for performance
202
+ >>> observer_manager.register( # doctest: +SKIP
203
+ ... alerting.handle,
204
+ ... event_types=[NodeCompleted, NodeFailed]
205
+ ... )
206
+ >>> # Check alerts programmatically
207
+ >>> alerts = alerting.get_alerts()
208
+ """
209
+
210
+ def __init__(
211
+ self,
212
+ slow_threshold_ms: float = 1000.0,
213
+ on_alert: Callable[[Alert], None] | None = None,
214
+ ):
215
+ """Initialize alerting observer.
216
+
217
+ Parameters
218
+ ----------
219
+ slow_threshold_ms : float
220
+ Millisecond threshold for slow node warnings
221
+ on_alert : callable, optional
222
+ Function to call when alert is triggered with Alert object
223
+ """
224
+ self.slow_threshold = slow_threshold_ms
225
+ self.on_alert = on_alert
226
+ self.alerts: list[Alert] = []
227
+
228
+ async def handle(self, event: Event) -> None:
229
+ """Monitor events and trigger alerts.
230
+
231
+ Note: Should be registered with event_types=[NodeCompleted, NodeFailed]
232
+ for optimal performance.
233
+
234
+ Parameters
235
+ ----------
236
+ event : Event
237
+ Event to monitor
238
+ """
239
+ alert: Alert | None = None
240
+
241
+ if isinstance(event, NodeCompleted):
242
+ if event.duration_ms > self.slow_threshold:
243
+ alert = Alert(
244
+ type=AlertType.SLOW_NODE,
245
+ node=event.name,
246
+ message=(
247
+ f"Node '{event.name}' took {event.duration_ms:.1f}ms "
248
+ f"(threshold: {self.slow_threshold}ms)"
249
+ ),
250
+ timestamp=event.timestamp.timestamp(),
251
+ severity=AlertSeverity.WARNING,
252
+ duration_ms=event.duration_ms,
253
+ threshold_ms=self.slow_threshold,
254
+ )
255
+ logger.warning(alert.message)
256
+
257
+ elif isinstance(event, NodeFailed):
258
+ alert = Alert(
259
+ type=AlertType.NODE_FAILURE,
260
+ node=event.name,
261
+ message=f"Node '{event.name}' failed: {event.error}",
262
+ timestamp=event.timestamp.timestamp(),
263
+ severity=AlertSeverity.ERROR,
264
+ error=str(event.error),
265
+ )
266
+ logger.error(alert.message)
267
+
268
+ if alert:
269
+ self.alerts.append(alert)
270
+ if self.on_alert:
271
+ try:
272
+ self.on_alert(alert)
273
+ except Exception as e:
274
+ logger.error(f"Alert callback failed: {e}")
275
+
276
+ def get_alerts(
277
+ self,
278
+ alert_type: AlertType | None = None,
279
+ severity: AlertSeverity | None = None,
280
+ ) -> list[Alert]:
281
+ """Get triggered alerts, optionally filtered by type or severity.
282
+
283
+ Parameters
284
+ ----------
285
+ alert_type : AlertType, optional
286
+ Filter alerts by type
287
+ severity : AlertSeverity, optional
288
+ Filter alerts by severity level
289
+
290
+ Returns
291
+ -------
292
+ list[Alert]
293
+ List of Alert objects matching the criteria
294
+ """
295
+ return [
296
+ a
297
+ for a in self.alerts
298
+ if (alert_type is None or a.type == alert_type)
299
+ and (severity is None or a.severity == severity)
300
+ ]
301
+
302
+ def clear_alerts(self) -> None:
303
+ """Clear all alerts."""
304
+ self.alerts.clear()
305
+
306
+
307
+ # ==============================================================================
308
+ # LOGGING AND DEBUGGING OBSERVERS
309
+ # ==============================================================================
310
+
311
+
312
+ @dataclass
313
+ class ExecutionTrace:
314
+ """Execution trace with timing information.
315
+
316
+ Uses event timestamps for precise, reproducible timing information
317
+ instead of wall-clock time.
318
+ """
319
+
320
+ events: list[tuple[float, str, Event]] = field(default_factory=list)
321
+ start_time: float | None = None
322
+
323
+ def add(self, event: Event) -> None:
324
+ """Add event to trace with elapsed time from first event."""
325
+ event_timestamp = event.timestamp.timestamp()
326
+
327
+ if self.start_time is None:
328
+ self.start_time = event_timestamp
329
+
330
+ elapsed_ms = (event_timestamp - self.start_time) * 1000
331
+ event_type = type(event).__name__
332
+ self.events.append((elapsed_ms, event_type, event))
333
+
334
+
335
+ class ExecutionTracerObserver:
336
+ """Observer that builds detailed execution traces.
337
+
338
+ Useful for debugging and understanding execution flow. Captures all events
339
+ with precise timing information.
340
+
341
+ Example
342
+ -------
343
+ >>> from hexdag.builtin.adapters.local import LocalObserverManager
344
+ >>> tracer = ExecutionTracerObserver()
345
+ >>> observer_manager = LocalObserverManager()
346
+ >>> observer_manager.register(tracer.handle) # doctest: +SKIP
347
+ >>> # ... run pipeline ...
348
+ >>> tracer.print_trace()
349
+ """
350
+
351
+ def __init__(self) -> None:
352
+ """Initialize execution tracer."""
353
+ self.trace = ExecutionTrace()
354
+
355
+ async def handle(self, event: Event) -> None:
356
+ """Capture event in trace.
357
+
358
+ Parameters
359
+ ----------
360
+ event : Event
361
+ Event to capture
362
+ """
363
+ self.trace.add(event)
364
+
365
+ def get_trace(self) -> ExecutionTrace:
366
+ """Get the current execution trace.
367
+
368
+ Returns
369
+ -------
370
+ ExecutionTrace
371
+ The captured execution trace
372
+ """
373
+ return self.trace
374
+
375
+ def print_trace(self, max_events: int | None = None) -> None:
376
+ """Print the execution trace in a readable format.
377
+
378
+ Parameters
379
+ ----------
380
+ max_events : int, optional
381
+ Maximum number of events to print. If None, prints all events.
382
+ """
383
+ events_to_print = self.trace.events
384
+ if max_events is not None:
385
+ events_to_print = events_to_print[:max_events]
386
+
387
+ for elapsed_ms, event_type, event in events_to_print:
388
+ # Type narrowing: check if event has 'name' attribute
389
+ if event_name := getattr(event, "name", None):
390
+ print(f"[{elapsed_ms:7.1f}ms] {event_type:25s} | {event_name}")
391
+ else:
392
+ print(f"[{elapsed_ms:7.1f}ms] {event_type:25s}")
393
+
394
+ def reset(self) -> None:
395
+ """Reset the trace."""
396
+ self.trace = ExecutionTrace()
397
+
398
+
399
+ class SimpleLoggingObserver:
400
+ """Simple observer that logs events to console/logger.
401
+
402
+ Provides basic logging of pipeline execution with optional verbose mode
403
+ for detailed information. Leverages event.log_message() for consistent formatting.
404
+
405
+ Parameters
406
+ ----------
407
+ verbose : bool
408
+ If True, log detailed information including results and dependencies
409
+
410
+ Example
411
+ -------
412
+ >>> from hexdag.core.orchestration.events import ALL_EXECUTION_EVENTS
413
+ >>> logger_obs = SimpleLoggingObserver(verbose=True)
414
+ >>> # Register with event filtering
415
+ >>> observer_manager.register( # doctest: +SKIP
416
+ ... logger_obs.handle,
417
+ ... event_types=ALL_EXECUTION_EVENTS
418
+ ... )
419
+ """
420
+
421
+ def __init__(self, verbose: bool = False):
422
+ """Initialize simple logging observer.
423
+
424
+ Parameters
425
+ ----------
426
+ verbose : bool
427
+ Enable verbose logging with additional details
428
+ """
429
+ self.verbose = verbose
430
+
431
+ async def handle(self, event: Event) -> None:
432
+ """Log events to console.
433
+
434
+ Note: Should be registered with event_types=ALL_EXECUTION_EVENTS
435
+ for optimal performance. Uses event.log_message() for consistent formatting.
436
+
437
+ Parameters
438
+ ----------
439
+ event : Event
440
+ Event to log
441
+ """
442
+ # Use built-in log_message() for consistent formatting
443
+ if isinstance(event, NodeStarted):
444
+ logger.info(event.log_message())
445
+ if self.verbose:
446
+ logger.debug(f" Wave: {event.wave_index}, Dependencies: {event.dependencies}")
447
+
448
+ elif isinstance(event, NodeCompleted):
449
+ logger.info(event.log_message())
450
+ if self.verbose and event.result is not None:
451
+ result_preview = str(event.result)[:100]
452
+ logger.debug(f" Result: {result_preview}...")
453
+
454
+ elif isinstance(event, NodeFailed):
455
+ logger.error(event.log_message())
456
+ if self.verbose:
457
+ logger.error(f" Error: {event.error}")
458
+
459
+ elif isinstance(event, (PipelineStarted, PipelineCompleted)):
460
+ logger.info(event.log_message())
461
+
462
+
463
+ # ==============================================================================
464
+ # RESOURCE AND QUALITY MONITORING OBSERVERS
465
+ # ==============================================================================
466
+
467
+
468
+ class ResourceMonitorObserver:
469
+ """Observer that monitors resource usage patterns.
470
+
471
+ Tracks:
472
+ - Concurrent node execution levels
473
+ - Wave-based parallelism patterns
474
+ - Maximum concurrency reached
475
+ - Average wave sizes
476
+
477
+ Example
478
+ -------
479
+ >>> from hexdag.core.orchestration.events import NODE_LIFECYCLE_EVENTS
480
+ >>> resource_mon = ResourceMonitorObserver()
481
+ >>> # Register with event filtering
482
+ >>> observer_manager.register( # doctest: +SKIP
483
+ ... resource_mon.handle,
484
+ ... event_types=NODE_LIFECYCLE_EVENTS
485
+ ... )
486
+ >>> # ... run pipeline ...
487
+ >>> stats = resource_mon.get_stats() # doctest: +SKIP
488
+ >>> print(f"Max concurrency: {stats['max_concurrent']}") # doctest: +SKIP
489
+ """
490
+
491
+ def __init__(self) -> None:
492
+ """Initialize resource monitor."""
493
+ self.concurrent_nodes = 0
494
+ self.max_concurrent = 0
495
+ self.wave_sizes: list[int] = []
496
+ self.current_wave_nodes: set[str] = set()
497
+ self.current_wave: int = -1
498
+
499
+ async def handle(self, event: Event) -> None:
500
+ """Track resource usage patterns.
501
+
502
+ Note: Should be registered with event_types=NODE_LIFECYCLE_EVENTS
503
+ for optimal performance.
504
+
505
+ Parameters
506
+ ----------
507
+ event : Event
508
+ Event to process
509
+ """
510
+ if isinstance(event, NodeStarted):
511
+ self.concurrent_nodes += 1
512
+ self.max_concurrent = max(self.max_concurrent, self.concurrent_nodes)
513
+ self.current_wave_nodes.add(event.name)
514
+
515
+ if event.wave_index != self.current_wave:
516
+ if self.current_wave_nodes and self.current_wave >= 0:
517
+ self.wave_sizes.append(len(self.current_wave_nodes))
518
+ self.current_wave_nodes.clear()
519
+ self.current_wave = event.wave_index
520
+
521
+ elif isinstance(event, (NodeCompleted, NodeFailed)):
522
+ self.concurrent_nodes = max(0, self.concurrent_nodes - 1)
523
+
524
+ def get_stats(self) -> dict[str, Any]:
525
+ """Get resource usage statistics.
526
+
527
+ Returns
528
+ -------
529
+ dict[str, Any]
530
+ Resource usage statistics including:
531
+ - max_concurrent: Maximum concurrent nodes
532
+ - wave_sizes: List of node counts per wave
533
+ - total_waves: Total number of execution waves
534
+ - avg_wave_size: Average nodes per wave
535
+ """
536
+ avg_wave_size = sum(self.wave_sizes) / len(self.wave_sizes) if self.wave_sizes else 0
537
+ return {
538
+ "max_concurrent": self.max_concurrent,
539
+ "wave_sizes": self.wave_sizes.copy(),
540
+ "total_waves": len(self.wave_sizes),
541
+ "avg_wave_size": avg_wave_size,
542
+ }
543
+
544
+ def reset(self) -> None:
545
+ """Reset resource monitoring state."""
546
+ self.concurrent_nodes = 0
547
+ self.max_concurrent = 0
548
+ self.wave_sizes.clear()
549
+ self.current_wave_nodes.clear()
550
+ self.current_wave = -1
551
+
552
+
553
+ class DataQualityObserver:
554
+ """Observer that monitors data quality in pipeline execution.
555
+
556
+ Checks for common data quality issues:
557
+ - None/null values
558
+ - Empty collections (lists, dicts, strings)
559
+ - Error indicators in result data
560
+
561
+ Uses typed Alert dataclass for quality issues.
562
+
563
+ Example
564
+ -------
565
+ >>> quality = DataQualityObserver()
566
+ >>> # Register with event filtering - only need NodeCompleted
567
+ >>> observer_manager.register( # doctest: +SKIP
568
+ ... quality.handle,
569
+ ... event_types=[NodeCompleted]
570
+ ... )
571
+ >>> # ... run pipeline ...
572
+ >>> if quality.has_issues():
573
+ ... for issue in quality.get_issues():
574
+ ... print(f"Quality issue in {issue.node}: {issue.message}")
575
+ """
576
+
577
+ # Error status constants
578
+ ERROR_STATUSES = frozenset(["error", "failed", "failure"])
579
+
580
+ def __init__(self) -> None:
581
+ """Initialize data quality observer."""
582
+ self.quality_issues: list[Alert] = []
583
+ self.validated_nodes = 0
584
+
585
+ async def handle(self, event: Event) -> None:
586
+ """Check data quality in node outputs.
587
+
588
+ Note: Should be registered with event_types=[NodeCompleted]
589
+ for optimal performance.
590
+
591
+ Parameters
592
+ ----------
593
+ event : Event
594
+ Event to process
595
+ """
596
+ if isinstance(event, NodeCompleted):
597
+ self.validated_nodes += 1
598
+ result = event.result
599
+
600
+ # Check for None results
601
+ if result is None:
602
+ self.quality_issues.append(
603
+ Alert(
604
+ type=AlertType.QUALITY_ISSUE,
605
+ node=event.name,
606
+ message="Node returned None",
607
+ timestamp=event.timestamp.timestamp(),
608
+ severity=AlertSeverity.WARNING,
609
+ metadata={"issue_type": "null_result"},
610
+ )
611
+ )
612
+
613
+ # Check for empty collections
614
+ elif isinstance(result, (list, dict, str)) and not result:
615
+ self.quality_issues.append(
616
+ Alert(
617
+ type=AlertType.QUALITY_ISSUE,
618
+ node=event.name,
619
+ message=f"Node returned empty {type(result).__name__}",
620
+ timestamp=event.timestamp.timestamp(),
621
+ severity=AlertSeverity.WARNING,
622
+ metadata={"issue_type": "empty_result"},
623
+ )
624
+ )
625
+
626
+ # Check for error indicators in dict results
627
+ elif isinstance(result, dict):
628
+ if result.get("error"):
629
+ self.quality_issues.append(
630
+ Alert(
631
+ type=AlertType.QUALITY_ISSUE,
632
+ node=event.name,
633
+ message="Result contains error flag",
634
+ timestamp=event.timestamp.timestamp(),
635
+ severity=AlertSeverity.ERROR,
636
+ error=str(result.get("error")),
637
+ metadata={"issue_type": "error_in_result"},
638
+ )
639
+ )
640
+ # Check for common error status codes using constant
641
+ if result.get("status") in self.ERROR_STATUSES:
642
+ self.quality_issues.append(
643
+ Alert(
644
+ type=AlertType.QUALITY_ISSUE,
645
+ node=event.name,
646
+ message=f"Result has error status: {result.get('status')}",
647
+ timestamp=event.timestamp.timestamp(),
648
+ severity=AlertSeverity.ERROR,
649
+ metadata={"issue_type": "error_status", "status": result.get("status")},
650
+ )
651
+ )
652
+
653
+ def has_issues(self, severity: AlertSeverity | None = None) -> bool:
654
+ """Check if any quality issues were detected.
655
+
656
+ Parameters
657
+ ----------
658
+ severity : AlertSeverity, optional
659
+ Filter by severity level
660
+
661
+ Returns
662
+ -------
663
+ bool
664
+ True if issues were found matching the criteria
665
+ """
666
+ if severity is None:
667
+ return len(self.quality_issues) > 0
668
+ return any(issue.severity == severity for issue in self.quality_issues)
669
+
670
+ def get_issues(self, severity: AlertSeverity | None = None) -> list[Alert]:
671
+ """Get all detected quality issues.
672
+
673
+ Parameters
674
+ ----------
675
+ severity : AlertSeverity, optional
676
+ Filter by severity level
677
+
678
+ Returns
679
+ -------
680
+ list[Alert]
681
+ List of quality issue alerts
682
+ """
683
+ if severity is None:
684
+ return self.quality_issues
685
+ return [i for i in self.quality_issues if i.severity == severity]
686
+
687
+ def clear_issues(self) -> None:
688
+ """Clear all quality issues and reset counters."""
689
+ self.quality_issues.clear()
690
+ self.validated_nodes = 0