flowsense-engine 0.2.1__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 (49) hide show
  1. flowsense/__init__.py +77 -0
  2. flowsense/application/__init__.py +20 -0
  3. flowsense/application/analyzer.py +68 -0
  4. flowsense/application/output.py +128 -0
  5. flowsense/application/pipeline.py +210 -0
  6. flowsense/application/ports.py +11 -0
  7. flowsense/application/request.py +32 -0
  8. flowsense/application/serialization.py +177 -0
  9. flowsense/cli/__init__.py +0 -0
  10. flowsense/cli/main.py +195 -0
  11. flowsense/cli/report.py +233 -0
  12. flowsense/collector/__init__.py +0 -0
  13. flowsense/collector/airflow_client.py +5 -0
  14. flowsense/config.py +72 -0
  15. flowsense/domain/__init__.py +52 -0
  16. flowsense/domain/enums.py +47 -0
  17. flowsense/domain/exceptions.py +25 -0
  18. flowsense/domain/models.py +19 -0
  19. flowsense/domain/policy.py +55 -0
  20. flowsense/domain/results.py +187 -0
  21. flowsense/engine/__init__.py +0 -0
  22. flowsense/engine/analyzer.py +17 -0
  23. flowsense/engine/change_point.py +87 -0
  24. flowsense/engine/drift.py +80 -0
  25. flowsense/engine/history.py +34 -0
  26. flowsense/engine/impact.py +51 -0
  27. flowsense/engine/propagation.py +173 -0
  28. flowsense/engine/root_cause.py +148 -0
  29. flowsense/engine/timing.py +221 -0
  30. flowsense/engine/trend.py +82 -0
  31. flowsense/infrastructure/__init__.py +1 -0
  32. flowsense/infrastructure/airflow/__init__.py +13 -0
  33. flowsense/infrastructure/airflow/client.py +373 -0
  34. flowsense/infrastructure/airflow/dto.py +33 -0
  35. flowsense/infrastructure/airflow/exceptions.py +31 -0
  36. flowsense/infrastructure/airflow/mapper.py +27 -0
  37. flowsense/mcp/__init__.py +0 -0
  38. flowsense/mcp/server.py +75 -0
  39. flowsense/models/__init__.py +7 -0
  40. flowsense/models/dag_analysis.py +3 -0
  41. flowsense/models/task_run.py +3 -0
  42. flowsense/py.typed +0 -0
  43. flowsense/version.py +6 -0
  44. flowsense_engine-0.2.1.dist-info/METADATA +447 -0
  45. flowsense_engine-0.2.1.dist-info/RECORD +49 -0
  46. flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
  47. flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
  48. flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
  49. flowsense_engine-0.2.1.dist-info/top_level.txt +1 -0
flowsense/__init__.py ADDED
@@ -0,0 +1,77 @@
1
+ from flowsense.application import (
2
+ ANALYSIS_SCHEMA_VERSION,
3
+ AnalysisDocument,
4
+ AnalysisRequest,
5
+ DAGDataSource,
6
+ analysis_json_schema,
7
+ analyze_dag,
8
+ build_analysis_document,
9
+ serialize_analysis,
10
+ )
11
+ from flowsense.domain import (
12
+ DEFAULT_ANALYSIS_POLICY,
13
+ AnalysisDiagnostic,
14
+ AnalysisPolicy,
15
+ ChangeDirection,
16
+ ChangePointResult,
17
+ ConfigurationError,
18
+ DAGAnalysis,
19
+ DAGAnalysisSummary,
20
+ DriftResult,
21
+ FlowSenseError,
22
+ ImpactClassification,
23
+ InsufficientHistoryError,
24
+ InvalidTaskTimingError,
25
+ MappedTaskAggregation,
26
+ PropagationResult,
27
+ RootCauseResult,
28
+ Severity,
29
+ TaskImpact,
30
+ TaskRun,
31
+ TrendDirection,
32
+ TrendResult,
33
+ )
34
+ from flowsense.infrastructure.airflow import (
35
+ AirflowApiError,
36
+ AirflowClient,
37
+ AirflowDagRunNotFoundError,
38
+ AirflowDataError,
39
+ )
40
+ from flowsense.version import __version__
41
+
42
+ __all__ = [
43
+ "ANALYSIS_SCHEMA_VERSION",
44
+ "DEFAULT_ANALYSIS_POLICY",
45
+ "AirflowApiError",
46
+ "AirflowClient",
47
+ "AirflowDagRunNotFoundError",
48
+ "AirflowDataError",
49
+ "AnalysisDiagnostic",
50
+ "AnalysisDocument",
51
+ "AnalysisPolicy",
52
+ "AnalysisRequest",
53
+ "ChangeDirection",
54
+ "ChangePointResult",
55
+ "ConfigurationError",
56
+ "DAGAnalysis",
57
+ "DAGAnalysisSummary",
58
+ "DAGDataSource",
59
+ "DriftResult",
60
+ "FlowSenseError",
61
+ "ImpactClassification",
62
+ "InsufficientHistoryError",
63
+ "InvalidTaskTimingError",
64
+ "MappedTaskAggregation",
65
+ "PropagationResult",
66
+ "RootCauseResult",
67
+ "Severity",
68
+ "TaskImpact",
69
+ "TaskRun",
70
+ "TrendDirection",
71
+ "TrendResult",
72
+ "__version__",
73
+ "analysis_json_schema",
74
+ "analyze_dag",
75
+ "build_analysis_document",
76
+ "serialize_analysis",
77
+ ]
@@ -0,0 +1,20 @@
1
+ from flowsense.application.analyzer import analyze_dag
2
+ from flowsense.application.output import ANALYSIS_SCHEMA_VERSION, AnalysisDocument
3
+ from flowsense.application.ports import DAGDataSource
4
+ from flowsense.application.request import AnalysisRequest
5
+ from flowsense.application.serialization import (
6
+ analysis_json_schema,
7
+ build_analysis_document,
8
+ serialize_analysis,
9
+ )
10
+
11
+ __all__ = [
12
+ "ANALYSIS_SCHEMA_VERSION",
13
+ "AnalysisDocument",
14
+ "AnalysisRequest",
15
+ "DAGDataSource",
16
+ "analysis_json_schema",
17
+ "analyze_dag",
18
+ "build_analysis_document",
19
+ "serialize_analysis",
20
+ ]
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from flowsense.application.pipeline import (
4
+ analyze_handoff_histories,
5
+ analyze_task_histories,
6
+ classify_task_impacts,
7
+ determine_overall_severity,
8
+ )
9
+ from flowsense.application.ports import DAGDataSource
10
+ from flowsense.domain import DEFAULT_ANALYSIS_POLICY, AnalysisPolicy, DAGAnalysis
11
+ from flowsense.engine.history import build_duration_history
12
+ from flowsense.engine.propagation import analyze_propagation
13
+ from flowsense.engine.root_cause import select_primary_origin
14
+
15
+
16
+ def analyze_dag(
17
+ dag_id: str,
18
+ source: DAGDataSource,
19
+ policy: AnalysisPolicy = DEFAULT_ANALYSIS_POLICY,
20
+ ) -> DAGAnalysis:
21
+ task_runs = source.collect_task_runs(dag_id)
22
+ dependencies = source.get_dag_dependencies(dag_id)
23
+ duration_history = build_duration_history(
24
+ task_runs,
25
+ aggregation=policy.mapped_task_aggregation,
26
+ )
27
+
28
+ task_analysis = analyze_task_histories(duration_history, policy)
29
+ handoff_analysis = analyze_handoff_histories(task_runs, dependencies, policy)
30
+ task_impacts = classify_task_impacts(
31
+ task_analysis.drift_results,
32
+ handoff_analysis.drift_results,
33
+ )
34
+ propagation_results = analyze_propagation(
35
+ drift_results=task_analysis.drift_results,
36
+ dependencies=dependencies,
37
+ )
38
+ primary_origin = select_primary_origin(
39
+ drift_results=task_analysis.drift_results,
40
+ task_impacts=task_impacts,
41
+ dependencies=dependencies,
42
+ propagation_results=propagation_results,
43
+ )
44
+
45
+ return DAGAnalysis(
46
+ dag_id=dag_id,
47
+ runs_analyzed=len({run.dag_run_id for run in task_runs}),
48
+ current_dag_run_id=(task_runs[-1].dag_run_id if task_runs else None),
49
+ overall_severity=determine_overall_severity(
50
+ task_analysis.drift_results,
51
+ handoff_analysis.drift_results,
52
+ ),
53
+ primary_origin=primary_origin,
54
+ drift_results=task_analysis.drift_results,
55
+ handoff_drift_results=handoff_analysis.drift_results,
56
+ task_impacts=task_impacts,
57
+ propagation_results=propagation_results,
58
+ dependencies=dependencies,
59
+ diagnostics=[
60
+ *task_analysis.diagnostics,
61
+ *handoff_analysis.diagnostics,
62
+ ],
63
+ policy=policy,
64
+ change_point_results=task_analysis.change_point_results,
65
+ handoff_change_point_results=handoff_analysis.change_point_results,
66
+ trend_results=task_analysis.trend_results,
67
+ handoff_trend_results=handoff_analysis.trend_results,
68
+ )
@@ -0,0 +1,128 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from flowsense.domain import (
6
+ ChangeDirection,
7
+ ImpactClassification,
8
+ MappedTaskAggregation,
9
+ Severity,
10
+ TrendDirection,
11
+ )
12
+
13
+ ANALYSIS_SCHEMA_VERSION = "1.1"
14
+
15
+
16
+ class OutputModel(BaseModel):
17
+ """Base model for the versioned public analysis contract."""
18
+
19
+ model_config = ConfigDict(frozen=True, extra="forbid")
20
+
21
+
22
+ class AnalysisSummaryOutput(OutputModel):
23
+ total_tasks: int
24
+ analyzed_tasks: int
25
+ analysis_coverage_percent: float
26
+ normal_tasks: int
27
+ medium_tasks: int
28
+ high_tasks: int
29
+ critical_tasks: int
30
+ anomalous_tasks: int
31
+ anomalous_handoffs: int
32
+ affected_tasks: int
33
+ change_points: int
34
+ trends: int
35
+ diagnostics: int
36
+
37
+
38
+ class AnalysisPolicyOutput(OutputModel):
39
+ minimum_history: int
40
+ baseline_window: int | None
41
+ medium_threshold: float
42
+ high_threshold: float
43
+ critical_threshold: float
44
+ mapped_task_aggregation: MappedTaskAggregation
45
+ change_point_detection_enabled: bool
46
+ change_point_minimum_segment_size: int
47
+ change_point_score_threshold: float
48
+ trend_detection_enabled: bool
49
+ trend_minimum_observations: int
50
+ trend_score_threshold: float
51
+ trend_minimum_directional_consistency: float
52
+
53
+
54
+ class RootCauseOutput(OutputModel):
55
+ task_id: str
56
+ classification: ImpactClassification
57
+ severity: Severity
58
+ propagation_score: float
59
+
60
+
61
+ class DriftOutput(OutputModel):
62
+ baseline: float
63
+ current: float
64
+ mad: float
65
+ robust_z_score: float
66
+ deviation_percent: float
67
+ severity: Severity
68
+
69
+
70
+ class ChangePointOutput(OutputModel):
71
+ change_index: int
72
+ before_median: float
73
+ after_median: float
74
+ change_percent: float | None
75
+ score: float
76
+ direction: ChangeDirection
77
+
78
+
79
+ class TrendOutput(OutputModel):
80
+ direction: TrendDirection
81
+ slope_per_observation: float
82
+ estimated_change: float
83
+ change_percent: float | None
84
+ score: float
85
+ directional_consistency: float
86
+ observations: int
87
+
88
+
89
+ class TaskImpactOutput(OutputModel):
90
+ classification: ImpactClassification
91
+ task_severity: Severity
92
+ upstream_handoff_severity: Severity | None
93
+
94
+
95
+ class PropagationOutput(OutputModel):
96
+ origin_task: str
97
+ affected_tasks: list[str]
98
+ path: list[str]
99
+ propagation_score: float
100
+
101
+
102
+ class DiagnosticOutput(OutputModel):
103
+ code: str
104
+ subject_id: str
105
+ message: str
106
+
107
+
108
+ class AnalysisDocument(OutputModel):
109
+ """Typed representation of the FlowSense analysis output schema."""
110
+
111
+ schema_version: Literal["1.1"] = ANALYSIS_SCHEMA_VERSION
112
+ dag_id: str
113
+ current_dag_run_id: str | None
114
+ runs_analyzed: int
115
+ overall_severity: Severity
116
+ summary: AnalysisSummaryOutput
117
+ policy: AnalysisPolicyOutput
118
+ primary_origin: RootCauseOutput | None
119
+ drift_results: dict[str, DriftOutput]
120
+ change_point_results: dict[str, ChangePointOutput]
121
+ trend_results: dict[str, TrendOutput]
122
+ handoff_drift_results: dict[str, DriftOutput]
123
+ handoff_change_point_results: dict[str, ChangePointOutput]
124
+ handoff_trend_results: dict[str, TrendOutput]
125
+ task_impacts: dict[str, TaskImpactOutput]
126
+ propagation_results: list[PropagationOutput]
127
+ dependencies: dict[str, list[str]]
128
+ diagnostics: list[DiagnosticOutput]
@@ -0,0 +1,210 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from flowsense.domain import (
6
+ AnalysisDiagnostic,
7
+ AnalysisPolicy,
8
+ ChangePointResult,
9
+ DriftResult,
10
+ InsufficientHistoryError,
11
+ Severity,
12
+ TaskImpact,
13
+ TaskRun,
14
+ TrendResult,
15
+ )
16
+ from flowsense.domain.enums import SEVERITY_SCORE
17
+ from flowsense.engine.change_point import detect_change_point
18
+ from flowsense.engine.drift import calculate_drift
19
+ from flowsense.engine.impact import classify_task_impact
20
+ from flowsense.engine.timing import (
21
+ build_handoff_history_with_diagnostics,
22
+ calculate_handoff_drift,
23
+ )
24
+ from flowsense.engine.trend import detect_trend
25
+
26
+ TaskId = str
27
+ Edge = tuple[str, str]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TaskAnalysisStageResult:
32
+ drift_results: dict[TaskId, DriftResult]
33
+ change_point_results: dict[TaskId, ChangePointResult]
34
+ trend_results: dict[TaskId, TrendResult]
35
+ diagnostics: list[AnalysisDiagnostic]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class HandoffAnalysisStageResult:
40
+ drift_results: dict[Edge, DriftResult]
41
+ change_point_results: dict[Edge, ChangePointResult]
42
+ trend_results: dict[Edge, TrendResult]
43
+ diagnostics: list[AnalysisDiagnostic]
44
+
45
+
46
+ def _detect_change_point(
47
+ subject_id: str,
48
+ values: list[float],
49
+ policy: AnalysisPolicy,
50
+ ) -> ChangePointResult | None:
51
+ if not policy.change_point_detection_enabled:
52
+ return None
53
+
54
+ return detect_change_point(
55
+ subject_id,
56
+ values,
57
+ minimum_segment_size=policy.change_point_minimum_segment_size,
58
+ score_threshold=policy.change_point_score_threshold,
59
+ )
60
+
61
+
62
+ def _detect_trend(
63
+ subject_id: str,
64
+ values: list[float],
65
+ policy: AnalysisPolicy,
66
+ ) -> TrendResult | None:
67
+ if not policy.trend_detection_enabled:
68
+ return None
69
+
70
+ return detect_trend(
71
+ subject_id,
72
+ values,
73
+ minimum_observations=policy.trend_minimum_observations,
74
+ score_threshold=policy.trend_score_threshold,
75
+ minimum_directional_consistency=(policy.trend_minimum_directional_consistency),
76
+ )
77
+
78
+
79
+ def analyze_task_histories(
80
+ duration_history: dict[TaskId, list[float]],
81
+ policy: AnalysisPolicy,
82
+ ) -> TaskAnalysisStageResult:
83
+ drift_results: dict[TaskId, DriftResult] = {}
84
+ change_point_results: dict[TaskId, ChangePointResult] = {}
85
+ trend_results: dict[TaskId, TrendResult] = {}
86
+ diagnostics: list[AnalysisDiagnostic] = []
87
+
88
+ for task_id, durations in duration_history.items():
89
+ change_point = _detect_change_point(task_id, durations, policy)
90
+ if change_point is not None:
91
+ change_point_results[task_id] = change_point
92
+
93
+ trend = _detect_trend(task_id, durations, policy)
94
+ if trend is not None:
95
+ trend_results[task_id] = trend
96
+
97
+ try:
98
+ drift_results[task_id] = calculate_drift(
99
+ task_id=task_id,
100
+ durations=durations,
101
+ policy=policy,
102
+ )
103
+ except InsufficientHistoryError as exc:
104
+ diagnostics.append(
105
+ AnalysisDiagnostic(
106
+ code="INSUFFICIENT_TASK_HISTORY",
107
+ subject_id=task_id,
108
+ message=str(exc),
109
+ )
110
+ )
111
+
112
+ return TaskAnalysisStageResult(
113
+ drift_results=drift_results,
114
+ change_point_results=change_point_results,
115
+ trend_results=trend_results,
116
+ diagnostics=diagnostics,
117
+ )
118
+
119
+
120
+ def analyze_handoff_histories(
121
+ task_runs: list[TaskRun],
122
+ dependencies: dict[TaskId, list[TaskId]],
123
+ policy: AnalysisPolicy,
124
+ ) -> HandoffAnalysisStageResult:
125
+ history_result = build_handoff_history_with_diagnostics(
126
+ task_runs=task_runs,
127
+ dependencies=dependencies,
128
+ )
129
+ drift_results: dict[Edge, DriftResult] = {}
130
+ change_point_results: dict[Edge, ChangePointResult] = {}
131
+ trend_results: dict[Edge, TrendResult] = {}
132
+ diagnostics = [
133
+ AnalysisDiagnostic(
134
+ code=diagnostic.code,
135
+ subject_id=f"{diagnostic.upstream_task}->{diagnostic.downstream_task}",
136
+ message=diagnostic.message,
137
+ )
138
+ for diagnostic in history_result.diagnostics
139
+ ]
140
+
141
+ for edge, delays in history_result.history.items():
142
+ upstream_task, downstream_task = edge
143
+ subject_id = f"{upstream_task}->{downstream_task}"
144
+
145
+ change_point = _detect_change_point(subject_id, delays, policy)
146
+ if change_point is not None:
147
+ change_point_results[edge] = change_point
148
+
149
+ trend = _detect_trend(subject_id, delays, policy)
150
+ if trend is not None:
151
+ trend_results[edge] = trend
152
+
153
+ try:
154
+ drift_results[edge] = calculate_handoff_drift(
155
+ upstream_task=upstream_task,
156
+ downstream_task=downstream_task,
157
+ handoff_delays=delays,
158
+ policy=policy,
159
+ )
160
+ except InsufficientHistoryError as exc:
161
+ diagnostics.append(
162
+ AnalysisDiagnostic(
163
+ code="INSUFFICIENT_HANDOFF_HISTORY",
164
+ subject_id=subject_id,
165
+ message=str(exc),
166
+ )
167
+ )
168
+
169
+ return HandoffAnalysisStageResult(
170
+ drift_results=drift_results,
171
+ change_point_results=change_point_results,
172
+ trend_results=trend_results,
173
+ diagnostics=diagnostics,
174
+ )
175
+
176
+
177
+ def classify_task_impacts(
178
+ task_drift_results: dict[TaskId, DriftResult],
179
+ handoff_drift_results: dict[Edge, DriftResult],
180
+ ) -> dict[TaskId, TaskImpact]:
181
+ return {
182
+ task_id: classify_task_impact(
183
+ task_id=task_id,
184
+ task_drift=task_drift,
185
+ upstream_handoff_drifts=[
186
+ drift
187
+ for (_upstream, downstream), drift in handoff_drift_results.items()
188
+ if downstream == task_id
189
+ ],
190
+ )
191
+ for task_id, task_drift in task_drift_results.items()
192
+ }
193
+
194
+
195
+ def determine_overall_severity(
196
+ task_drift_results: dict[TaskId, DriftResult],
197
+ handoff_drift_results: dict[Edge, DriftResult],
198
+ ) -> Severity:
199
+ drift_results = [
200
+ *task_drift_results.values(),
201
+ *handoff_drift_results.values(),
202
+ ]
203
+
204
+ if not drift_results:
205
+ return Severity.NORMAL
206
+
207
+ return max(
208
+ drift_results,
209
+ key=lambda result: SEVERITY_SCORE[result.severity],
210
+ ).severity
@@ -0,0 +1,11 @@
1
+ from typing import Protocol
2
+
3
+ from flowsense.domain import TaskRun
4
+
5
+
6
+ class DAGDataSource(Protocol):
7
+ def collect_task_runs(self, dag_id: str) -> list[TaskRun]:
8
+ """Return task runs ordered from oldest DAG run to newest."""
9
+ ...
10
+
11
+ def get_dag_dependencies(self, dag_id: str) -> dict[str, list[str]]: ...
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+
3
+ from flowsense.domain import (
4
+ DEFAULT_ANALYSIS_POLICY,
5
+ AnalysisPolicy,
6
+ ConfigurationError,
7
+ )
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class AnalysisRequest:
12
+ """Validated input contract shared by FlowSense delivery adapters."""
13
+
14
+ dag_id: str
15
+ policy: AnalysisPolicy = DEFAULT_ANALYSIS_POLICY
16
+ history_run_limit: int | None = None
17
+ dag_run_id: str | None = None
18
+
19
+ def __post_init__(self) -> None:
20
+ if not self.dag_id.strip():
21
+ raise ConfigurationError("dag_id must not be empty.")
22
+
23
+ if self.dag_run_id is not None and not self.dag_run_id.strip():
24
+ raise ConfigurationError("dag_run_id must not be empty.")
25
+
26
+ if (
27
+ self.history_run_limit is not None
28
+ and self.history_run_limit < self.policy.minimum_history
29
+ ):
30
+ raise ConfigurationError(
31
+ "history_run_limit must be at least minimum_history."
32
+ )