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.
- hexdag/__init__.py +116 -0
- hexdag/__main__.py +30 -0
- hexdag/adapters/executors/__init__.py +5 -0
- hexdag/adapters/executors/local_executor.py +316 -0
- hexdag/builtin/__init__.py +6 -0
- hexdag/builtin/adapters/__init__.py +51 -0
- hexdag/builtin/adapters/anthropic/__init__.py +5 -0
- hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
- hexdag/builtin/adapters/database/__init__.py +6 -0
- hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
- hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
- hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
- hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
- hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
- hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
- hexdag/builtin/adapters/local/README.md +59 -0
- hexdag/builtin/adapters/local/__init__.py +7 -0
- hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
- hexdag/builtin/adapters/memory/__init__.py +47 -0
- hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
- hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
- hexdag/builtin/adapters/memory/schemas.py +57 -0
- hexdag/builtin/adapters/memory/session_memory.py +178 -0
- hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
- hexdag/builtin/adapters/memory/state_memory.py +280 -0
- hexdag/builtin/adapters/mock/README.md +89 -0
- hexdag/builtin/adapters/mock/__init__.py +15 -0
- hexdag/builtin/adapters/mock/hexdag.toml +50 -0
- hexdag/builtin/adapters/mock/mock_database.py +225 -0
- hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
- hexdag/builtin/adapters/mock/mock_llm.py +177 -0
- hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
- hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
- hexdag/builtin/adapters/openai/__init__.py +5 -0
- hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
- hexdag/builtin/adapters/secret/__init__.py +7 -0
- hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
- hexdag/builtin/adapters/unified_tool_router.py +280 -0
- hexdag/builtin/macros/__init__.py +17 -0
- hexdag/builtin/macros/conversation_agent.py +390 -0
- hexdag/builtin/macros/llm_macro.py +151 -0
- hexdag/builtin/macros/reasoning_agent.py +423 -0
- hexdag/builtin/macros/tool_macro.py +380 -0
- hexdag/builtin/nodes/__init__.py +38 -0
- hexdag/builtin/nodes/_discovery.py +123 -0
- hexdag/builtin/nodes/agent_node.py +696 -0
- hexdag/builtin/nodes/base_node_factory.py +242 -0
- hexdag/builtin/nodes/composite_node.py +926 -0
- hexdag/builtin/nodes/data_node.py +201 -0
- hexdag/builtin/nodes/expression_node.py +487 -0
- hexdag/builtin/nodes/function_node.py +454 -0
- hexdag/builtin/nodes/llm_node.py +491 -0
- hexdag/builtin/nodes/loop_node.py +920 -0
- hexdag/builtin/nodes/mapped_input.py +518 -0
- hexdag/builtin/nodes/port_call_node.py +269 -0
- hexdag/builtin/nodes/tool_call_node.py +195 -0
- hexdag/builtin/nodes/tool_utils.py +390 -0
- hexdag/builtin/prompts/__init__.py +68 -0
- hexdag/builtin/prompts/base.py +422 -0
- hexdag/builtin/prompts/chat_prompts.py +303 -0
- hexdag/builtin/prompts/error_correction_prompts.py +320 -0
- hexdag/builtin/prompts/tool_prompts.py +160 -0
- hexdag/builtin/tools/builtin_tools.py +84 -0
- hexdag/builtin/tools/database_tools.py +164 -0
- hexdag/cli/__init__.py +17 -0
- hexdag/cli/__main__.py +7 -0
- hexdag/cli/commands/__init__.py +27 -0
- hexdag/cli/commands/build_cmd.py +812 -0
- hexdag/cli/commands/create_cmd.py +208 -0
- hexdag/cli/commands/docs_cmd.py +293 -0
- hexdag/cli/commands/generate_types_cmd.py +252 -0
- hexdag/cli/commands/init_cmd.py +188 -0
- hexdag/cli/commands/pipeline_cmd.py +494 -0
- hexdag/cli/commands/plugin_dev_cmd.py +529 -0
- hexdag/cli/commands/plugins_cmd.py +441 -0
- hexdag/cli/commands/studio_cmd.py +101 -0
- hexdag/cli/commands/validate_cmd.py +221 -0
- hexdag/cli/main.py +84 -0
- hexdag/core/__init__.py +83 -0
- hexdag/core/config/__init__.py +20 -0
- hexdag/core/config/loader.py +479 -0
- hexdag/core/config/models.py +150 -0
- hexdag/core/configurable.py +294 -0
- hexdag/core/context/__init__.py +37 -0
- hexdag/core/context/execution_context.py +378 -0
- hexdag/core/docs/__init__.py +26 -0
- hexdag/core/docs/extractors.py +678 -0
- hexdag/core/docs/generators.py +890 -0
- hexdag/core/docs/models.py +120 -0
- hexdag/core/domain/__init__.py +10 -0
- hexdag/core/domain/dag.py +1225 -0
- hexdag/core/exceptions.py +234 -0
- hexdag/core/expression_parser.py +569 -0
- hexdag/core/logging.py +449 -0
- hexdag/core/models/__init__.py +17 -0
- hexdag/core/models/base.py +138 -0
- hexdag/core/orchestration/__init__.py +46 -0
- hexdag/core/orchestration/body_executor.py +481 -0
- hexdag/core/orchestration/components/__init__.py +97 -0
- hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
- hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
- hexdag/core/orchestration/components/execution_coordinator.py +360 -0
- hexdag/core/orchestration/components/health_check_manager.py +176 -0
- hexdag/core/orchestration/components/input_mapper.py +143 -0
- hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
- hexdag/core/orchestration/components/node_executor.py +377 -0
- hexdag/core/orchestration/components/secret_manager.py +202 -0
- hexdag/core/orchestration/components/wave_executor.py +158 -0
- hexdag/core/orchestration/constants.py +17 -0
- hexdag/core/orchestration/events/README.md +312 -0
- hexdag/core/orchestration/events/__init__.py +104 -0
- hexdag/core/orchestration/events/batching.py +330 -0
- hexdag/core/orchestration/events/decorators.py +139 -0
- hexdag/core/orchestration/events/events.py +573 -0
- hexdag/core/orchestration/events/observers/__init__.py +30 -0
- hexdag/core/orchestration/events/observers/core_observers.py +690 -0
- hexdag/core/orchestration/events/observers/models.py +111 -0
- hexdag/core/orchestration/events/taxonomy.py +269 -0
- hexdag/core/orchestration/hook_context.py +237 -0
- hexdag/core/orchestration/hooks.py +437 -0
- hexdag/core/orchestration/models.py +418 -0
- hexdag/core/orchestration/orchestrator.py +910 -0
- hexdag/core/orchestration/orchestrator_factory.py +275 -0
- hexdag/core/orchestration/port_wrappers.py +327 -0
- hexdag/core/orchestration/prompt/__init__.py +32 -0
- hexdag/core/orchestration/prompt/template.py +332 -0
- hexdag/core/pipeline_builder/__init__.py +21 -0
- hexdag/core/pipeline_builder/component_instantiator.py +386 -0
- hexdag/core/pipeline_builder/include_tag.py +265 -0
- hexdag/core/pipeline_builder/pipeline_config.py +133 -0
- hexdag/core/pipeline_builder/py_tag.py +223 -0
- hexdag/core/pipeline_builder/tag_discovery.py +268 -0
- hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
- hexdag/core/pipeline_builder/yaml_validator.py +569 -0
- hexdag/core/ports/__init__.py +65 -0
- hexdag/core/ports/api_call.py +133 -0
- hexdag/core/ports/database.py +489 -0
- hexdag/core/ports/embedding.py +215 -0
- hexdag/core/ports/executor.py +237 -0
- hexdag/core/ports/file_storage.py +117 -0
- hexdag/core/ports/healthcheck.py +87 -0
- hexdag/core/ports/llm.py +551 -0
- hexdag/core/ports/memory.py +70 -0
- hexdag/core/ports/observer_manager.py +130 -0
- hexdag/core/ports/secret.py +145 -0
- hexdag/core/ports/tool_router.py +94 -0
- hexdag/core/ports_builder.py +623 -0
- hexdag/core/protocols.py +273 -0
- hexdag/core/resolver.py +304 -0
- hexdag/core/schema/__init__.py +9 -0
- hexdag/core/schema/generator.py +742 -0
- hexdag/core/secrets.py +242 -0
- hexdag/core/types.py +413 -0
- hexdag/core/utils/async_warnings.py +206 -0
- hexdag/core/utils/schema_conversion.py +78 -0
- hexdag/core/utils/sql_validation.py +86 -0
- hexdag/core/validation/secure_json.py +148 -0
- hexdag/core/yaml_macro.py +517 -0
- hexdag/mcp_server.py +3120 -0
- hexdag/studio/__init__.py +10 -0
- hexdag/studio/build_ui.py +92 -0
- hexdag/studio/server/__init__.py +1 -0
- hexdag/studio/server/main.py +100 -0
- hexdag/studio/server/routes/__init__.py +9 -0
- hexdag/studio/server/routes/execute.py +208 -0
- hexdag/studio/server/routes/export.py +558 -0
- hexdag/studio/server/routes/files.py +207 -0
- hexdag/studio/server/routes/plugins.py +419 -0
- hexdag/studio/server/routes/validate.py +220 -0
- hexdag/studio/ui/index.html +13 -0
- hexdag/studio/ui/package-lock.json +2992 -0
- hexdag/studio/ui/package.json +31 -0
- hexdag/studio/ui/postcss.config.js +6 -0
- hexdag/studio/ui/public/hexdag.svg +5 -0
- hexdag/studio/ui/src/App.tsx +251 -0
- hexdag/studio/ui/src/components/Canvas.tsx +408 -0
- hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
- hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
- hexdag/studio/ui/src/components/Header.tsx +181 -0
- hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
- hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
- hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
- hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
- hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
- hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
- hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
- hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
- hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
- hexdag/studio/ui/src/components/index.ts +8 -0
- hexdag/studio/ui/src/index.css +92 -0
- hexdag/studio/ui/src/main.tsx +10 -0
- hexdag/studio/ui/src/types/index.ts +123 -0
- hexdag/studio/ui/src/vite-env.d.ts +1 -0
- hexdag/studio/ui/tailwind.config.js +29 -0
- hexdag/studio/ui/tsconfig.json +37 -0
- hexdag/studio/ui/tsconfig.node.json +13 -0
- hexdag/studio/ui/vite.config.ts +35 -0
- hexdag/visualization/__init__.py +69 -0
- hexdag/visualization/dag_visualizer.py +1020 -0
- hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
- hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
- hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
- hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
- hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
- hexdag_plugins/.gitignore +43 -0
- hexdag_plugins/README.md +73 -0
- hexdag_plugins/__init__.py +1 -0
- hexdag_plugins/azure/LICENSE +21 -0
- hexdag_plugins/azure/README.md +414 -0
- hexdag_plugins/azure/__init__.py +21 -0
- hexdag_plugins/azure/azure_blob_adapter.py +450 -0
- hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
- hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
- hexdag_plugins/azure/azure_openai_adapter.py +415 -0
- hexdag_plugins/azure/pyproject.toml +107 -0
- hexdag_plugins/azure/tests/__init__.py +1 -0
- hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
- hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
- hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
- hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
- hexdag_plugins/hexdag_etl/README.md +168 -0
- hexdag_plugins/hexdag_etl/__init__.py +53 -0
- hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
- hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
- hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
- hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
- hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
- hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
- hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
- hexdag_plugins/hexdag_etl/test_transform.py +54 -0
- hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
- hexdag_plugins/mysql_adapter/LICENSE +21 -0
- hexdag_plugins/mysql_adapter/README.md +224 -0
- hexdag_plugins/mysql_adapter/__init__.py +6 -0
- hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
- hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
- hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
- hexdag_plugins/storage/README.md +184 -0
- hexdag_plugins/storage/__init__.py +19 -0
- hexdag_plugins/storage/file/__init__.py +5 -0
- hexdag_plugins/storage/file/local.py +325 -0
- hexdag_plugins/storage/ports/__init__.py +5 -0
- hexdag_plugins/storage/ports/vector_store.py +236 -0
- hexdag_plugins/storage/sql/__init__.py +7 -0
- hexdag_plugins/storage/sql/base.py +187 -0
- hexdag_plugins/storage/sql/mysql.py +27 -0
- hexdag_plugins/storage/sql/postgresql.py +27 -0
- hexdag_plugins/storage/tests/__init__.py +1 -0
- hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
- hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
- hexdag_plugins/storage/vector/__init__.py +7 -0
- hexdag_plugins/storage/vector/chromadb.py +223 -0
- hexdag_plugins/storage/vector/in_memory.py +285 -0
- 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
|