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.
- flowsense/__init__.py +77 -0
- flowsense/application/__init__.py +20 -0
- flowsense/application/analyzer.py +68 -0
- flowsense/application/output.py +128 -0
- flowsense/application/pipeline.py +210 -0
- flowsense/application/ports.py +11 -0
- flowsense/application/request.py +32 -0
- flowsense/application/serialization.py +177 -0
- flowsense/cli/__init__.py +0 -0
- flowsense/cli/main.py +195 -0
- flowsense/cli/report.py +233 -0
- flowsense/collector/__init__.py +0 -0
- flowsense/collector/airflow_client.py +5 -0
- flowsense/config.py +72 -0
- flowsense/domain/__init__.py +52 -0
- flowsense/domain/enums.py +47 -0
- flowsense/domain/exceptions.py +25 -0
- flowsense/domain/models.py +19 -0
- flowsense/domain/policy.py +55 -0
- flowsense/domain/results.py +187 -0
- flowsense/engine/__init__.py +0 -0
- flowsense/engine/analyzer.py +17 -0
- flowsense/engine/change_point.py +87 -0
- flowsense/engine/drift.py +80 -0
- flowsense/engine/history.py +34 -0
- flowsense/engine/impact.py +51 -0
- flowsense/engine/propagation.py +173 -0
- flowsense/engine/root_cause.py +148 -0
- flowsense/engine/timing.py +221 -0
- flowsense/engine/trend.py +82 -0
- flowsense/infrastructure/__init__.py +1 -0
- flowsense/infrastructure/airflow/__init__.py +13 -0
- flowsense/infrastructure/airflow/client.py +373 -0
- flowsense/infrastructure/airflow/dto.py +33 -0
- flowsense/infrastructure/airflow/exceptions.py +31 -0
- flowsense/infrastructure/airflow/mapper.py +27 -0
- flowsense/mcp/__init__.py +0 -0
- flowsense/mcp/server.py +75 -0
- flowsense/models/__init__.py +7 -0
- flowsense/models/dag_analysis.py +3 -0
- flowsense/models/task_run.py +3 -0
- flowsense/py.typed +0 -0
- flowsense/version.py +6 -0
- flowsense_engine-0.2.1.dist-info/METADATA +447 -0
- flowsense_engine-0.2.1.dist-info/RECORD +49 -0
- flowsense_engine-0.2.1.dist-info/WHEEL +5 -0
- flowsense_engine-0.2.1.dist-info/entry_points.txt +3 -0
- flowsense_engine-0.2.1.dist-info/licenses/LICENSE +17 -0
- flowsense_engine-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from flowsense.application.output import (
|
|
4
|
+
AnalysisDocument,
|
|
5
|
+
AnalysisPolicyOutput,
|
|
6
|
+
AnalysisSummaryOutput,
|
|
7
|
+
ChangePointOutput,
|
|
8
|
+
DiagnosticOutput,
|
|
9
|
+
DriftOutput,
|
|
10
|
+
PropagationOutput,
|
|
11
|
+
RootCauseOutput,
|
|
12
|
+
TaskImpactOutput,
|
|
13
|
+
TrendOutput,
|
|
14
|
+
)
|
|
15
|
+
from flowsense.domain import DAGAnalysis, DriftResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _drift_output(result: DriftResult) -> DriftOutput:
|
|
19
|
+
return DriftOutput(
|
|
20
|
+
baseline=result.baseline,
|
|
21
|
+
current=result.current,
|
|
22
|
+
mad=result.mad,
|
|
23
|
+
robust_z_score=result.robust_z_score,
|
|
24
|
+
deviation_percent=result.deviation_percent,
|
|
25
|
+
severity=result.severity,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_analysis_document(analysis: DAGAnalysis) -> AnalysisDocument:
|
|
30
|
+
"""Build and validate the typed public output document."""
|
|
31
|
+
summary = analysis.summary
|
|
32
|
+
|
|
33
|
+
return AnalysisDocument(
|
|
34
|
+
dag_id=analysis.dag_id,
|
|
35
|
+
current_dag_run_id=analysis.current_dag_run_id,
|
|
36
|
+
runs_analyzed=analysis.runs_analyzed,
|
|
37
|
+
overall_severity=analysis.overall_severity,
|
|
38
|
+
summary=AnalysisSummaryOutput(
|
|
39
|
+
total_tasks=summary.total_tasks,
|
|
40
|
+
analyzed_tasks=summary.analyzed_tasks,
|
|
41
|
+
analysis_coverage_percent=summary.analysis_coverage_percent,
|
|
42
|
+
normal_tasks=summary.normal_tasks,
|
|
43
|
+
medium_tasks=summary.medium_tasks,
|
|
44
|
+
high_tasks=summary.high_tasks,
|
|
45
|
+
critical_tasks=summary.critical_tasks,
|
|
46
|
+
anomalous_tasks=summary.anomalous_tasks,
|
|
47
|
+
anomalous_handoffs=summary.anomalous_handoffs,
|
|
48
|
+
affected_tasks=summary.affected_tasks,
|
|
49
|
+
change_points=summary.change_points,
|
|
50
|
+
trends=summary.trends,
|
|
51
|
+
diagnostics=summary.diagnostics,
|
|
52
|
+
),
|
|
53
|
+
policy=AnalysisPolicyOutput(
|
|
54
|
+
minimum_history=analysis.policy.minimum_history,
|
|
55
|
+
baseline_window=analysis.policy.baseline_window,
|
|
56
|
+
medium_threshold=analysis.policy.medium_threshold,
|
|
57
|
+
high_threshold=analysis.policy.high_threshold,
|
|
58
|
+
critical_threshold=analysis.policy.critical_threshold,
|
|
59
|
+
mapped_task_aggregation=analysis.policy.mapped_task_aggregation,
|
|
60
|
+
change_point_detection_enabled=(
|
|
61
|
+
analysis.policy.change_point_detection_enabled
|
|
62
|
+
),
|
|
63
|
+
change_point_minimum_segment_size=(
|
|
64
|
+
analysis.policy.change_point_minimum_segment_size
|
|
65
|
+
),
|
|
66
|
+
change_point_score_threshold=(analysis.policy.change_point_score_threshold),
|
|
67
|
+
trend_detection_enabled=analysis.policy.trend_detection_enabled,
|
|
68
|
+
trend_minimum_observations=analysis.policy.trend_minimum_observations,
|
|
69
|
+
trend_score_threshold=analysis.policy.trend_score_threshold,
|
|
70
|
+
trend_minimum_directional_consistency=(
|
|
71
|
+
analysis.policy.trend_minimum_directional_consistency
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
primary_origin=(
|
|
75
|
+
RootCauseOutput(
|
|
76
|
+
task_id=analysis.primary_origin.task_id,
|
|
77
|
+
classification=analysis.primary_origin.classification,
|
|
78
|
+
severity=analysis.primary_origin.severity,
|
|
79
|
+
propagation_score=analysis.primary_origin.propagation_score,
|
|
80
|
+
)
|
|
81
|
+
if analysis.primary_origin
|
|
82
|
+
else None
|
|
83
|
+
),
|
|
84
|
+
drift_results={
|
|
85
|
+
task_id: _drift_output(result)
|
|
86
|
+
for task_id, result in analysis.drift_results.items()
|
|
87
|
+
},
|
|
88
|
+
change_point_results={
|
|
89
|
+
task_id: ChangePointOutput(
|
|
90
|
+
change_index=result.change_index,
|
|
91
|
+
before_median=result.before_median,
|
|
92
|
+
after_median=result.after_median,
|
|
93
|
+
change_percent=result.change_percent,
|
|
94
|
+
score=result.score,
|
|
95
|
+
direction=result.direction,
|
|
96
|
+
)
|
|
97
|
+
for task_id, result in analysis.change_point_results.items()
|
|
98
|
+
},
|
|
99
|
+
trend_results={
|
|
100
|
+
task_id: TrendOutput(
|
|
101
|
+
direction=result.direction,
|
|
102
|
+
slope_per_observation=result.slope_per_observation,
|
|
103
|
+
estimated_change=result.estimated_change,
|
|
104
|
+
change_percent=result.change_percent,
|
|
105
|
+
score=result.score,
|
|
106
|
+
directional_consistency=result.directional_consistency,
|
|
107
|
+
observations=result.observations,
|
|
108
|
+
)
|
|
109
|
+
for task_id, result in analysis.trend_results.items()
|
|
110
|
+
},
|
|
111
|
+
handoff_drift_results={
|
|
112
|
+
f"{upstream}->{downstream}": _drift_output(result)
|
|
113
|
+
for (upstream, downstream), result in analysis.handoff_drift_results.items()
|
|
114
|
+
},
|
|
115
|
+
handoff_change_point_results={
|
|
116
|
+
f"{upstream}->{downstream}": ChangePointOutput(
|
|
117
|
+
change_index=result.change_index,
|
|
118
|
+
before_median=result.before_median,
|
|
119
|
+
after_median=result.after_median,
|
|
120
|
+
change_percent=result.change_percent,
|
|
121
|
+
score=result.score,
|
|
122
|
+
direction=result.direction,
|
|
123
|
+
)
|
|
124
|
+
for (
|
|
125
|
+
upstream,
|
|
126
|
+
downstream,
|
|
127
|
+
), result in analysis.handoff_change_point_results.items()
|
|
128
|
+
},
|
|
129
|
+
handoff_trend_results={
|
|
130
|
+
f"{upstream}->{downstream}": TrendOutput(
|
|
131
|
+
direction=result.direction,
|
|
132
|
+
slope_per_observation=result.slope_per_observation,
|
|
133
|
+
estimated_change=result.estimated_change,
|
|
134
|
+
change_percent=result.change_percent,
|
|
135
|
+
score=result.score,
|
|
136
|
+
directional_consistency=result.directional_consistency,
|
|
137
|
+
observations=result.observations,
|
|
138
|
+
)
|
|
139
|
+
for (upstream, downstream), result in analysis.handoff_trend_results.items()
|
|
140
|
+
},
|
|
141
|
+
task_impacts={
|
|
142
|
+
task_id: TaskImpactOutput(
|
|
143
|
+
classification=impact.classification,
|
|
144
|
+
task_severity=impact.task_severity,
|
|
145
|
+
upstream_handoff_severity=impact.upstream_handoff_severity,
|
|
146
|
+
)
|
|
147
|
+
for task_id, impact in analysis.task_impacts.items()
|
|
148
|
+
},
|
|
149
|
+
propagation_results=[
|
|
150
|
+
PropagationOutput(
|
|
151
|
+
origin_task=result.origin_task,
|
|
152
|
+
affected_tasks=result.affected_tasks,
|
|
153
|
+
path=result.path,
|
|
154
|
+
propagation_score=result.propagation_score,
|
|
155
|
+
)
|
|
156
|
+
for result in analysis.propagation_results
|
|
157
|
+
],
|
|
158
|
+
dependencies=analysis.dependencies,
|
|
159
|
+
diagnostics=[
|
|
160
|
+
DiagnosticOutput(
|
|
161
|
+
code=diagnostic.code,
|
|
162
|
+
subject_id=diagnostic.subject_id,
|
|
163
|
+
message=diagnostic.message,
|
|
164
|
+
)
|
|
165
|
+
for diagnostic in analysis.diagnostics
|
|
166
|
+
],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def serialize_analysis(analysis: DAGAnalysis) -> dict[str, object]:
|
|
171
|
+
"""Serialize a DAG analysis to the versioned public output schema."""
|
|
172
|
+
return build_analysis_document(analysis).model_dump(mode="json")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def analysis_json_schema() -> dict[str, object]:
|
|
176
|
+
"""Return the JSON Schema for the current analysis output contract."""
|
|
177
|
+
return AnalysisDocument.model_json_schema()
|
|
File without changes
|
flowsense/cli/main.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from flowsense.application import (
|
|
11
|
+
AnalysisRequest,
|
|
12
|
+
analysis_json_schema,
|
|
13
|
+
analyze_dag,
|
|
14
|
+
serialize_analysis,
|
|
15
|
+
)
|
|
16
|
+
from flowsense.cli.report import render_analysis
|
|
17
|
+
from flowsense.domain import (
|
|
18
|
+
AnalysisPolicy,
|
|
19
|
+
FlowSenseError,
|
|
20
|
+
MappedTaskAggregation,
|
|
21
|
+
Severity,
|
|
22
|
+
severity_meets_threshold,
|
|
23
|
+
)
|
|
24
|
+
from flowsense.infrastructure.airflow import AirflowApiError, AirflowClient
|
|
25
|
+
from flowsense.version import __version__
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(
|
|
28
|
+
name="flowsense",
|
|
29
|
+
help="Temporal drift and anomaly detection for Apache Airflow.",
|
|
30
|
+
no_args_is_help=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
ANALYSIS_THRESHOLD_EXIT_CODE = 2
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _version_callback(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(__version__)
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutputFormat(StrEnum):
|
|
44
|
+
TABLE = "table"
|
|
45
|
+
JSON = "json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FailureThreshold(StrEnum):
|
|
49
|
+
MEDIUM = "medium"
|
|
50
|
+
HIGH = "high"
|
|
51
|
+
CRITICAL = "critical"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.callback()
|
|
55
|
+
def main(
|
|
56
|
+
version: Annotated[
|
|
57
|
+
bool | None,
|
|
58
|
+
typer.Option(
|
|
59
|
+
"--version",
|
|
60
|
+
callback=_version_callback,
|
|
61
|
+
is_eager=True,
|
|
62
|
+
help="Show the installed FlowSense version and exit.",
|
|
63
|
+
),
|
|
64
|
+
] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""FlowSense CLI."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("schema")
|
|
70
|
+
def show_schema() -> None:
|
|
71
|
+
"""Print the versioned analysis output JSON Schema."""
|
|
72
|
+
typer.echo(
|
|
73
|
+
json.dumps(
|
|
74
|
+
analysis_json_schema(),
|
|
75
|
+
indent=2,
|
|
76
|
+
ensure_ascii=False,
|
|
77
|
+
sort_keys=True,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def analyze(
|
|
84
|
+
dag_id: str = typer.Argument(
|
|
85
|
+
...,
|
|
86
|
+
help="Airflow DAG id to analyze.",
|
|
87
|
+
),
|
|
88
|
+
minimum_history: int = typer.Option(5, min=2),
|
|
89
|
+
baseline_window: int | None = typer.Option(None, min=1),
|
|
90
|
+
medium_threshold: float = typer.Option(2.0, min=0.0),
|
|
91
|
+
high_threshold: float = typer.Option(3.5, min=0.0),
|
|
92
|
+
critical_threshold: float = typer.Option(5.0, min=0.0),
|
|
93
|
+
change_point_detection: bool = typer.Option(
|
|
94
|
+
True,
|
|
95
|
+
"--change-point-detection/--no-change-point-detection",
|
|
96
|
+
),
|
|
97
|
+
change_point_minimum_segment_size: int = typer.Option(3, min=2),
|
|
98
|
+
change_point_score_threshold: float = typer.Option(3.5, min=0.0),
|
|
99
|
+
trend_detection: bool = typer.Option(
|
|
100
|
+
True,
|
|
101
|
+
"--trend-detection/--no-trend-detection",
|
|
102
|
+
),
|
|
103
|
+
trend_minimum_observations: int = typer.Option(5, min=3),
|
|
104
|
+
trend_score_threshold: float = typer.Option(3.5, min=0.0),
|
|
105
|
+
trend_minimum_directional_consistency: float = typer.Option(
|
|
106
|
+
0.6,
|
|
107
|
+
min=0.0,
|
|
108
|
+
max=1.0,
|
|
109
|
+
),
|
|
110
|
+
mapped_task_aggregation: Annotated[
|
|
111
|
+
MappedTaskAggregation,
|
|
112
|
+
typer.Option(),
|
|
113
|
+
] = MappedTaskAggregation.MAX,
|
|
114
|
+
output: Annotated[
|
|
115
|
+
OutputFormat,
|
|
116
|
+
typer.Option("--output", "-o"),
|
|
117
|
+
] = OutputFormat.TABLE,
|
|
118
|
+
fail_on: Annotated[
|
|
119
|
+
FailureThreshold | None,
|
|
120
|
+
typer.Option(
|
|
121
|
+
"--fail-on",
|
|
122
|
+
help="Exit with code 2 when severity reaches this threshold.",
|
|
123
|
+
),
|
|
124
|
+
] = None,
|
|
125
|
+
history_run_limit: int | None = typer.Option(
|
|
126
|
+
None,
|
|
127
|
+
min=2,
|
|
128
|
+
help="Limit collection to the most recent successful DAG runs.",
|
|
129
|
+
),
|
|
130
|
+
dag_run_id: str | None = typer.Option(
|
|
131
|
+
None,
|
|
132
|
+
"--dag-run-id",
|
|
133
|
+
help="Analyze this successful DAG run using only its preceding history.",
|
|
134
|
+
),
|
|
135
|
+
) -> None:
|
|
136
|
+
try:
|
|
137
|
+
policy = AnalysisPolicy(
|
|
138
|
+
minimum_history=minimum_history,
|
|
139
|
+
baseline_window=baseline_window,
|
|
140
|
+
medium_threshold=medium_threshold,
|
|
141
|
+
high_threshold=high_threshold,
|
|
142
|
+
critical_threshold=critical_threshold,
|
|
143
|
+
mapped_task_aggregation=mapped_task_aggregation,
|
|
144
|
+
change_point_detection_enabled=change_point_detection,
|
|
145
|
+
change_point_minimum_segment_size=change_point_minimum_segment_size,
|
|
146
|
+
change_point_score_threshold=change_point_score_threshold,
|
|
147
|
+
trend_detection_enabled=trend_detection,
|
|
148
|
+
trend_minimum_observations=trend_minimum_observations,
|
|
149
|
+
trend_score_threshold=trend_score_threshold,
|
|
150
|
+
trend_minimum_directional_consistency=(
|
|
151
|
+
trend_minimum_directional_consistency
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
request = AnalysisRequest(
|
|
159
|
+
dag_id=dag_id,
|
|
160
|
+
policy=policy,
|
|
161
|
+
history_run_limit=history_run_limit,
|
|
162
|
+
dag_run_id=dag_run_id,
|
|
163
|
+
)
|
|
164
|
+
with AirflowClient(
|
|
165
|
+
history_run_limit=history_run_limit,
|
|
166
|
+
target_dag_run_id=request.dag_run_id,
|
|
167
|
+
) as source:
|
|
168
|
+
analysis = analyze_dag(
|
|
169
|
+
dag_id=request.dag_id,
|
|
170
|
+
source=source,
|
|
171
|
+
policy=request.policy,
|
|
172
|
+
)
|
|
173
|
+
except AirflowApiError as exc:
|
|
174
|
+
console.print(f"[bold red]Airflow request failed:[/bold red] {exc}")
|
|
175
|
+
raise typer.Exit(code=1) from exc
|
|
176
|
+
except FlowSenseError as exc:
|
|
177
|
+
console.print(f"[bold red]Analysis failed:[/bold red] {exc}")
|
|
178
|
+
raise typer.Exit(code=1) from exc
|
|
179
|
+
|
|
180
|
+
if output is OutputFormat.JSON:
|
|
181
|
+
typer.echo(
|
|
182
|
+
json.dumps(
|
|
183
|
+
serialize_analysis(analysis),
|
|
184
|
+
indent=2,
|
|
185
|
+
ensure_ascii=False,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
render_analysis(console, analysis)
|
|
190
|
+
|
|
191
|
+
if fail_on is not None and severity_meets_threshold(
|
|
192
|
+
analysis.overall_severity,
|
|
193
|
+
Severity(fail_on.value.upper()),
|
|
194
|
+
):
|
|
195
|
+
raise typer.Exit(code=ANALYSIS_THRESHOLD_EXIT_CODE)
|
flowsense/cli/report.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from flowsense.domain import DAGAnalysis, Severity
|
|
9
|
+
from flowsense.domain.enums import SEVERITY_SCORE
|
|
10
|
+
|
|
11
|
+
_SEVERITY_STYLES = {
|
|
12
|
+
Severity.NORMAL: "green",
|
|
13
|
+
Severity.MEDIUM: "yellow",
|
|
14
|
+
Severity.HIGH: "bright_red",
|
|
15
|
+
Severity.CRITICAL: "bold red",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _severity_text(severity: Severity) -> Text:
|
|
20
|
+
return Text(str(severity), style=_SEVERITY_STYLES[severity])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _percent(value: float | None) -> str:
|
|
24
|
+
return f"{value:+.1f}%" if value is not None else "n/a"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _render_summary(console: Console, analysis: DAGAnalysis) -> None:
|
|
28
|
+
dag_summary = analysis.summary
|
|
29
|
+
summary = Table.grid(padding=(0, 2))
|
|
30
|
+
summary.add_column(style="bold")
|
|
31
|
+
summary.add_column()
|
|
32
|
+
summary.add_row("DAG", analysis.dag_id)
|
|
33
|
+
if analysis.current_dag_run_id is not None:
|
|
34
|
+
summary.add_row("Current DAG run", analysis.current_dag_run_id)
|
|
35
|
+
summary.add_row("Runs analyzed", str(analysis.runs_analyzed))
|
|
36
|
+
summary.add_row("Overall severity", _severity_text(analysis.overall_severity))
|
|
37
|
+
summary.add_row(
|
|
38
|
+
"Task coverage",
|
|
39
|
+
f"{dag_summary.analyzed_tasks}/{dag_summary.total_tasks} "
|
|
40
|
+
f"({dag_summary.analysis_coverage_percent:.1f}%)",
|
|
41
|
+
)
|
|
42
|
+
summary.add_row("Anomalous tasks", str(dag_summary.anomalous_tasks))
|
|
43
|
+
summary.add_row("Anomalous handoffs", str(dag_summary.anomalous_handoffs))
|
|
44
|
+
summary.add_row("Affected tasks", str(dag_summary.affected_tasks))
|
|
45
|
+
summary.add_row(
|
|
46
|
+
"Structural signals",
|
|
47
|
+
f"{dag_summary.change_points} change points, {dag_summary.trends} trends",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if analysis.primary_origin is not None:
|
|
51
|
+
summary.add_row("Primary origin", analysis.primary_origin.task_id)
|
|
52
|
+
summary.add_row("Classification", str(analysis.primary_origin.classification))
|
|
53
|
+
summary.add_row(
|
|
54
|
+
"Propagation score",
|
|
55
|
+
f"{analysis.primary_origin.propagation_score:.2f}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
console.print(Panel(summary, title="FlowSense Analysis", expand=False))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _render_task_drift(console: Console, analysis: DAGAnalysis) -> None:
|
|
62
|
+
table = Table(title="Task Drift")
|
|
63
|
+
table.add_column("Task")
|
|
64
|
+
table.add_column("Baseline", justify="right")
|
|
65
|
+
table.add_column("Current", justify="right")
|
|
66
|
+
table.add_column("Deviation", justify="right")
|
|
67
|
+
table.add_column("Z-Score", justify="right")
|
|
68
|
+
table.add_column("Severity")
|
|
69
|
+
table.add_column("Impact")
|
|
70
|
+
|
|
71
|
+
ordered_results = sorted(
|
|
72
|
+
analysis.drift_results.items(),
|
|
73
|
+
key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
for task_id, result in ordered_results:
|
|
77
|
+
impact = analysis.task_impacts.get(task_id)
|
|
78
|
+
table.add_row(
|
|
79
|
+
task_id,
|
|
80
|
+
f"{result.baseline:.2f}s",
|
|
81
|
+
f"{result.current:.2f}s",
|
|
82
|
+
f"{result.deviation_percent:+.1f}%",
|
|
83
|
+
f"{result.robust_z_score:.2f}",
|
|
84
|
+
_severity_text(result.severity),
|
|
85
|
+
str(impact.classification) if impact else "-",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
console.print(table)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_handoff_drift(console: Console, analysis: DAGAnalysis) -> None:
|
|
92
|
+
if not analysis.handoff_drift_results:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
table = Table(title="Handoff Drift")
|
|
96
|
+
table.add_column("Edge")
|
|
97
|
+
table.add_column("Baseline", justify="right")
|
|
98
|
+
table.add_column("Current", justify="right")
|
|
99
|
+
table.add_column("Deviation", justify="right")
|
|
100
|
+
table.add_column("Z-Score", justify="right")
|
|
101
|
+
table.add_column("Severity")
|
|
102
|
+
|
|
103
|
+
ordered_results = sorted(
|
|
104
|
+
analysis.handoff_drift_results.items(),
|
|
105
|
+
key=lambda item: (-SEVERITY_SCORE[item[1].severity], item[0]),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
for (upstream, downstream), result in ordered_results:
|
|
109
|
+
table.add_row(
|
|
110
|
+
f"{upstream} -> {downstream}",
|
|
111
|
+
f"{result.baseline:.2f}s",
|
|
112
|
+
f"{result.current:.2f}s",
|
|
113
|
+
f"{result.deviation_percent:+.1f}%",
|
|
114
|
+
f"{result.robust_z_score:.2f}",
|
|
115
|
+
_severity_text(result.severity),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
console.print(table)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _render_change_points(console: Console, analysis: DAGAnalysis) -> None:
|
|
122
|
+
results = [
|
|
123
|
+
*analysis.change_point_results.values(),
|
|
124
|
+
*analysis.handoff_change_point_results.values(),
|
|
125
|
+
]
|
|
126
|
+
if not results:
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
table = Table(title="Change Points")
|
|
130
|
+
table.add_column("Subject")
|
|
131
|
+
table.add_column("Direction")
|
|
132
|
+
table.add_column("Observation", justify="right")
|
|
133
|
+
table.add_column("Before", justify="right")
|
|
134
|
+
table.add_column("After", justify="right")
|
|
135
|
+
table.add_column("Change", justify="right")
|
|
136
|
+
table.add_column("Score", justify="right")
|
|
137
|
+
|
|
138
|
+
for result in sorted(results, key=lambda item: item.subject_id):
|
|
139
|
+
table.add_row(
|
|
140
|
+
result.subject_id,
|
|
141
|
+
str(result.direction),
|
|
142
|
+
str(result.change_index + 1),
|
|
143
|
+
f"{result.before_median:.2f}s",
|
|
144
|
+
f"{result.after_median:.2f}s",
|
|
145
|
+
_percent(result.change_percent),
|
|
146
|
+
f"{result.score:.2f}",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
console.print(table)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _render_trends(console: Console, analysis: DAGAnalysis) -> None:
|
|
153
|
+
results = [
|
|
154
|
+
*analysis.trend_results.values(),
|
|
155
|
+
*analysis.handoff_trend_results.values(),
|
|
156
|
+
]
|
|
157
|
+
if not results:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
table = Table(title="Trends")
|
|
161
|
+
table.add_column("Subject")
|
|
162
|
+
table.add_column("Direction")
|
|
163
|
+
table.add_column("Slope / run", justify="right")
|
|
164
|
+
table.add_column("Est. change", justify="right")
|
|
165
|
+
table.add_column("Change", justify="right")
|
|
166
|
+
table.add_column("Consistency", justify="right")
|
|
167
|
+
table.add_column("Score", justify="right")
|
|
168
|
+
|
|
169
|
+
for result in sorted(results, key=lambda item: item.subject_id):
|
|
170
|
+
table.add_row(
|
|
171
|
+
result.subject_id,
|
|
172
|
+
str(result.direction),
|
|
173
|
+
f"{result.slope_per_observation:+.2f}s",
|
|
174
|
+
f"{result.estimated_change:+.2f}s",
|
|
175
|
+
_percent(result.change_percent),
|
|
176
|
+
f"{result.directional_consistency:.0%}",
|
|
177
|
+
f"{result.score:.2f}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
console.print(table)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _render_propagation(console: Console, analysis: DAGAnalysis) -> None:
|
|
184
|
+
if not analysis.propagation_results:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
table = Table(title="Propagation")
|
|
188
|
+
table.add_column("Origin")
|
|
189
|
+
table.add_column("Path")
|
|
190
|
+
table.add_column("Affected", justify="right")
|
|
191
|
+
table.add_column("Score", justify="right")
|
|
192
|
+
|
|
193
|
+
for result in sorted(
|
|
194
|
+
analysis.propagation_results,
|
|
195
|
+
key=lambda item: (item.origin_task, item.path),
|
|
196
|
+
):
|
|
197
|
+
table.add_row(
|
|
198
|
+
result.origin_task,
|
|
199
|
+
" -> ".join(result.path),
|
|
200
|
+
str(len(result.affected_tasks)),
|
|
201
|
+
f"{result.propagation_score:.2f}",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
console.print(table)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _render_diagnostics(console: Console, analysis: DAGAnalysis) -> None:
|
|
208
|
+
if not analysis.diagnostics:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
table = Table(title="Diagnostics", title_style="bold yellow")
|
|
212
|
+
table.add_column("Code", style="yellow")
|
|
213
|
+
table.add_column("Subject")
|
|
214
|
+
table.add_column("Message")
|
|
215
|
+
|
|
216
|
+
for diagnostic in sorted(
|
|
217
|
+
analysis.diagnostics,
|
|
218
|
+
key=lambda item: (item.code, item.subject_id),
|
|
219
|
+
):
|
|
220
|
+
table.add_row(diagnostic.code, diagnostic.subject_id, diagnostic.message)
|
|
221
|
+
|
|
222
|
+
console.print(table)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def render_analysis(console: Console, analysis: DAGAnalysis) -> None:
|
|
226
|
+
"""Render a complete human-readable analysis report."""
|
|
227
|
+
_render_summary(console, analysis)
|
|
228
|
+
_render_task_drift(console, analysis)
|
|
229
|
+
_render_handoff_drift(console, analysis)
|
|
230
|
+
_render_change_points(console, analysis)
|
|
231
|
+
_render_trends(console, analysis)
|
|
232
|
+
_render_propagation(console, analysis)
|
|
233
|
+
_render_diagnostics(console, analysis)
|
|
File without changes
|
flowsense/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from flowsense.domain import ConfigurationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class AirflowConfig:
|
|
11
|
+
base_url: str
|
|
12
|
+
username: str
|
|
13
|
+
password: str
|
|
14
|
+
api_version: str = "v2"
|
|
15
|
+
auth_mode: str = "token"
|
|
16
|
+
connect_timeout: float = 10.0
|
|
17
|
+
read_timeout: float = 10.0
|
|
18
|
+
max_retries: int = 2
|
|
19
|
+
retry_backoff: float = 0.5
|
|
20
|
+
history_run_limit: int = 100
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_airflow_config() -> AirflowConfig:
|
|
24
|
+
base_url = os.getenv(
|
|
25
|
+
"AIRFLOW_BASE_URL",
|
|
26
|
+
"http://localhost:8080",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
username = os.getenv("AIRFLOW_USERNAME")
|
|
30
|
+
password = os.getenv("AIRFLOW_PASSWORD")
|
|
31
|
+
api_version = os.getenv("AIRFLOW_API_VERSION", "v2")
|
|
32
|
+
auth_mode = os.getenv("AIRFLOW_AUTH_MODE", "token")
|
|
33
|
+
connect_timeout = _read_float("AIRFLOW_CONNECT_TIMEOUT", "10")
|
|
34
|
+
read_timeout = _read_float("AIRFLOW_READ_TIMEOUT", "10")
|
|
35
|
+
max_retries = _read_int("AIRFLOW_MAX_RETRIES", "2")
|
|
36
|
+
retry_backoff = _read_float("AIRFLOW_RETRY_BACKOFF", "0.5")
|
|
37
|
+
history_run_limit = _read_int("AIRFLOW_HISTORY_RUN_LIMIT", "100")
|
|
38
|
+
|
|
39
|
+
if not username:
|
|
40
|
+
raise ConfigurationError("AIRFLOW_USERNAME environment variable is required.")
|
|
41
|
+
|
|
42
|
+
if not password:
|
|
43
|
+
raise ConfigurationError("AIRFLOW_PASSWORD environment variable is required.")
|
|
44
|
+
|
|
45
|
+
return AirflowConfig(
|
|
46
|
+
base_url=base_url,
|
|
47
|
+
username=username,
|
|
48
|
+
password=password,
|
|
49
|
+
api_version=api_version,
|
|
50
|
+
auth_mode=auth_mode,
|
|
51
|
+
connect_timeout=connect_timeout,
|
|
52
|
+
read_timeout=read_timeout,
|
|
53
|
+
max_retries=max_retries,
|
|
54
|
+
retry_backoff=retry_backoff,
|
|
55
|
+
history_run_limit=history_run_limit,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_float(name: str, default: str) -> float:
|
|
60
|
+
value = os.getenv(name, default)
|
|
61
|
+
try:
|
|
62
|
+
return float(value)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise ConfigurationError(f"{name} must be a number.") from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _read_int(name: str, default: str) -> int:
|
|
68
|
+
value = os.getenv(name, default)
|
|
69
|
+
try:
|
|
70
|
+
return int(value)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise ConfigurationError(f"{name} must be an integer.") from exc
|