airflow-pytest-plugin 0.3.2__tar.gz → 0.4.0__tar.gz
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.
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/PKG-INFO +57 -5
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/README.md +51 -4
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/pyproject.toml +18 -1
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/config.py +58 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/filesystem.py +22 -6
- airflow_pytest_plugin-0.4.0/src/airflow_pytest_plugin/web/routes/failures.py +281 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/flaky.py +15 -2
- airflow_pytest_plugin-0.4.0/src/airflow_pytest_plugin/web/routes/monitoring.py +324 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/reports.py +205 -8
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/templates.py +635 -106
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/conftest.py +7 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_config.py +27 -0
- airflow_pytest_plugin-0.4.0/tests/test_metrics.py +283 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_web_app.py +489 -9
- airflow_pytest_plugin-0.4.0/tests/ui/README.md +52 -0
- airflow_pytest_plugin-0.4.0/tests/ui/conftest.py +345 -0
- airflow_pytest_plugin-0.4.0/tests/ui/test_ui_airflow.py +63 -0
- airflow_pytest_plugin-0.4.0/tests/ui/test_ui_regression.py +162 -0
- airflow_pytest_plugin-0.4.0/tests/ui/test_ui_scale.py +118 -0
- airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/web/routes/failures.py +0 -109
- airflow_pytest_plugin-0.3.2/src/airflow_pytest_plugin/web/routes/monitoring.py +0 -92
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/.gitignore +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/LICENSE +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/examples/example_dag.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/compat/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/compat/airflow.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/layout.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/models.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/plugin.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/producer/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/producer/archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/retention.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/sources/base.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/version.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/__main__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/app.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/__init__.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/common.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/web/routes/compare.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_archiving_parser.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_authz_integration.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_compat.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_filesystem_source.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_layout.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_models.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_package.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_plugin.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_retention.py +0 -0
- {airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: airflow-pytest-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: View airflow-pytest-operator results in the Airflow 3 web UI.
|
|
5
5
|
Project-URL: Homepage, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
6
6
|
Project-URL: Repository, https://github.com/IKrysanov/airflow-pytest-plugin
|
|
@@ -37,6 +37,11 @@ Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
|
37
37
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
38
38
|
Provides-Extra: secure-xml
|
|
39
39
|
Requires-Dist: defusedxml>=0.7; extra == 'secure-xml'
|
|
40
|
+
Provides-Extra: ui-test
|
|
41
|
+
Requires-Dist: fastapi>=0.110; extra == 'ui-test'
|
|
42
|
+
Requires-Dist: pytest-playwright>=0.5; extra == 'ui-test'
|
|
43
|
+
Requires-Dist: pytest>=7.0; extra == 'ui-test'
|
|
44
|
+
Requires-Dist: uvicorn>=0.23; extra == 'ui-test'
|
|
40
45
|
Provides-Extra: web
|
|
41
46
|
Requires-Dist: fastapi>=0.110; extra == 'web'
|
|
42
47
|
Requires-Dist: uvicorn>=0.23; extra == 'web'
|
|
@@ -91,6 +96,7 @@ It has two halves that share one on-disk layout:
|
|
|
91
96
|
- [HTTP API](#http-api)
|
|
92
97
|
- [Access control (RBAC)](#access-control-rbac)
|
|
93
98
|
- [Configuration](#configuration)
|
|
99
|
+
- [Prometheus metrics](#prometheus-metrics)
|
|
94
100
|
- [Architecture (SOLID)](#architecture-solid)
|
|
95
101
|
- [Development](#development)
|
|
96
102
|
- [License](#license)
|
|
@@ -124,9 +130,21 @@ run; expanding a case offers its full **history**:
|
|
|
124
130
|
|
|
125
131
|

|
|
126
132
|
|
|
133
|
+
**Slow tests & duration regressions** — the *Slowdowns* KPI opens a panel of tests
|
|
134
|
+
whose **execution time got slower** (recent-half average vs the older half, over a
|
|
135
|
+
configurable window) alongside the **slowest tests** by average duration. A test
|
|
136
|
+
that speeds back up drops off the list. Inside a run, the case table sorts by
|
|
137
|
+
execution time (slowest first) so the heaviest tests surface immediately.
|
|
138
|
+
|
|
139
|
+

|
|
140
|
+
|
|
127
141
|
**Unique tests & failures** — the *Unique tests* KPI opens the searchable,
|
|
128
|
-
paginated catalogue of every distinct test
|
|
129
|
-
|
|
142
|
+
paginated catalogue of every distinct test (each with its runs / pass-fail-error-skip
|
|
143
|
+
counts / average time); the *Failures* KPI shows what's broken **now** — failures in each
|
|
144
|
+
dag·task's latest run, so the count shrinks as tests are fixed — grouped into **clusters
|
|
145
|
+
by normalized error** (biggest first) so common root causes surface instead of
|
|
146
|
+
per-failure spam. Expand a cluster to its tests, or open the same clusters scoped to one
|
|
147
|
+
run via the detail's *Error clusters* button:
|
|
130
148
|
|
|
131
149
|

|
|
132
150
|
|
|
@@ -234,15 +252,18 @@ runtime. Endpoints (relative to the mount):
|
|
|
234
252
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
235
253
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
236
254
|
| `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
|
|
237
|
-
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases
|
|
255
|
+
| `GET /api/failures?dag_id=&run_id=&task_id=&latest=` | failed/errored cases — each dag·task's latest run by default (`latest=0` for full history) |
|
|
256
|
+
| `GET /api/failure-clusters?dag_id=&run_id=&task_id=&latest=` | failures grouped by normalized error signature (biggest first); latest-run-only by default |
|
|
238
257
|
| `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
|
|
239
258
|
| `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
|
|
259
|
+
| `GET /api/slow?dag_id=&task_id=&window=` | duration regressions (tests whose execution time got slower) + the slowest tests by average |
|
|
240
260
|
| `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
|
|
241
|
-
| `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+
|
|
261
|
+
| `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ when `full`, each test's runs / passed / failed / errors / skipped / avg duration) |
|
|
242
262
|
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
243
263
|
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
244
264
|
| `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
|
|
245
265
|
| `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
|
|
266
|
+
| `GET /api/metrics` | Prometheus exposition — opt-in, bearer-token (see [Prometheus metrics](#prometheus-metrics)) |
|
|
246
267
|
| `GET /api/docs` | OpenAPI docs (Swagger UI) |
|
|
247
268
|
|
|
248
269
|
The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
|
|
@@ -308,7 +329,10 @@ Download them from a report's detail view, or `GET
|
|
|
308
329
|
| `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
|
|
309
330
|
| `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
|
|
310
331
|
| `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
|
|
332
|
+
| `AIRFLOW_PYTEST_SLOW_FACTOR` (env/cfg) | `1.3` | how much slower (recent-half avg ÷ older half, ≥1) a test must get to count as a duration regression |
|
|
333
|
+
| `AIRFLOW_PYTEST_SLOW_MIN_DELTA` (env/cfg) | `0.5` | minimum absolute slowdown in seconds for a regression to register (filters jittery fast tests) |
|
|
311
334
|
| `AIRFLOW_PYTEST_SUCCESS_THRESHOLD` (env/cfg) | `0.85` | pass-rate (0–1) over executed tests at/above which a run counts as successful (*Passing runs*); `1.0` = strict, zero failures/errors |
|
|
335
|
+
| `AIRFLOW_PYTEST_METRICS_TOKEN` (env/cfg) | — | bearer token that **enables** the Prometheus `/api/metrics` endpoint; unset = disabled (see [below](#prometheus-metrics)) |
|
|
312
336
|
|
|
313
337
|
**Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
|
|
314
338
|
value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
|
|
@@ -328,6 +352,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
|
|
|
328
352
|
invalidate it immediately). New runs therefore appear within a couple of seconds (or
|
|
329
353
|
on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
|
|
330
354
|
|
|
355
|
+
## Prometheus metrics
|
|
356
|
+
|
|
357
|
+
`GET /api/metrics` exposes per-dag·task gauges (from each dag·task's **latest** run)
|
|
358
|
+
in the Prometheus text format — `airflow_pytest_latest_{passed,failed,errors,skipped,
|
|
359
|
+
tests,pass_ratio,duration_seconds,success,run_timestamp_seconds}{dag_id,task_id}` and
|
|
360
|
+
`airflow_pytest_dagtask_runs{dag_id,task_id}`, plus globals
|
|
361
|
+
`airflow_pytest_{up,runs,dagtasks,latest_failures,series_truncated,build_info}` (all gauges).
|
|
362
|
+
|
|
363
|
+
It's **disabled by default** and turns on only when you set a scrape token; requests must
|
|
364
|
+
then present it as a bearer token (constant-time compared). The scrape is cheap and
|
|
365
|
+
bounded — one cached directory scan, summary-derived (no per-run reads), capped at 2000
|
|
366
|
+
series — so it's safe to poll frequently.
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
export AIRFLOW_PYTEST_METRICS_TOKEN="$(openssl rand -hex 16)"
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```yaml
|
|
373
|
+
# prometheus.yml
|
|
374
|
+
scrape_configs:
|
|
375
|
+
- job_name: airflow-pytest
|
|
376
|
+
metrics_path: /pytest-reports/api/metrics # the plugin's mount prefix
|
|
377
|
+
authorization:
|
|
378
|
+
credentials: "<AIRFLOW_PYTEST_METRICS_TOKEN>"
|
|
379
|
+
static_configs:
|
|
380
|
+
- targets: ["airflow-apiserver:8080"]
|
|
381
|
+
```
|
|
382
|
+
|
|
331
383
|
## Retention (auto-cleanup)
|
|
332
384
|
|
|
333
385
|
Reports accumulate forever unless you prune them. Set any of the
|
|
@@ -47,6 +47,7 @@ It has two halves that share one on-disk layout:
|
|
|
47
47
|
- [HTTP API](#http-api)
|
|
48
48
|
- [Access control (RBAC)](#access-control-rbac)
|
|
49
49
|
- [Configuration](#configuration)
|
|
50
|
+
- [Prometheus metrics](#prometheus-metrics)
|
|
50
51
|
- [Architecture (SOLID)](#architecture-solid)
|
|
51
52
|
- [Development](#development)
|
|
52
53
|
- [License](#license)
|
|
@@ -80,9 +81,21 @@ run; expanding a case offers its full **history**:
|
|
|
80
81
|
|
|
81
82
|

|
|
82
83
|
|
|
84
|
+
**Slow tests & duration regressions** — the *Slowdowns* KPI opens a panel of tests
|
|
85
|
+
whose **execution time got slower** (recent-half average vs the older half, over a
|
|
86
|
+
configurable window) alongside the **slowest tests** by average duration. A test
|
|
87
|
+
that speeds back up drops off the list. Inside a run, the case table sorts by
|
|
88
|
+
execution time (slowest first) so the heaviest tests surface immediately.
|
|
89
|
+
|
|
90
|
+

|
|
91
|
+
|
|
83
92
|
**Unique tests & failures** — the *Unique tests* KPI opens the searchable,
|
|
84
|
-
paginated catalogue of every distinct test
|
|
85
|
-
|
|
93
|
+
paginated catalogue of every distinct test (each with its runs / pass-fail-error-skip
|
|
94
|
+
counts / average time); the *Failures* KPI shows what's broken **now** — failures in each
|
|
95
|
+
dag·task's latest run, so the count shrinks as tests are fixed — grouped into **clusters
|
|
96
|
+
by normalized error** (biggest first) so common root causes surface instead of
|
|
97
|
+
per-failure spam. Expand a cluster to its tests, or open the same clusters scoped to one
|
|
98
|
+
run via the detail's *Error clusters* button:
|
|
86
99
|
|
|
87
100
|

|
|
88
101
|
|
|
@@ -190,15 +203,18 @@ runtime. Endpoints (relative to the mount):
|
|
|
190
203
|
| `GET /api/reports?dag_id=&run_id=` | summaries, newest first |
|
|
191
204
|
| `GET /api/reports/{report_id}` | one report with per-case rows |
|
|
192
205
|
| `GET /api/groups?dag_id=&task_id=` | runs aggregated by dag·task (count, pass-rate, avg duration, last status) |
|
|
193
|
-
| `GET /api/failures?dag_id=&run_id=&task_id=` | failed/errored cases
|
|
206
|
+
| `GET /api/failures?dag_id=&run_id=&task_id=&latest=` | failed/errored cases — each dag·task's latest run by default (`latest=0` for full history) |
|
|
207
|
+
| `GET /api/failure-clusters?dag_id=&run_id=&task_id=&latest=` | failures grouped by normalized error signature (biggest first); latest-run-only by default |
|
|
194
208
|
| `GET /api/compare?base=&head=` | per-test diff between two runs (newly failed / fixed / …) |
|
|
195
209
|
| `GET /api/flaky?dag_id=&task_id=&window=` | flaky tests with score, trend, and a quarantine flag |
|
|
210
|
+
| `GET /api/slow?dag_id=&task_id=&window=` | duration regressions (tests whose execution time got slower) + the slowest tests by average |
|
|
196
211
|
| `GET /api/test-history?dag_id=&task_id=&node_id=&limit=` | one test's outcome per run |
|
|
197
|
-
| `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+
|
|
212
|
+
| `GET /api/unique-tests?dag_id=&task_id=&run_id=&full=` | distinct test count (+ when `full`, each test's runs / passed / failed / errors / skipped / avg duration) |
|
|
198
213
|
| `DELETE /api/reports/{report_id}` | delete a report (RBAC-gated) |
|
|
199
214
|
| `GET /api/reports/{report_id}/allure.zip` | raw Allure results as a zip (if any) |
|
|
200
215
|
| `GET /api/health` | liveness + readiness: `status`, `ready`, `reports_root`(+`_exists`), `auth`, `secure_xml` |
|
|
201
216
|
| `GET /api/version` | `{"name": ..., "version": ...}` from package metadata |
|
|
217
|
+
| `GET /api/metrics` | Prometheus exposition — opt-in, bearer-token (see [Prometheus metrics](#prometheus-metrics)) |
|
|
202
218
|
| `GET /api/docs` | OpenAPI docs (Swagger UI) |
|
|
203
219
|
|
|
204
220
|
The reads (`GET`) and the delete are gated by Airflow RBAC — see below.
|
|
@@ -264,7 +280,10 @@ Download them from a report's detail view, or `GET
|
|
|
264
280
|
| `AIRFLOW_PYTEST_FLAKY_WINDOW` (env/cfg) | `30` | default recent runs the flaky detector scans |
|
|
265
281
|
| `AIRFLOW_PYTEST_FLAKY_QUARANTINE_SCORE` (env/cfg) | `0.5` | flakiness score (0–1) that flags a test for quarantine |
|
|
266
282
|
| `AIRFLOW_PYTEST_FLAKY_MIN_SCORE` (env/cfg) | `0.1` | flakiness score (0–1) below which a test is not counted as flaky |
|
|
283
|
+
| `AIRFLOW_PYTEST_SLOW_FACTOR` (env/cfg) | `1.3` | how much slower (recent-half avg ÷ older half, ≥1) a test must get to count as a duration regression |
|
|
284
|
+
| `AIRFLOW_PYTEST_SLOW_MIN_DELTA` (env/cfg) | `0.5` | minimum absolute slowdown in seconds for a regression to register (filters jittery fast tests) |
|
|
267
285
|
| `AIRFLOW_PYTEST_SUCCESS_THRESHOLD` (env/cfg) | `0.85` | pass-rate (0–1) over executed tests at/above which a run counts as successful (*Passing runs*); `1.0` = strict, zero failures/errors |
|
|
286
|
+
| `AIRFLOW_PYTEST_METRICS_TOKEN` (env/cfg) | — | bearer token that **enables** the Prometheus `/api/metrics` endpoint; unset = disabled (see [below](#prometheus-metrics)) |
|
|
268
287
|
|
|
269
288
|
**Enable / disable the reader.** Set `AIRFLOW_PYTEST_PLUGIN_ENABLE` to a falsey
|
|
270
289
|
value (`0`, `false`, `no`, `off`) to stop the plugin registering its UI and API
|
|
@@ -284,6 +303,34 @@ single scan for `AIRFLOW_PYTEST_SCAN_CACHE_TTL` seconds (default `2.0`; deletes
|
|
|
284
303
|
invalidate it immediately). New runs therefore appear within a couple of seconds (or
|
|
285
304
|
on **Refresh**); set it to `0` for no caching, or higher on a very large tree.
|
|
286
305
|
|
|
306
|
+
## Prometheus metrics
|
|
307
|
+
|
|
308
|
+
`GET /api/metrics` exposes per-dag·task gauges (from each dag·task's **latest** run)
|
|
309
|
+
in the Prometheus text format — `airflow_pytest_latest_{passed,failed,errors,skipped,
|
|
310
|
+
tests,pass_ratio,duration_seconds,success,run_timestamp_seconds}{dag_id,task_id}` and
|
|
311
|
+
`airflow_pytest_dagtask_runs{dag_id,task_id}`, plus globals
|
|
312
|
+
`airflow_pytest_{up,runs,dagtasks,latest_failures,series_truncated,build_info}` (all gauges).
|
|
313
|
+
|
|
314
|
+
It's **disabled by default** and turns on only when you set a scrape token; requests must
|
|
315
|
+
then present it as a bearer token (constant-time compared). The scrape is cheap and
|
|
316
|
+
bounded — one cached directory scan, summary-derived (no per-run reads), capped at 2000
|
|
317
|
+
series — so it's safe to poll frequently.
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
export AIRFLOW_PYTEST_METRICS_TOKEN="$(openssl rand -hex 16)"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```yaml
|
|
324
|
+
# prometheus.yml
|
|
325
|
+
scrape_configs:
|
|
326
|
+
- job_name: airflow-pytest
|
|
327
|
+
metrics_path: /pytest-reports/api/metrics # the plugin's mount prefix
|
|
328
|
+
authorization:
|
|
329
|
+
credentials: "<AIRFLOW_PYTEST_METRICS_TOKEN>"
|
|
330
|
+
static_configs:
|
|
331
|
+
- targets: ["airflow-apiserver:8080"]
|
|
332
|
+
```
|
|
333
|
+
|
|
287
334
|
## Retention (auto-cleanup)
|
|
288
335
|
|
|
289
336
|
Reports accumulate forever unless you prune them. Set any of the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "airflow-pytest-plugin"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "View airflow-pytest-operator results in the Airflow 3 web UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -60,6 +60,14 @@ dev = [
|
|
|
60
60
|
"mypy>=1.8",
|
|
61
61
|
"ruff>=0.4",
|
|
62
62
|
]
|
|
63
|
+
# Playwright UI regression suite (tests/ui, marker `ui`). Opt-in: heavy (real browser),
|
|
64
|
+
# run locally + in a dedicated CI job. After install: `playwright install chromium`.
|
|
65
|
+
ui-test = [
|
|
66
|
+
"pytest>=7.0",
|
|
67
|
+
"pytest-playwright>=0.5",
|
|
68
|
+
"fastapi>=0.110",
|
|
69
|
+
"uvicorn>=0.23", # the standalone dev server the UI tests drive
|
|
70
|
+
]
|
|
63
71
|
|
|
64
72
|
[project.urls]
|
|
65
73
|
Homepage = "https://github.com/IKrysanov/airflow-pytest-plugin"
|
|
@@ -83,6 +91,15 @@ include = ["src", "tests", "examples", "README.md", "LICENSE"]
|
|
|
83
91
|
[tool.pytest.ini_options]
|
|
84
92
|
testpaths = ["tests"]
|
|
85
93
|
pythonpath = ["src"]
|
|
94
|
+
# UI (Playwright) tests are opt-in and excluded by default so the unit suite stays fast
|
|
95
|
+
# and browser-free. Run them explicitly: `pytest -m ui` (standalone dev server) or
|
|
96
|
+
# `pytest -m ui_airflow` (embedded in a real Airflow api-server). Both need the
|
|
97
|
+
# `ui-test` extra + browsers; `ui_airflow` also needs Airflow installed.
|
|
98
|
+
addopts = ["-m", "not ui and not ui_airflow"]
|
|
99
|
+
markers = [
|
|
100
|
+
"ui: Playwright UI regression test (opt-in; needs the 'ui-test' extra + browsers)",
|
|
101
|
+
"ui_airflow: Playwright UI test against a real Airflow api-server with the plugin embedded",
|
|
102
|
+
]
|
|
86
103
|
|
|
87
104
|
[tool.coverage.run]
|
|
88
105
|
branch = true
|
{airflow_pytest_plugin-0.3.2 → airflow_pytest_plugin-0.4.0}/src/airflow_pytest_plugin/config.py
RENAMED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import math
|
|
19
20
|
import os
|
|
20
21
|
|
|
21
22
|
from .compat import get_conf_value
|
|
@@ -149,6 +150,49 @@ def get_flaky_min_score() -> float:
|
|
|
149
150
|
)
|
|
150
151
|
|
|
151
152
|
|
|
153
|
+
#: Duration-regression detector. A test is flagged "slower" when its recent-half
|
|
154
|
+
#: average duration is at least ``SLOW_FACTOR``× its older-half average AND the
|
|
155
|
+
#: absolute increase clears ``SLOW_MIN_DELTA`` seconds (the delta filters noise on
|
|
156
|
+
#: fast tests, where a tiny jitter can still beat the ratio). Shares the flaky window.
|
|
157
|
+
SLOW_FACTOR_ENV = "AIRFLOW_PYTEST_SLOW_FACTOR"
|
|
158
|
+
DEFAULT_SLOW_FACTOR = 1.3
|
|
159
|
+
SLOW_MIN_DELTA_ENV = "AIRFLOW_PYTEST_SLOW_MIN_DELTA"
|
|
160
|
+
DEFAULT_SLOW_MIN_DELTA = 0.5
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _positive_float_setting(
|
|
164
|
+
env_var: str, conf_key: str, default: float, *, minimum: float
|
|
165
|
+
) -> float:
|
|
166
|
+
"""A float ≥ ``minimum`` from env, then cfg; default on missing/invalid/below."""
|
|
167
|
+
raw = os.environ.get(env_var)
|
|
168
|
+
if raw is None or not raw.strip():
|
|
169
|
+
raw = get_conf_value(CONF_SECTION, conf_key)
|
|
170
|
+
if raw is None or not str(raw).strip():
|
|
171
|
+
return default
|
|
172
|
+
try:
|
|
173
|
+
value = float(str(raw).strip())
|
|
174
|
+
except ValueError:
|
|
175
|
+
return default
|
|
176
|
+
# Reject NaN/inf: they'd serialize to non-spec JSON ("NaN"/"Infinity") downstream.
|
|
177
|
+
if not math.isfinite(value):
|
|
178
|
+
return default
|
|
179
|
+
return value if value >= minimum else default
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_slow_factor() -> float:
|
|
183
|
+
"""Multiplier a test's recent-half avg duration must reach to count as a regression."""
|
|
184
|
+
return _positive_float_setting(
|
|
185
|
+
SLOW_FACTOR_ENV, "slow_factor", DEFAULT_SLOW_FACTOR, minimum=1.0
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_slow_min_delta() -> float:
|
|
190
|
+
"""Minimum absolute slowdown (seconds) for a duration regression to register."""
|
|
191
|
+
return _positive_float_setting(
|
|
192
|
+
SLOW_MIN_DELTA_ENV, "slow_min_delta", DEFAULT_SLOW_MIN_DELTA, minimum=0.0
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
152
196
|
#: Pass-rate (0–1) at/above which a run counts as successful ("Passing runs"). The
|
|
153
197
|
#: rate is over executed tests, so a run can carry a few failures and still pass.
|
|
154
198
|
#: At 1.0 a run is successful only with zero failures/errors (the strict default of
|
|
@@ -164,6 +208,20 @@ def get_success_threshold() -> float:
|
|
|
164
208
|
)
|
|
165
209
|
|
|
166
210
|
|
|
211
|
+
#: Opt-in bearer token for the Prometheus ``/api/metrics`` endpoint. Secure-by-default:
|
|
212
|
+
#: unset = the endpoint is DISABLED (404). When set, a scrape must send
|
|
213
|
+
#: ``Authorization: Bearer <token>``. Reads the env var, then the cfg key.
|
|
214
|
+
METRICS_TOKEN_ENV = "AIRFLOW_PYTEST_METRICS_TOKEN"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_metrics_token() -> str | None:
|
|
218
|
+
"""The metrics scrape token, or ``None`` when the endpoint should stay disabled."""
|
|
219
|
+
raw = os.environ.get(METRICS_TOKEN_ENV)
|
|
220
|
+
if raw is None or not raw.strip():
|
|
221
|
+
raw = get_conf_value(CONF_SECTION, "metrics_token")
|
|
222
|
+
return raw.strip() if raw and raw.strip() else None
|
|
223
|
+
|
|
224
|
+
|
|
167
225
|
def get_reports_root() -> str:
|
|
168
226
|
"""Resolve the report root directory (absolute path)."""
|
|
169
227
|
env = os.environ.get(ENV_VAR)
|
|
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import io
|
|
20
20
|
import json
|
|
21
21
|
import logging
|
|
22
|
+
import math
|
|
22
23
|
import os
|
|
23
24
|
import shutil
|
|
24
25
|
import threading
|
|
@@ -101,7 +102,17 @@ class FileSystemReportSource(ReportSource):
|
|
|
101
102
|
if summary is not None:
|
|
102
103
|
out.append(summary)
|
|
103
104
|
# Newest first: ISO-8601 created_at sorts chronologically; missing sorts last.
|
|
104
|
-
|
|
105
|
+
# Tiebreak deterministically (try_number, run_id, map_index) so that on equal or
|
|
106
|
+
# missing created_at a stable "latest" run is chosen (e.g. a retry wins its try).
|
|
107
|
+
out.sort(
|
|
108
|
+
key=lambda s: (
|
|
109
|
+
s.created_at or "",
|
|
110
|
+
s.ref.try_number,
|
|
111
|
+
s.ref.run_id,
|
|
112
|
+
s.ref.map_index,
|
|
113
|
+
),
|
|
114
|
+
reverse=True,
|
|
115
|
+
)
|
|
105
116
|
return out
|
|
106
117
|
|
|
107
118
|
def _all_summaries(self) -> list[ReportSummary]:
|
|
@@ -206,6 +217,10 @@ class FileSystemReportSource(ReportSource):
|
|
|
206
217
|
if len(row) > 2 and isinstance(row[2], int | float)
|
|
207
218
|
else 0.0
|
|
208
219
|
)
|
|
220
|
+
# Guard NaN/Infinity from a corrupt meta so they can't reach a
|
|
221
|
+
# JSON response (json.dumps would emit non-spec NaN/Infinity).
|
|
222
|
+
if not math.isfinite(dur):
|
|
223
|
+
dur = 0.0
|
|
209
224
|
out[str(row[0])] = {"outcome": str(row[1]), "duration": dur}
|
|
210
225
|
return out
|
|
211
226
|
# Older archive without the per-test map: parse junit.xml on demand.
|
|
@@ -217,12 +232,13 @@ class FileSystemReportSource(ReportSource):
|
|
|
217
232
|
except Exception:
|
|
218
233
|
_log.exception("Failed to parse JUnit report %s", report_path)
|
|
219
234
|
return None
|
|
235
|
+
|
|
236
|
+
def _dur(c: Any) -> float:
|
|
237
|
+
d = float(getattr(c, "time", 0.0) or 0.0)
|
|
238
|
+
return d if math.isfinite(d) else 0.0
|
|
239
|
+
|
|
220
240
|
return {
|
|
221
|
-
c.node_id: {
|
|
222
|
-
"outcome": c.outcome,
|
|
223
|
-
"duration": float(getattr(c, "time", 0.0) or 0.0),
|
|
224
|
-
}
|
|
225
|
-
for c in result.cases
|
|
241
|
+
c.node_id: {"outcome": c.outcome, "duration": _dur(c)} for c in result.cases
|
|
226
242
|
}
|
|
227
243
|
|
|
228
244
|
def delete(self, ref: ReportRef) -> bool:
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Copyright 2026 the airflow-pytest-plugin contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Failures route — failed/errored cases across the visible runs, flat or clustered."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends
|
|
23
|
+
from fastapi.responses import JSONResponse
|
|
24
|
+
|
|
25
|
+
from .common import RouteDeps, ok
|
|
26
|
+
|
|
27
|
+
TAG = "failures"
|
|
28
|
+
|
|
29
|
+
#: Upper bound on failed cases returned, to keep the payload bounded.
|
|
30
|
+
_FAILURES_CAP = 5000
|
|
31
|
+
#: Cap on failing tests listed under a single cluster (the count is still exact).
|
|
32
|
+
_CLUSTER_TESTS_CAP = 200
|
|
33
|
+
|
|
34
|
+
# Volatile bits scrubbed from an error message so messages that differ only by run
|
|
35
|
+
# specifics collapse to one signature. Order matters: UUID before the generic digit
|
|
36
|
+
# rule (a UUID is mostly digits), hex addresses before digits too.
|
|
37
|
+
_RE_UUID = re.compile(r"[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}")
|
|
38
|
+
_RE_HEX = re.compile(r"0x[0-9a-fA-F]+")
|
|
39
|
+
_RE_NUM = re.compile(r"\d+")
|
|
40
|
+
_RE_WS = re.compile(r"\s+")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _error_line(message: str | None) -> str:
|
|
44
|
+
"""First meaningful line of a case message (skips our ``--- section ---`` headers)."""
|
|
45
|
+
for line in (message or "").splitlines():
|
|
46
|
+
stripped = line.strip()
|
|
47
|
+
if stripped and not stripped.startswith("---"):
|
|
48
|
+
return stripped
|
|
49
|
+
return (message or "").strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def normalize_error(message: str | None) -> str:
|
|
53
|
+
"""A stable signature for an error message: its first line with volatile bits masked.
|
|
54
|
+
|
|
55
|
+
Strips UUIDs, hex addresses and numbers (and collapses whitespace) so that
|
|
56
|
+
``expected 5 got 7`` and ``expected 8 got 3`` share the signature
|
|
57
|
+
``expected N got N``. Capped to keep signatures comparable and bounded.
|
|
58
|
+
"""
|
|
59
|
+
line = _error_line(message)
|
|
60
|
+
line = _RE_UUID.sub("UUID", line)
|
|
61
|
+
line = _RE_HEX.sub(
|
|
62
|
+
"ADDR", line
|
|
63
|
+
) # digit-free placeholder so the number rule won't touch it
|
|
64
|
+
line = _RE_NUM.sub("N", line)
|
|
65
|
+
return _RE_WS.sub(" ", line).strip()[:200]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def cluster_failures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
69
|
+
"""Group failing cases by normalized message (pure), most common cluster first.
|
|
70
|
+
|
|
71
|
+
Each cluster carries the ``signature``, an exact ``count``, the ``outcomes`` seen,
|
|
72
|
+
a representative raw ``sample`` line, and the (capped) list of failing ``tests``.
|
|
73
|
+
"""
|
|
74
|
+
clusters: dict[str, dict[str, Any]] = {}
|
|
75
|
+
for it in items:
|
|
76
|
+
sig = normalize_error(it.get("message"))
|
|
77
|
+
c = clusters.get(sig)
|
|
78
|
+
if c is None:
|
|
79
|
+
c = clusters[sig] = {
|
|
80
|
+
"signature": sig,
|
|
81
|
+
"count": 0,
|
|
82
|
+
"sample": _error_line(it.get("message")),
|
|
83
|
+
"outcomes": set(),
|
|
84
|
+
"tests": [],
|
|
85
|
+
}
|
|
86
|
+
c["count"] += 1
|
|
87
|
+
c["outcomes"].add(it["outcome"])
|
|
88
|
+
if len(c["tests"]) < _CLUSTER_TESTS_CAP:
|
|
89
|
+
c["tests"].append(
|
|
90
|
+
{
|
|
91
|
+
k: it[k]
|
|
92
|
+
for k in (
|
|
93
|
+
"id",
|
|
94
|
+
"dag_id",
|
|
95
|
+
"task_id",
|
|
96
|
+
"run_id",
|
|
97
|
+
"created_at",
|
|
98
|
+
"node_id",
|
|
99
|
+
"outcome",
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
out = [{**c, "outcomes": sorted(c["outcomes"])} for c in clusters.values()]
|
|
104
|
+
out.sort(key=lambda c: (-c["count"], c["signature"]))
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _collect_failures(
|
|
109
|
+
deps: RouteDeps,
|
|
110
|
+
user: Any,
|
|
111
|
+
*,
|
|
112
|
+
dag_id: str | None,
|
|
113
|
+
run_id: str | None,
|
|
114
|
+
task_q: str,
|
|
115
|
+
latest: bool,
|
|
116
|
+
with_message: bool,
|
|
117
|
+
) -> tuple[list[dict[str, Any]], bool]:
|
|
118
|
+
"""Collect failed/errored cases across readable runs (newest first).
|
|
119
|
+
|
|
120
|
+
When ``latest`` (the default for the views), only each **dag·task's** newest readable
|
|
121
|
+
run is considered — that's the pipeline's current state across all of its run_ids, so
|
|
122
|
+
a fixed test drops off once its next run is green and the list reflects *current*
|
|
123
|
+
breakage rather than every failure ever archived (older run_ids are history). Runs are
|
|
124
|
+
deduped per (dag_id, task_id); a retry wins its run via the scan's created_at/
|
|
125
|
+
try_number ordering. ``latest=False`` walks the full history. Returns ``(items, capped)``.
|
|
126
|
+
"""
|
|
127
|
+
src, read_auth = deps.src, deps.read_auth
|
|
128
|
+
items: list[dict[str, Any]] = []
|
|
129
|
+
seen: set[tuple[str, str]] = set()
|
|
130
|
+
capped = False
|
|
131
|
+
for s in src.list_summaries(dag_id=dag_id, run_id=run_id): # newest first
|
|
132
|
+
if task_q and task_q not in s.ref.task_id.lower():
|
|
133
|
+
continue
|
|
134
|
+
if not read_auth(s.ref.dag_id, user):
|
|
135
|
+
continue
|
|
136
|
+
key = (s.ref.dag_id, s.ref.task_id)
|
|
137
|
+
if latest:
|
|
138
|
+
if key in seen: # an older run of a group whose newest we already passed
|
|
139
|
+
continue
|
|
140
|
+
seen.add(
|
|
141
|
+
key
|
|
142
|
+
) # mark before the green check, so a green newest hides old fails
|
|
143
|
+
if (s.failed + s.errors) <= 0:
|
|
144
|
+
continue
|
|
145
|
+
detail = src.get_detail(s.ref)
|
|
146
|
+
if detail is None:
|
|
147
|
+
continue
|
|
148
|
+
for c in detail.cases:
|
|
149
|
+
if c.outcome not in ("failed", "error"):
|
|
150
|
+
continue
|
|
151
|
+
item = {
|
|
152
|
+
"id": s.ref.token,
|
|
153
|
+
"dag_id": s.ref.dag_id,
|
|
154
|
+
"task_id": s.ref.task_id,
|
|
155
|
+
"run_id": s.ref.run_id,
|
|
156
|
+
"created_at": s.created_at,
|
|
157
|
+
"node_id": c.node_id,
|
|
158
|
+
"outcome": c.outcome,
|
|
159
|
+
}
|
|
160
|
+
if with_message:
|
|
161
|
+
item["message"] = c.message
|
|
162
|
+
items.append(item)
|
|
163
|
+
if len(items) >= _FAILURES_CAP: # stop mid-run, don't over-read one big run
|
|
164
|
+
capped = True
|
|
165
|
+
break
|
|
166
|
+
if capped:
|
|
167
|
+
break
|
|
168
|
+
return items[:_FAILURES_CAP], capped
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_router(deps: RouteDeps) -> APIRouter:
|
|
172
|
+
"""Routes tagged ``failures``."""
|
|
173
|
+
router = APIRouter(tags=[TAG])
|
|
174
|
+
user_dep = deps.user_dep
|
|
175
|
+
|
|
176
|
+
@router.get(
|
|
177
|
+
"/api/failures",
|
|
178
|
+
summary="Failed cases across runs",
|
|
179
|
+
responses=ok(
|
|
180
|
+
{
|
|
181
|
+
"failures": [
|
|
182
|
+
{
|
|
183
|
+
"id": "YXBpX2dhdGV3YXl8...",
|
|
184
|
+
"dag_id": "api_gateway",
|
|
185
|
+
"task_id": "integration_tests",
|
|
186
|
+
"run_id": "scheduled__2026-06-20",
|
|
187
|
+
"created_at": "2026-06-20T07:00:00+00:00",
|
|
188
|
+
"node_id": "tests/api.py::test_auth",
|
|
189
|
+
"outcome": "failed",
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
"total": 1,
|
|
193
|
+
"capped": False,
|
|
194
|
+
}
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
def failures(
|
|
198
|
+
dag_id: str | None = None,
|
|
199
|
+
run_id: str | None = None,
|
|
200
|
+
task_id: str | None = None,
|
|
201
|
+
latest: bool = True,
|
|
202
|
+
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
203
|
+
) -> JSONResponse:
|
|
204
|
+
"""Currently-failing cases (one flat list), newest run first.
|
|
205
|
+
|
|
206
|
+
By default only each dag·task's **latest** readable run is considered, so a
|
|
207
|
+
fixed test drops off once its next run is green — the list reflects what's broken
|
|
208
|
+
*now* and shrinks as code improves. Pass ``latest=0`` for the full history.
|
|
209
|
+
Filters mirror the run list (``dag_id`` / ``run_id`` / ``task_id``); RBAC; capped
|
|
210
|
+
at ``5000`` (``capped`` flags truncation).
|
|
211
|
+
"""
|
|
212
|
+
items, capped = _collect_failures(
|
|
213
|
+
deps,
|
|
214
|
+
user,
|
|
215
|
+
dag_id=dag_id,
|
|
216
|
+
run_id=run_id,
|
|
217
|
+
task_q=(task_id or "").lower(),
|
|
218
|
+
latest=latest,
|
|
219
|
+
with_message=False,
|
|
220
|
+
)
|
|
221
|
+
return JSONResponse({"failures": items, "total": len(items), "capped": capped})
|
|
222
|
+
|
|
223
|
+
@router.get(
|
|
224
|
+
"/api/failure-clusters",
|
|
225
|
+
summary="Failures grouped by error",
|
|
226
|
+
responses=ok(
|
|
227
|
+
{
|
|
228
|
+
"clusters": [
|
|
229
|
+
{
|
|
230
|
+
"signature": "AssertionError: expected N got N",
|
|
231
|
+
"count": 12,
|
|
232
|
+
"sample": "AssertionError: expected 5 got 7",
|
|
233
|
+
"outcomes": ["failed"],
|
|
234
|
+
"tests": [
|
|
235
|
+
{
|
|
236
|
+
"id": "YXBpX2dhdGV3YXl8...",
|
|
237
|
+
"dag_id": "api_gateway",
|
|
238
|
+
"task_id": "integration_tests",
|
|
239
|
+
"run_id": "scheduled__2026-06-20",
|
|
240
|
+
"created_at": "2026-06-20T07:00:00+00:00",
|
|
241
|
+
"node_id": "tests/api.py::test_auth",
|
|
242
|
+
"outcome": "failed",
|
|
243
|
+
}
|
|
244
|
+
],
|
|
245
|
+
}
|
|
246
|
+
],
|
|
247
|
+
"total": 12,
|
|
248
|
+
"capped": False,
|
|
249
|
+
}
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
def failure_clusters(
|
|
253
|
+
dag_id: str | None = None,
|
|
254
|
+
run_id: str | None = None,
|
|
255
|
+
task_id: str | None = None,
|
|
256
|
+
latest: bool = True,
|
|
257
|
+
user: Any = Depends(user_dep), # noqa: B008 - FastAPI dependency idiom
|
|
258
|
+
) -> JSONResponse:
|
|
259
|
+
"""Currently-failing cases grouped by a normalized error signature, biggest first.
|
|
260
|
+
|
|
261
|
+
Same scan/filters/RBAC/cap as ``/api/failures``, and likewise defaults to each
|
|
262
|
+
dag·task's **latest** run (``latest=0`` for full history) — so clusters reflect
|
|
263
|
+
what's broken now and shrink as tests are fixed. Instead of a flat list it
|
|
264
|
+
returns clusters, so common root causes surface instead of per-failure spam; each
|
|
265
|
+
has an exact ``count``, a representative ``sample`` message, and the (capped)
|
|
266
|
+
failing ``tests``. Scope to one ``run_id`` for the in-run view.
|
|
267
|
+
"""
|
|
268
|
+
items, capped = _collect_failures(
|
|
269
|
+
deps,
|
|
270
|
+
user,
|
|
271
|
+
dag_id=dag_id,
|
|
272
|
+
run_id=run_id,
|
|
273
|
+
task_q=(task_id or "").lower(),
|
|
274
|
+
latest=latest,
|
|
275
|
+
with_message=True,
|
|
276
|
+
)
|
|
277
|
+
return JSONResponse(
|
|
278
|
+
{"clusters": cluster_failures(items), "total": len(items), "capped": capped}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return router
|