rowmonitor-airflow 0.1.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.
- rowmonitor_airflow-0.1.0/LICENSE +4 -0
- rowmonitor_airflow-0.1.0/PKG-INFO +173 -0
- rowmonitor_airflow-0.1.0/README.md +146 -0
- rowmonitor_airflow-0.1.0/pyproject.toml +43 -0
- rowmonitor_airflow-0.1.0/setup.cfg +4 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/__init__.py +3 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/client.py +60 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/collect.py +36 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/heartbeat.py +84 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/listener.py +72 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/metadata.py +131 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow/plugin.py +21 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/PKG-INFO +173 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/SOURCES.txt +20 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/dependency_links.txt +1 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/entry_points.txt +2 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/requires.txt +3 -0
- rowmonitor_airflow-0.1.0/src/rowmonitor_airflow.egg-info/top_level.txt +1 -0
- rowmonitor_airflow-0.1.0/tests/test_airflow_install.py +31 -0
- rowmonitor_airflow-0.1.0/tests/test_client.py +23 -0
- rowmonitor_airflow-0.1.0/tests/test_listener.py +39 -0
- rowmonitor_airflow-0.1.0/tests/test_metadata.py +68 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rowmonitor-airflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Beta Airflow listener that sends DAG run metadata to Row Monitor.
|
|
5
|
+
Author: Row Monitor
|
|
6
|
+
License-Expression: LicenseRef-RowMonitor-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://rowmonitor.com/help/airflow
|
|
8
|
+
Project-URL: Repository, https://github.com/kharipara/row-monitor
|
|
9
|
+
Project-URL: Documentation, https://rowmonitor.com/help/airflow
|
|
10
|
+
Keywords: airflow,rowmonitor,monitoring
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Framework :: Apache Airflow
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# rowmonitor-airflow
|
|
29
|
+
|
|
30
|
+
Beta listener for Airflow 2.6+ and 3.x. It sends DAG run metadata and a heartbeat of unpaused DAG schedules to [Row Monitor](https://rowmonitor.com/help/airflow).
|
|
31
|
+
|
|
32
|
+
Version `0.1.0`. Not published yet. Not a general-availability release.
|
|
33
|
+
|
|
34
|
+
Late runs are flagged within about 15 minutes. Row Monitor's check runs every 15 minutes. A run is late when it has not started within 5 minutes of the expected time.
|
|
35
|
+
|
|
36
|
+
## What is sent
|
|
37
|
+
|
|
38
|
+
Metadata only:
|
|
39
|
+
|
|
40
|
+
- DAG id, run id, run type, logical date, start, end, state, duration
|
|
41
|
+
- On task failure: DAG id, run id, task id, try number, start, end. Not the exception text
|
|
42
|
+
- Heartbeat: unpaused DAG id, schedule, next run time when the scheduler has stored one
|
|
43
|
+
|
|
44
|
+
Not sent: connections, variables, XComs, logs, DAG source, or row data.
|
|
45
|
+
|
|
46
|
+
The HTTP call runs on a daemon thread with a short timeout (default 2 seconds). A failure does not fail the Airflow task.
|
|
47
|
+
|
|
48
|
+
## Why the heartbeat is a thread
|
|
49
|
+
|
|
50
|
+
The thread starts when the scheduler job starts (`on_starting`, and only in the scheduler process). It posts about once a minute.
|
|
51
|
+
|
|
52
|
+
A small heartbeat DAG would wait for a free worker slot, and it would go silent if that DAG were paused, while every other DAG kept running. The thread still sends when slots are full, and it stops when the scheduler process stops. Row Monitor then opens one "heartbeat missing" issue after 15 minutes of silence, and it does not open a late or never-arrived issue per DAG for that outage.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
The GitHub repository may be private. A plain git URL then fails. Do not commit a token.
|
|
57
|
+
|
|
58
|
+
From a checkout of this repo (works for a private checkout you already have):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install plugins/rowmonitor-airflow
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
From git, when the repo is reachable:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "git+https://github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Private git, token only in the environment:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install "git+https://${GITHUB_TOKEN}@github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Wheel, built from the checkout. CI also uploads this wheel as an artifact. It is not published.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m pip install build
|
|
80
|
+
python -m build plugins/rowmonitor-airflow
|
|
81
|
+
pip install plugins/rowmonitor-airflow/dist/rowmonitor_airflow-0.1.0-py3-none-any.whl
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Install into the scheduler environment and every worker environment, then restart those processes.
|
|
85
|
+
|
|
86
|
+
Airflow 2.6 through 2.10 and Airflow 3.x load `airflow.plugins`. Task-failure hook arguments differ (2.10 has `error` and `session`, earlier 2.x has `session`, Airflow 3 drops `session`). The listener accepts those shapes.
|
|
87
|
+
|
|
88
|
+
## Environment
|
|
89
|
+
|
|
90
|
+
| Variable | Required | Default | Purpose |
|
|
91
|
+
| --- | --- | --- | --- |
|
|
92
|
+
| `ROWMONITOR_URL` | yes | | Row Monitor origin, no path. Use the preview URL while testing. |
|
|
93
|
+
| `ROWMONITOR_TOKEN` | yes | | Ingest token shown once when the deployment is created. |
|
|
94
|
+
| `ROWMONITOR_HEARTBEAT` | no | `scheduler` | `scheduler`, `always`, or `off`. |
|
|
95
|
+
| `ROWMONITOR_HEARTBEAT_SECONDS` | no | `60` | Minimum 30. |
|
|
96
|
+
| `ROWMONITOR_TIMEOUT_SECONDS` | no | `2` | Capped at 5. |
|
|
97
|
+
|
|
98
|
+
In the app: Airflow → create a deployment → copy the token into `ROWMONITOR_TOKEN`.
|
|
99
|
+
|
|
100
|
+
## Local Docker test
|
|
101
|
+
|
|
102
|
+
One compose file, `examples/docker/docker-compose.yml`. It is a sample, not a production Airflow. Run steps, the 3.x image, and cleanup are in `examples/docker/README.md`.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd plugins/rowmonitor-airflow/examples/docker
|
|
106
|
+
cp .env.example .env
|
|
107
|
+
# Edit ROWMONITOR_TOKEN. Never commit the real token.
|
|
108
|
+
# .env.example sets ROWMONITOR_URL=https://rowmonitor.com
|
|
109
|
+
|
|
110
|
+
# Airflow 2.10.5. UI on http://localhost:8080 when standalone is ready.
|
|
111
|
+
docker compose up
|
|
112
|
+
|
|
113
|
+
# Airflow 3.4.0. Same port. airflow standalone also starts the dag-processor and api-server.
|
|
114
|
+
AIRFLOW_IMAGE=apache/airflow:3.4.0 docker compose up
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Airflow 2.10.5 was tested by a user end to end against https://rowmonitor.com with this compose file. All checks passed. Airflow 3.4.0 is covered by the plugin unit tests. It was not part of that end-to-end run. Run one image at a time, and `docker compose down -v` before switching so the database is empty.
|
|
118
|
+
|
|
119
|
+
The container copies the read-only checkout to `/tmp` and runs `pip install` there, then `airflow standalone`. The admin user is `admin`. The password is printed in the service logs. Sample DAGs are mounted from `examples/dags/`:
|
|
120
|
+
|
|
121
|
+
| DAG | What it does | What to expect after the check |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `rowmonitor_failed` | Raises every 2 minutes | FAILED |
|
|
124
|
+
| `rowmonitor_late` | `max_active_runs=1`, sleeps 8 minutes, catchup on for a few minutes | LATE once the blocked run starts (start is more than 5 minutes after the logical date) |
|
|
125
|
+
| `rowmonitor_slow` | Five 20-second runs, then a 15-minute run | SLOW after the long run succeeds. Needs the five successes first |
|
|
126
|
+
| `rowmonitor_never_arrived` | `catchup=False`, one run sleeps 40 minutes, schedule every minute | NEVER_ARRIVED for minutes the scheduler did not create. The heartbeat keeps listing the DAG |
|
|
127
|
+
|
|
128
|
+
Pausing a DAG removes it from the unpaused heartbeat. That is not the never-arrived test.
|
|
129
|
+
|
|
130
|
+
Heartbeat missing: from `examples/docker`, `docker compose stop airflow` for at least 15 minutes. Leave the deployment token in place, then run the check. You should see one heartbeat-missing issue, not a late issue for every DAG. Cleanup is `docker compose down -v`.
|
|
131
|
+
|
|
132
|
+
### How long to wait
|
|
133
|
+
|
|
134
|
+
The production cron is every 15 minutes and starts only after this change is merged. Preview deployments do not run that schedule by themselves.
|
|
135
|
+
|
|
136
|
+
After the DAG has done the thing you care about, trigger the check if `CRON_SECRET` is set on the preview:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
curl -sS -X POST \
|
|
140
|
+
-H "Authorization: Bearer $CRON_SECRET" \
|
|
141
|
+
"https://YOUR-PREVIEW.vercel.app/api/cron/airflow"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
GET works the same way. Do not print the secret. If the preview has no `CRON_SECRET`, the route returns 401 and the check has to wait until production cron, or be run against a local app that has the secret.
|
|
145
|
+
|
|
146
|
+
Rough waits before that manual check is worth calling:
|
|
147
|
+
|
|
148
|
+
- Failed: the first `rowmonitor_failed` run, usually under 2 minutes, then the check
|
|
149
|
+
- Late: about 8 minutes for the sleep, then the check
|
|
150
|
+
- Never arrived: about 6 minutes after the blocker starts (the 5-minute grace), then the check
|
|
151
|
+
- Slow: about 6 short minutes plus the 15-minute sleep, then the check
|
|
152
|
+
- Heartbeat missing: 15 minutes with the scheduler stopped, then the check
|
|
153
|
+
|
|
154
|
+
In the app, open Airflow. You should see the deployment's DAGs, last run, state, duration, next expected time, and open issues. Those times, and the alert emails and Slack messages, use the workspace time zone. It starts as the browser's zone at signup. The owner can change it in Settings, and daylight saving is included. Airflow schedules are still evaluated the way Airflow defines them. The time zone does not change detection. Over the Free limit (5 DAGs) the extra DAGs stay listed as over the limit and are not checked.
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
cd plugins/rowmonitor-airflow
|
|
160
|
+
python -m pip install pytest
|
|
161
|
+
python -m pip install -e .
|
|
162
|
+
python -m pytest -q
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`tests/test_airflow_install.py` runs only when `apache-airflow` is installed in that interpreter. Unit tests do not need Airflow.
|
|
166
|
+
|
|
167
|
+
## Publishing to PyPI later
|
|
168
|
+
|
|
169
|
+
Do not publish from this change. After merge, publish with Actions, not an API token.
|
|
170
|
+
|
|
171
|
+
`.github/workflows/publish-airflow-plugin.yml` is `workflow_dispatch` only. It builds the sdist and wheel, runs `twine check`, and the `publish` job uses `pypa/gh-action-pypi-publish` with OIDC against the `pypi` environment. That workflow name appears in the Actions tab only after the file is on `main`.
|
|
172
|
+
|
|
173
|
+
Until that upload, install from git or from a wheel. Then `pip install rowmonitor-airflow`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# rowmonitor-airflow
|
|
2
|
+
|
|
3
|
+
Beta listener for Airflow 2.6+ and 3.x. It sends DAG run metadata and a heartbeat of unpaused DAG schedules to [Row Monitor](https://rowmonitor.com/help/airflow).
|
|
4
|
+
|
|
5
|
+
Version `0.1.0`. Not published yet. Not a general-availability release.
|
|
6
|
+
|
|
7
|
+
Late runs are flagged within about 15 minutes. Row Monitor's check runs every 15 minutes. A run is late when it has not started within 5 minutes of the expected time.
|
|
8
|
+
|
|
9
|
+
## What is sent
|
|
10
|
+
|
|
11
|
+
Metadata only:
|
|
12
|
+
|
|
13
|
+
- DAG id, run id, run type, logical date, start, end, state, duration
|
|
14
|
+
- On task failure: DAG id, run id, task id, try number, start, end. Not the exception text
|
|
15
|
+
- Heartbeat: unpaused DAG id, schedule, next run time when the scheduler has stored one
|
|
16
|
+
|
|
17
|
+
Not sent: connections, variables, XComs, logs, DAG source, or row data.
|
|
18
|
+
|
|
19
|
+
The HTTP call runs on a daemon thread with a short timeout (default 2 seconds). A failure does not fail the Airflow task.
|
|
20
|
+
|
|
21
|
+
## Why the heartbeat is a thread
|
|
22
|
+
|
|
23
|
+
The thread starts when the scheduler job starts (`on_starting`, and only in the scheduler process). It posts about once a minute.
|
|
24
|
+
|
|
25
|
+
A small heartbeat DAG would wait for a free worker slot, and it would go silent if that DAG were paused, while every other DAG kept running. The thread still sends when slots are full, and it stops when the scheduler process stops. Row Monitor then opens one "heartbeat missing" issue after 15 minutes of silence, and it does not open a late or never-arrived issue per DAG for that outage.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
The GitHub repository may be private. A plain git URL then fails. Do not commit a token.
|
|
30
|
+
|
|
31
|
+
From a checkout of this repo (works for a private checkout you already have):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install plugins/rowmonitor-airflow
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From git, when the repo is reachable:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install "git+https://github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Private git, token only in the environment:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "git+https://${GITHUB_TOKEN}@github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Wheel, built from the checkout. CI also uploads this wheel as an artifact. It is not published.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install build
|
|
53
|
+
python -m build plugins/rowmonitor-airflow
|
|
54
|
+
pip install plugins/rowmonitor-airflow/dist/rowmonitor_airflow-0.1.0-py3-none-any.whl
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Install into the scheduler environment and every worker environment, then restart those processes.
|
|
58
|
+
|
|
59
|
+
Airflow 2.6 through 2.10 and Airflow 3.x load `airflow.plugins`. Task-failure hook arguments differ (2.10 has `error` and `session`, earlier 2.x has `session`, Airflow 3 drops `session`). The listener accepts those shapes.
|
|
60
|
+
|
|
61
|
+
## Environment
|
|
62
|
+
|
|
63
|
+
| Variable | Required | Default | Purpose |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `ROWMONITOR_URL` | yes | | Row Monitor origin, no path. Use the preview URL while testing. |
|
|
66
|
+
| `ROWMONITOR_TOKEN` | yes | | Ingest token shown once when the deployment is created. |
|
|
67
|
+
| `ROWMONITOR_HEARTBEAT` | no | `scheduler` | `scheduler`, `always`, or `off`. |
|
|
68
|
+
| `ROWMONITOR_HEARTBEAT_SECONDS` | no | `60` | Minimum 30. |
|
|
69
|
+
| `ROWMONITOR_TIMEOUT_SECONDS` | no | `2` | Capped at 5. |
|
|
70
|
+
|
|
71
|
+
In the app: Airflow → create a deployment → copy the token into `ROWMONITOR_TOKEN`.
|
|
72
|
+
|
|
73
|
+
## Local Docker test
|
|
74
|
+
|
|
75
|
+
One compose file, `examples/docker/docker-compose.yml`. It is a sample, not a production Airflow. Run steps, the 3.x image, and cleanup are in `examples/docker/README.md`.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd plugins/rowmonitor-airflow/examples/docker
|
|
79
|
+
cp .env.example .env
|
|
80
|
+
# Edit ROWMONITOR_TOKEN. Never commit the real token.
|
|
81
|
+
# .env.example sets ROWMONITOR_URL=https://rowmonitor.com
|
|
82
|
+
|
|
83
|
+
# Airflow 2.10.5. UI on http://localhost:8080 when standalone is ready.
|
|
84
|
+
docker compose up
|
|
85
|
+
|
|
86
|
+
# Airflow 3.4.0. Same port. airflow standalone also starts the dag-processor and api-server.
|
|
87
|
+
AIRFLOW_IMAGE=apache/airflow:3.4.0 docker compose up
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Airflow 2.10.5 was tested by a user end to end against https://rowmonitor.com with this compose file. All checks passed. Airflow 3.4.0 is covered by the plugin unit tests. It was not part of that end-to-end run. Run one image at a time, and `docker compose down -v` before switching so the database is empty.
|
|
91
|
+
|
|
92
|
+
The container copies the read-only checkout to `/tmp` and runs `pip install` there, then `airflow standalone`. The admin user is `admin`. The password is printed in the service logs. Sample DAGs are mounted from `examples/dags/`:
|
|
93
|
+
|
|
94
|
+
| DAG | What it does | What to expect after the check |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| `rowmonitor_failed` | Raises every 2 minutes | FAILED |
|
|
97
|
+
| `rowmonitor_late` | `max_active_runs=1`, sleeps 8 minutes, catchup on for a few minutes | LATE once the blocked run starts (start is more than 5 minutes after the logical date) |
|
|
98
|
+
| `rowmonitor_slow` | Five 20-second runs, then a 15-minute run | SLOW after the long run succeeds. Needs the five successes first |
|
|
99
|
+
| `rowmonitor_never_arrived` | `catchup=False`, one run sleeps 40 minutes, schedule every minute | NEVER_ARRIVED for minutes the scheduler did not create. The heartbeat keeps listing the DAG |
|
|
100
|
+
|
|
101
|
+
Pausing a DAG removes it from the unpaused heartbeat. That is not the never-arrived test.
|
|
102
|
+
|
|
103
|
+
Heartbeat missing: from `examples/docker`, `docker compose stop airflow` for at least 15 minutes. Leave the deployment token in place, then run the check. You should see one heartbeat-missing issue, not a late issue for every DAG. Cleanup is `docker compose down -v`.
|
|
104
|
+
|
|
105
|
+
### How long to wait
|
|
106
|
+
|
|
107
|
+
The production cron is every 15 minutes and starts only after this change is merged. Preview deployments do not run that schedule by themselves.
|
|
108
|
+
|
|
109
|
+
After the DAG has done the thing you care about, trigger the check if `CRON_SECRET` is set on the preview:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl -sS -X POST \
|
|
113
|
+
-H "Authorization: Bearer $CRON_SECRET" \
|
|
114
|
+
"https://YOUR-PREVIEW.vercel.app/api/cron/airflow"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
GET works the same way. Do not print the secret. If the preview has no `CRON_SECRET`, the route returns 401 and the check has to wait until production cron, or be run against a local app that has the secret.
|
|
118
|
+
|
|
119
|
+
Rough waits before that manual check is worth calling:
|
|
120
|
+
|
|
121
|
+
- Failed: the first `rowmonitor_failed` run, usually under 2 minutes, then the check
|
|
122
|
+
- Late: about 8 minutes for the sleep, then the check
|
|
123
|
+
- Never arrived: about 6 minutes after the blocker starts (the 5-minute grace), then the check
|
|
124
|
+
- Slow: about 6 short minutes plus the 15-minute sleep, then the check
|
|
125
|
+
- Heartbeat missing: 15 minutes with the scheduler stopped, then the check
|
|
126
|
+
|
|
127
|
+
In the app, open Airflow. You should see the deployment's DAGs, last run, state, duration, next expected time, and open issues. Those times, and the alert emails and Slack messages, use the workspace time zone. It starts as the browser's zone at signup. The owner can change it in Settings, and daylight saving is included. Airflow schedules are still evaluated the way Airflow defines them. The time zone does not change detection. Over the Free limit (5 DAGs) the extra DAGs stay listed as over the limit and are not checked.
|
|
128
|
+
|
|
129
|
+
## Tests
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
cd plugins/rowmonitor-airflow
|
|
133
|
+
python -m pip install pytest
|
|
134
|
+
python -m pip install -e .
|
|
135
|
+
python -m pytest -q
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`tests/test_airflow_install.py` runs only when `apache-airflow` is installed in that interpreter. Unit tests do not need Airflow.
|
|
139
|
+
|
|
140
|
+
## Publishing to PyPI later
|
|
141
|
+
|
|
142
|
+
Do not publish from this change. After merge, publish with Actions, not an API token.
|
|
143
|
+
|
|
144
|
+
`.github/workflows/publish-airflow-plugin.yml` is `workflow_dispatch` only. It builds the sdist and wheel, runs `twine check`, and the `publish` job uses `pypa/gh-action-pypi-publish` with OIDC against the `pypi` environment. That workflow name appears in the Actions tab only after the file is on `main`.
|
|
145
|
+
|
|
146
|
+
Until that upload, install from git or from a wheel. Then `pip install rowmonitor-airflow`.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rowmonitor-airflow"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Beta Airflow listener that sends DAG run metadata to Row Monitor."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "LicenseRef-RowMonitor-Proprietary"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
authors = [{ name = "Row Monitor" }]
|
|
9
|
+
keywords = ["airflow", "rowmonitor", "monitoring"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.9",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Framework :: Apache Airflow",
|
|
20
|
+
"Topic :: System :: Monitoring",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://rowmonitor.com/help/airflow"
|
|
26
|
+
Repository = "https://github.com/kharipara/row-monitor"
|
|
27
|
+
Documentation = "https://rowmonitor.com/help/airflow"
|
|
28
|
+
|
|
29
|
+
[project.entry-points."airflow.plugins"]
|
|
30
|
+
rowmonitor_airflow = "rowmonitor_airflow.plugin:RowMonitorPlugin"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8"]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["setuptools>=68"]
|
|
37
|
+
build-backend = "setuptools.build_meta"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Non-blocking HTTP. Failures are swallowed. The token is never logged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
|
|
11
|
+
_warned = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config():
|
|
15
|
+
url = os.environ.get("ROWMONITOR_URL", "").strip().rstrip("/")
|
|
16
|
+
token = os.environ.get("ROWMONITOR_TOKEN", "").strip()
|
|
17
|
+
try:
|
|
18
|
+
timeout = float(os.environ.get("ROWMONITOR_TIMEOUT_SECONDS", "2"))
|
|
19
|
+
except ValueError:
|
|
20
|
+
timeout = 2.0
|
|
21
|
+
timeout = min(max(timeout, 0.2), 5.0)
|
|
22
|
+
return url, token, timeout
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def post_json(path, payload, opener=None):
|
|
26
|
+
global _warned
|
|
27
|
+
url, token, timeout = load_config()
|
|
28
|
+
if not url or not token:
|
|
29
|
+
if not _warned:
|
|
30
|
+
_warned = True
|
|
31
|
+
return
|
|
32
|
+
body = json.dumps(payload).encode("utf-8")
|
|
33
|
+
if len(body) > 250_000:
|
|
34
|
+
return
|
|
35
|
+
request = urllib.request.Request(
|
|
36
|
+
url + path,
|
|
37
|
+
data=body,
|
|
38
|
+
headers={
|
|
39
|
+
"Authorization": "Bearer " + token,
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"User-Agent": "rowmonitor-airflow/0.1.0",
|
|
42
|
+
},
|
|
43
|
+
method="POST",
|
|
44
|
+
)
|
|
45
|
+
open_url = opener or urllib.request.urlopen
|
|
46
|
+
try:
|
|
47
|
+
with open_url(request, timeout=timeout) as response:
|
|
48
|
+
response.read(2048)
|
|
49
|
+
except Exception:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def emit_async(path, payload, sender=post_json):
|
|
54
|
+
def _safe():
|
|
55
|
+
try:
|
|
56
|
+
sender(path, payload)
|
|
57
|
+
except Exception:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
threading.Thread(target=_safe, name="rowmonitor-emit", daemon=True).start()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Read unpaused DAG schedules from the Airflow metadata database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def query_unpaused():
|
|
7
|
+
from airflow.models.dag import DagModel
|
|
8
|
+
from airflow.utils.session import create_session
|
|
9
|
+
|
|
10
|
+
rows = []
|
|
11
|
+
with create_session() as session:
|
|
12
|
+
query = session.query(DagModel)
|
|
13
|
+
if hasattr(DagModel, "is_active"):
|
|
14
|
+
query = query.filter(DagModel.is_active.is_(True))
|
|
15
|
+
if hasattr(DagModel, "is_paused"):
|
|
16
|
+
query = query.filter(DagModel.is_paused.is_(False))
|
|
17
|
+
for row in query.all():
|
|
18
|
+
rows.append(
|
|
19
|
+
SimpleDag(
|
|
20
|
+
dag_id=row.dag_id,
|
|
21
|
+
is_paused=bool(getattr(row, "is_paused", False)),
|
|
22
|
+
timetable_description=getattr(row, "timetable_description", None),
|
|
23
|
+
schedule_interval=getattr(row, "schedule_interval", None),
|
|
24
|
+
next_dagrun=getattr(row, "next_dagrun", None),
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
return rows
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SimpleDag:
|
|
31
|
+
def __init__(self, dag_id, is_paused, timetable_description, schedule_interval, next_dagrun):
|
|
32
|
+
self.dag_id = dag_id
|
|
33
|
+
self.is_paused = is_paused
|
|
34
|
+
self.timetable_description = timetable_description
|
|
35
|
+
self.schedule_interval = schedule_interval
|
|
36
|
+
self.next_dagrun = next_dagrun
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Scheduler heartbeat.
|
|
2
|
+
|
|
3
|
+
A daemon thread is started from the scheduler job, not a DAG.
|
|
4
|
+
|
|
5
|
+
A heartbeat DAG would wait for a worker slot and would go silent if that one
|
|
6
|
+
DAG were paused, while the rest of the deployment kept running. The thread
|
|
7
|
+
sends even when task slots are full, and it dies with the scheduler process.
|
|
8
|
+
That is the outage Row Monitor reports as one missing heartbeat. Duplicate
|
|
9
|
+
schedulers send duplicate heartbeats. The ingest API treats that as a state
|
|
10
|
+
update, not a second deployment.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
from rowmonitor_airflow.client import emit_async
|
|
19
|
+
from rowmonitor_airflow.metadata import heartbeat_body
|
|
20
|
+
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
_started = False
|
|
23
|
+
_stop = threading.Event()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def heartbeat_interval():
|
|
27
|
+
try:
|
|
28
|
+
seconds = float(os.environ.get("ROWMONITOR_HEARTBEAT_SECONDS", "60"))
|
|
29
|
+
except ValueError:
|
|
30
|
+
seconds = 60.0
|
|
31
|
+
return max(30.0, seconds)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def heartbeat_enabled():
|
|
35
|
+
mode = os.environ.get("ROWMONITOR_HEARTBEAT", "scheduler").strip().lower()
|
|
36
|
+
return mode not in {"0", "off", "false", "no"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def collect_unpaused():
|
|
40
|
+
from rowmonitor_airflow.collect import query_unpaused
|
|
41
|
+
|
|
42
|
+
return query_unpaused()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def heartbeat_loop(stop_event, collect, send, interval):
|
|
46
|
+
while not stop_event.is_set():
|
|
47
|
+
try:
|
|
48
|
+
rows = collect()
|
|
49
|
+
body = heartbeat_body(rows)
|
|
50
|
+
send("/api/airflow/v1/heartbeat", body)
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
if stop_event.wait(interval):
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def start_heartbeat(collect=None, send=None):
|
|
58
|
+
global _started
|
|
59
|
+
if not heartbeat_enabled():
|
|
60
|
+
return False
|
|
61
|
+
with _lock:
|
|
62
|
+
if _started:
|
|
63
|
+
return False
|
|
64
|
+
_started = True
|
|
65
|
+
threading.Thread(
|
|
66
|
+
target=heartbeat_loop,
|
|
67
|
+
kwargs={
|
|
68
|
+
"stop_event": _stop,
|
|
69
|
+
"collect": collect or collect_unpaused,
|
|
70
|
+
"send": send or emit_async,
|
|
71
|
+
"interval": heartbeat_interval(),
|
|
72
|
+
},
|
|
73
|
+
name="rowmonitor-heartbeat",
|
|
74
|
+
daemon=True,
|
|
75
|
+
).start()
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def reset_heartbeat_for_tests():
|
|
80
|
+
global _started
|
|
81
|
+
_stop.set()
|
|
82
|
+
with _lock:
|
|
83
|
+
_started = False
|
|
84
|
+
_stop.clear()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Listener hooks for Airflow 2.6+ and 3.x.
|
|
2
|
+
|
|
3
|
+
Airflow 2.10 task-failure hooks take (previous_state, task_instance, error, session).
|
|
4
|
+
Earlier 2.x hooks take (previous_state, task_instance, session) and have no error.
|
|
5
|
+
Airflow 3 dropped the session argument and kept error. **kwargs covers that.
|
|
6
|
+
The exception text is not sent. It can contain query text or row values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from rowmonitor_airflow.client import emit_async
|
|
14
|
+
from rowmonitor_airflow.heartbeat import start_heartbeat
|
|
15
|
+
from rowmonitor_airflow.metadata import dag_run_payload, task_failure_payload
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mark(func):
|
|
19
|
+
try:
|
|
20
|
+
from airflow.listeners import hookimpl
|
|
21
|
+
except ImportError:
|
|
22
|
+
return func
|
|
23
|
+
return hookimpl(func)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _guard(fn):
|
|
27
|
+
try:
|
|
28
|
+
fn()
|
|
29
|
+
except Exception:
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _argv_is_scheduler():
|
|
34
|
+
command = " ".join(sys.argv).lower()
|
|
35
|
+
return "scheduler" in command
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _on_dag_run_running(dag_run, msg="", **kwargs):
|
|
39
|
+
_guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "running")))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _on_dag_run_success(dag_run, msg="", **kwargs):
|
|
43
|
+
_guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "success")))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _on_dag_run_failed(dag_run, msg="", **kwargs):
|
|
47
|
+
_guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "failed")))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _on_task_instance_failed(previous_state, task_instance, **kwargs):
|
|
51
|
+
def _send():
|
|
52
|
+
payload = task_failure_payload(task_instance)
|
|
53
|
+
if payload is not None:
|
|
54
|
+
emit_async("/api/airflow/v1/events", payload)
|
|
55
|
+
|
|
56
|
+
_guard(_send)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _on_starting(component=None, **kwargs):
|
|
60
|
+
try:
|
|
61
|
+
name = type(component).__name__.lower() if component is not None else ""
|
|
62
|
+
if "scheduler" in name or _argv_is_scheduler():
|
|
63
|
+
start_heartbeat()
|
|
64
|
+
except Exception:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
on_dag_run_running = _mark(_on_dag_run_running)
|
|
69
|
+
on_dag_run_success = _mark(_on_dag_run_success)
|
|
70
|
+
on_dag_run_failed = _mark(_on_dag_run_failed)
|
|
71
|
+
on_task_instance_failed = _mark(_on_task_instance_failed)
|
|
72
|
+
on_starting = _mark(_on_starting)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Build ingest payloads. The allowlist is the whole contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
DAG_RUN_KEYS = ("type", "dag_id", "run_id", "run_type", "logical_date", "state", "start_date", "end_date", "duration_ms")
|
|
8
|
+
TASK_KEYS = ("type", "dag_id", "run_id", "task_id", "state", "start_date", "end_date", "try_number")
|
|
9
|
+
HEARTBEAT_KEYS = ("dag_id", "schedule", "next_run", "paused")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iso(value):
|
|
13
|
+
if value is None:
|
|
14
|
+
return None
|
|
15
|
+
if isinstance(value, datetime):
|
|
16
|
+
if value.tzinfo is None:
|
|
17
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
18
|
+
return value.isoformat()
|
|
19
|
+
isoformat = getattr(value, "isoformat", None)
|
|
20
|
+
if callable(isoformat):
|
|
21
|
+
try:
|
|
22
|
+
text = isoformat()
|
|
23
|
+
except Exception:
|
|
24
|
+
return None
|
|
25
|
+
if isinstance(text, str) and text and "T" in text:
|
|
26
|
+
if text.endswith("Z") or "+" in text[10:] or "-" in text[10:]:
|
|
27
|
+
return text
|
|
28
|
+
return text + "Z" if "+" not in text else text
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def duration_ms(start, end):
|
|
33
|
+
if start is None or end is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
seconds = (end - start).total_seconds()
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
if seconds < 0:
|
|
40
|
+
return None
|
|
41
|
+
return int(seconds * 1000)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _state(value, fallback):
|
|
45
|
+
raw = getattr(value, "value", value)
|
|
46
|
+
text = str(raw or fallback).lower()
|
|
47
|
+
if "." in text:
|
|
48
|
+
text = text.split(".")[-1]
|
|
49
|
+
return text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def dag_run_payload(dag_run, state):
|
|
53
|
+
logical = getattr(dag_run, "logical_date", None)
|
|
54
|
+
if logical is None:
|
|
55
|
+
logical = getattr(dag_run, "execution_date", None)
|
|
56
|
+
start = getattr(dag_run, "start_date", None)
|
|
57
|
+
end = getattr(dag_run, "end_date", None)
|
|
58
|
+
run_type = getattr(dag_run, "run_type", None)
|
|
59
|
+
if run_type is not None:
|
|
60
|
+
run_type = str(getattr(run_type, "value", run_type))[:64]
|
|
61
|
+
payload = {
|
|
62
|
+
"type": "dag_run",
|
|
63
|
+
"dag_id": str(getattr(dag_run, "dag_id", ""))[:250],
|
|
64
|
+
"run_id": str(getattr(dag_run, "run_id", ""))[:250],
|
|
65
|
+
"run_type": run_type,
|
|
66
|
+
"logical_date": iso(logical),
|
|
67
|
+
"state": _state(state, "running"),
|
|
68
|
+
"start_date": iso(start),
|
|
69
|
+
"end_date": iso(end),
|
|
70
|
+
"duration_ms": duration_ms(start, end),
|
|
71
|
+
}
|
|
72
|
+
return {key: payload[key] for key in DAG_RUN_KEYS}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def task_failure_payload(task_instance):
|
|
76
|
+
dag_id = getattr(task_instance, "dag_id", None)
|
|
77
|
+
task_id = getattr(task_instance, "task_id", None)
|
|
78
|
+
run_id = getattr(task_instance, "run_id", None)
|
|
79
|
+
if not run_id:
|
|
80
|
+
dag_run = getattr(task_instance, "dag_run", None)
|
|
81
|
+
run_id = getattr(dag_run, "run_id", None) if dag_run is not None else None
|
|
82
|
+
if not dag_id or not task_id or not run_id:
|
|
83
|
+
return None
|
|
84
|
+
try_number = getattr(task_instance, "try_number", None)
|
|
85
|
+
if try_number is not None:
|
|
86
|
+
try:
|
|
87
|
+
try_number = int(try_number)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
try_number = None
|
|
90
|
+
payload = {
|
|
91
|
+
"type": "task_run",
|
|
92
|
+
"dag_id": str(dag_id)[:250],
|
|
93
|
+
"run_id": str(run_id)[:250],
|
|
94
|
+
"task_id": str(task_id)[:250],
|
|
95
|
+
"state": "failed",
|
|
96
|
+
"start_date": iso(getattr(task_instance, "start_date", None)),
|
|
97
|
+
"end_date": iso(getattr(task_instance, "end_date", None)),
|
|
98
|
+
"try_number": try_number,
|
|
99
|
+
}
|
|
100
|
+
return {key: payload[key] for key in TASK_KEYS}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def snapshot_dag(row):
|
|
104
|
+
dag_id = getattr(row, "dag_id", None)
|
|
105
|
+
if not dag_id or getattr(row, "is_paused", False):
|
|
106
|
+
return None
|
|
107
|
+
schedule = None
|
|
108
|
+
for attr in ("timetable_description", "schedule_interval", "timetable_summary"):
|
|
109
|
+
value = getattr(row, attr, None)
|
|
110
|
+
if value is None:
|
|
111
|
+
continue
|
|
112
|
+
text = str(value).strip()
|
|
113
|
+
if text and text.lower() not in {"none", "null"}:
|
|
114
|
+
schedule = text[:500]
|
|
115
|
+
break
|
|
116
|
+
return {
|
|
117
|
+
"dag_id": str(dag_id)[:250],
|
|
118
|
+
"schedule": schedule,
|
|
119
|
+
"next_run": iso(getattr(row, "next_dagrun", None)),
|
|
120
|
+
"paused": False,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def heartbeat_body(rows, limit=500):
|
|
125
|
+
dags = []
|
|
126
|
+
for row in rows:
|
|
127
|
+
snap = snapshot_dag(row)
|
|
128
|
+
if snap is not None:
|
|
129
|
+
dags.append({key: snap[key] for key in HEARTBEAT_KEYS})
|
|
130
|
+
truncated = len(dags) > limit
|
|
131
|
+
return {"dags": dags[:limit], "truncated": truncated}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Airflow plugin entry point. Importing this module requires Airflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _load():
|
|
7
|
+
from airflow.plugins_manager import AirflowPlugin
|
|
8
|
+
|
|
9
|
+
from rowmonitor_airflow import listener
|
|
10
|
+
|
|
11
|
+
class RowMonitorPlugin(AirflowPlugin):
|
|
12
|
+
name = "rowmonitor_airflow"
|
|
13
|
+
listeners = [listener]
|
|
14
|
+
|
|
15
|
+
return RowMonitorPlugin
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
RowMonitorPlugin = _load()
|
|
20
|
+
except ImportError:
|
|
21
|
+
RowMonitorPlugin = None # type: ignore[misc, assignment]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rowmonitor-airflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Beta Airflow listener that sends DAG run metadata to Row Monitor.
|
|
5
|
+
Author: Row Monitor
|
|
6
|
+
License-Expression: LicenseRef-RowMonitor-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://rowmonitor.com/help/airflow
|
|
8
|
+
Project-URL: Repository, https://github.com/kharipara/row-monitor
|
|
9
|
+
Project-URL: Documentation, https://rowmonitor.com/help/airflow
|
|
10
|
+
Keywords: airflow,rowmonitor,monitoring
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Framework :: Apache Airflow
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# rowmonitor-airflow
|
|
29
|
+
|
|
30
|
+
Beta listener for Airflow 2.6+ and 3.x. It sends DAG run metadata and a heartbeat of unpaused DAG schedules to [Row Monitor](https://rowmonitor.com/help/airflow).
|
|
31
|
+
|
|
32
|
+
Version `0.1.0`. Not published yet. Not a general-availability release.
|
|
33
|
+
|
|
34
|
+
Late runs are flagged within about 15 minutes. Row Monitor's check runs every 15 minutes. A run is late when it has not started within 5 minutes of the expected time.
|
|
35
|
+
|
|
36
|
+
## What is sent
|
|
37
|
+
|
|
38
|
+
Metadata only:
|
|
39
|
+
|
|
40
|
+
- DAG id, run id, run type, logical date, start, end, state, duration
|
|
41
|
+
- On task failure: DAG id, run id, task id, try number, start, end. Not the exception text
|
|
42
|
+
- Heartbeat: unpaused DAG id, schedule, next run time when the scheduler has stored one
|
|
43
|
+
|
|
44
|
+
Not sent: connections, variables, XComs, logs, DAG source, or row data.
|
|
45
|
+
|
|
46
|
+
The HTTP call runs on a daemon thread with a short timeout (default 2 seconds). A failure does not fail the Airflow task.
|
|
47
|
+
|
|
48
|
+
## Why the heartbeat is a thread
|
|
49
|
+
|
|
50
|
+
The thread starts when the scheduler job starts (`on_starting`, and only in the scheduler process). It posts about once a minute.
|
|
51
|
+
|
|
52
|
+
A small heartbeat DAG would wait for a free worker slot, and it would go silent if that DAG were paused, while every other DAG kept running. The thread still sends when slots are full, and it stops when the scheduler process stops. Row Monitor then opens one "heartbeat missing" issue after 15 minutes of silence, and it does not open a late or never-arrived issue per DAG for that outage.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
The GitHub repository may be private. A plain git URL then fails. Do not commit a token.
|
|
57
|
+
|
|
58
|
+
From a checkout of this repo (works for a private checkout you already have):
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install plugins/rowmonitor-airflow
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
From git, when the repo is reachable:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install "git+https://github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Private git, token only in the environment:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install "git+https://${GITHUB_TOKEN}@github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Wheel, built from the checkout. CI also uploads this wheel as an artifact. It is not published.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python -m pip install build
|
|
80
|
+
python -m build plugins/rowmonitor-airflow
|
|
81
|
+
pip install plugins/rowmonitor-airflow/dist/rowmonitor_airflow-0.1.0-py3-none-any.whl
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Install into the scheduler environment and every worker environment, then restart those processes.
|
|
85
|
+
|
|
86
|
+
Airflow 2.6 through 2.10 and Airflow 3.x load `airflow.plugins`. Task-failure hook arguments differ (2.10 has `error` and `session`, earlier 2.x has `session`, Airflow 3 drops `session`). The listener accepts those shapes.
|
|
87
|
+
|
|
88
|
+
## Environment
|
|
89
|
+
|
|
90
|
+
| Variable | Required | Default | Purpose |
|
|
91
|
+
| --- | --- | --- | --- |
|
|
92
|
+
| `ROWMONITOR_URL` | yes | | Row Monitor origin, no path. Use the preview URL while testing. |
|
|
93
|
+
| `ROWMONITOR_TOKEN` | yes | | Ingest token shown once when the deployment is created. |
|
|
94
|
+
| `ROWMONITOR_HEARTBEAT` | no | `scheduler` | `scheduler`, `always`, or `off`. |
|
|
95
|
+
| `ROWMONITOR_HEARTBEAT_SECONDS` | no | `60` | Minimum 30. |
|
|
96
|
+
| `ROWMONITOR_TIMEOUT_SECONDS` | no | `2` | Capped at 5. |
|
|
97
|
+
|
|
98
|
+
In the app: Airflow → create a deployment → copy the token into `ROWMONITOR_TOKEN`.
|
|
99
|
+
|
|
100
|
+
## Local Docker test
|
|
101
|
+
|
|
102
|
+
One compose file, `examples/docker/docker-compose.yml`. It is a sample, not a production Airflow. Run steps, the 3.x image, and cleanup are in `examples/docker/README.md`.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
cd plugins/rowmonitor-airflow/examples/docker
|
|
106
|
+
cp .env.example .env
|
|
107
|
+
# Edit ROWMONITOR_TOKEN. Never commit the real token.
|
|
108
|
+
# .env.example sets ROWMONITOR_URL=https://rowmonitor.com
|
|
109
|
+
|
|
110
|
+
# Airflow 2.10.5. UI on http://localhost:8080 when standalone is ready.
|
|
111
|
+
docker compose up
|
|
112
|
+
|
|
113
|
+
# Airflow 3.4.0. Same port. airflow standalone also starts the dag-processor and api-server.
|
|
114
|
+
AIRFLOW_IMAGE=apache/airflow:3.4.0 docker compose up
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Airflow 2.10.5 was tested by a user end to end against https://rowmonitor.com with this compose file. All checks passed. Airflow 3.4.0 is covered by the plugin unit tests. It was not part of that end-to-end run. Run one image at a time, and `docker compose down -v` before switching so the database is empty.
|
|
118
|
+
|
|
119
|
+
The container copies the read-only checkout to `/tmp` and runs `pip install` there, then `airflow standalone`. The admin user is `admin`. The password is printed in the service logs. Sample DAGs are mounted from `examples/dags/`:
|
|
120
|
+
|
|
121
|
+
| DAG | What it does | What to expect after the check |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `rowmonitor_failed` | Raises every 2 minutes | FAILED |
|
|
124
|
+
| `rowmonitor_late` | `max_active_runs=1`, sleeps 8 minutes, catchup on for a few minutes | LATE once the blocked run starts (start is more than 5 minutes after the logical date) |
|
|
125
|
+
| `rowmonitor_slow` | Five 20-second runs, then a 15-minute run | SLOW after the long run succeeds. Needs the five successes first |
|
|
126
|
+
| `rowmonitor_never_arrived` | `catchup=False`, one run sleeps 40 minutes, schedule every minute | NEVER_ARRIVED for minutes the scheduler did not create. The heartbeat keeps listing the DAG |
|
|
127
|
+
|
|
128
|
+
Pausing a DAG removes it from the unpaused heartbeat. That is not the never-arrived test.
|
|
129
|
+
|
|
130
|
+
Heartbeat missing: from `examples/docker`, `docker compose stop airflow` for at least 15 minutes. Leave the deployment token in place, then run the check. You should see one heartbeat-missing issue, not a late issue for every DAG. Cleanup is `docker compose down -v`.
|
|
131
|
+
|
|
132
|
+
### How long to wait
|
|
133
|
+
|
|
134
|
+
The production cron is every 15 minutes and starts only after this change is merged. Preview deployments do not run that schedule by themselves.
|
|
135
|
+
|
|
136
|
+
After the DAG has done the thing you care about, trigger the check if `CRON_SECRET` is set on the preview:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
curl -sS -X POST \
|
|
140
|
+
-H "Authorization: Bearer $CRON_SECRET" \
|
|
141
|
+
"https://YOUR-PREVIEW.vercel.app/api/cron/airflow"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
GET works the same way. Do not print the secret. If the preview has no `CRON_SECRET`, the route returns 401 and the check has to wait until production cron, or be run against a local app that has the secret.
|
|
145
|
+
|
|
146
|
+
Rough waits before that manual check is worth calling:
|
|
147
|
+
|
|
148
|
+
- Failed: the first `rowmonitor_failed` run, usually under 2 minutes, then the check
|
|
149
|
+
- Late: about 8 minutes for the sleep, then the check
|
|
150
|
+
- Never arrived: about 6 minutes after the blocker starts (the 5-minute grace), then the check
|
|
151
|
+
- Slow: about 6 short minutes plus the 15-minute sleep, then the check
|
|
152
|
+
- Heartbeat missing: 15 minutes with the scheduler stopped, then the check
|
|
153
|
+
|
|
154
|
+
In the app, open Airflow. You should see the deployment's DAGs, last run, state, duration, next expected time, and open issues. Those times, and the alert emails and Slack messages, use the workspace time zone. It starts as the browser's zone at signup. The owner can change it in Settings, and daylight saving is included. Airflow schedules are still evaluated the way Airflow defines them. The time zone does not change detection. Over the Free limit (5 DAGs) the extra DAGs stay listed as over the limit and are not checked.
|
|
155
|
+
|
|
156
|
+
## Tests
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
cd plugins/rowmonitor-airflow
|
|
160
|
+
python -m pip install pytest
|
|
161
|
+
python -m pip install -e .
|
|
162
|
+
python -m pytest -q
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`tests/test_airflow_install.py` runs only when `apache-airflow` is installed in that interpreter. Unit tests do not need Airflow.
|
|
166
|
+
|
|
167
|
+
## Publishing to PyPI later
|
|
168
|
+
|
|
169
|
+
Do not publish from this change. After merge, publish with Actions, not an API token.
|
|
170
|
+
|
|
171
|
+
`.github/workflows/publish-airflow-plugin.yml` is `workflow_dispatch` only. It builds the sdist and wheel, runs `twine check`, and the `publish` job uses `pypa/gh-action-pypi-publish` with OIDC against the `pypi` environment. That workflow name appears in the Actions tab only after the file is on `main`.
|
|
172
|
+
|
|
173
|
+
Until that upload, install from git or from a wheel. Then `pip install rowmonitor-airflow`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/rowmonitor_airflow/__init__.py
|
|
5
|
+
src/rowmonitor_airflow/client.py
|
|
6
|
+
src/rowmonitor_airflow/collect.py
|
|
7
|
+
src/rowmonitor_airflow/heartbeat.py
|
|
8
|
+
src/rowmonitor_airflow/listener.py
|
|
9
|
+
src/rowmonitor_airflow/metadata.py
|
|
10
|
+
src/rowmonitor_airflow/plugin.py
|
|
11
|
+
src/rowmonitor_airflow.egg-info/PKG-INFO
|
|
12
|
+
src/rowmonitor_airflow.egg-info/SOURCES.txt
|
|
13
|
+
src/rowmonitor_airflow.egg-info/dependency_links.txt
|
|
14
|
+
src/rowmonitor_airflow.egg-info/entry_points.txt
|
|
15
|
+
src/rowmonitor_airflow.egg-info/requires.txt
|
|
16
|
+
src/rowmonitor_airflow.egg-info/top_level.txt
|
|
17
|
+
tests/test_airflow_install.py
|
|
18
|
+
tests/test_client.py
|
|
19
|
+
tests/test_listener.py
|
|
20
|
+
tests/test_metadata.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rowmonitor_airflow
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Runs only when apache-airflow is installed in this interpreter."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
pytest.importorskip("airflow")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_listener_registers_with_this_airflow():
|
|
9
|
+
import airflow
|
|
10
|
+
from airflow.plugins_manager import AirflowPlugin
|
|
11
|
+
|
|
12
|
+
from rowmonitor_airflow.plugin import RowMonitorPlugin
|
|
13
|
+
|
|
14
|
+
assert issubclass(RowMonitorPlugin, AirflowPlugin)
|
|
15
|
+
assert RowMonitorPlugin.listeners
|
|
16
|
+
hooks = [
|
|
17
|
+
"on_dag_run_running",
|
|
18
|
+
"on_dag_run_success",
|
|
19
|
+
"on_dag_run_failed",
|
|
20
|
+
"on_task_instance_failed",
|
|
21
|
+
"on_starting",
|
|
22
|
+
]
|
|
23
|
+
listener = RowMonitorPlugin.listeners[0]
|
|
24
|
+
for name in hooks:
|
|
25
|
+
assert callable(getattr(listener, name))
|
|
26
|
+
# Importing the listener manager proves the hook specs load on this version.
|
|
27
|
+
from airflow.listeners.listener import get_listener_manager
|
|
28
|
+
|
|
29
|
+
manager = get_listener_manager()
|
|
30
|
+
manager.add_listener(listener)
|
|
31
|
+
assert airflow.__version__
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
from rowmonitor_airflow.client import emit_async, post_json
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_post_swallows_errors(monkeypatch):
|
|
7
|
+
monkeypatch.setenv("ROWMONITOR_URL", "https://example.invalid")
|
|
8
|
+
monkeypatch.setenv("ROWMONITOR_TOKEN", "rm_airflow_test")
|
|
9
|
+
|
|
10
|
+
def boom(request, timeout):
|
|
11
|
+
raise TimeoutError("down")
|
|
12
|
+
|
|
13
|
+
post_json("/api/airflow/v1/events", {"type": "dag_run"}, opener=boom)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_emit_returns_while_the_send_blocks():
|
|
17
|
+
started = time.monotonic()
|
|
18
|
+
|
|
19
|
+
def slow(path, payload):
|
|
20
|
+
time.sleep(0.8)
|
|
21
|
+
|
|
22
|
+
emit_async("/api/airflow/v1/events", {"type": "dag_run"}, sender=slow)
|
|
23
|
+
assert time.monotonic() - started < 0.4
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from rowmonitor_airflow import listener
|
|
2
|
+
from rowmonitor_airflow.metadata import dag_run_payload
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DagRun:
|
|
6
|
+
dag_id = "orders"
|
|
7
|
+
run_id = "manual__1"
|
|
8
|
+
run_type = "manual"
|
|
9
|
+
logical_date = None
|
|
10
|
+
execution_date = None
|
|
11
|
+
start_date = None
|
|
12
|
+
end_date = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Task:
|
|
16
|
+
dag_id = "orders"
|
|
17
|
+
task_id = "load"
|
|
18
|
+
run_id = "manual__1"
|
|
19
|
+
try_number = 1
|
|
20
|
+
start_date = None
|
|
21
|
+
end_date = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_hooks_do_not_raise_and_do_not_forward_the_error(monkeypatch):
|
|
25
|
+
sent = []
|
|
26
|
+
monkeypatch.setattr(listener, "emit_async", lambda path, payload: sent.append((path, payload)))
|
|
27
|
+
listener.on_dag_run_running(DagRun(), msg="queued row 9")
|
|
28
|
+
listener.on_dag_run_success(dag_run=DagRun(), msg="ok", session=object())
|
|
29
|
+
listener.on_dag_run_failed(DagRun(), "failed")
|
|
30
|
+
listener.on_task_instance_failed(None, Task(), error="SELECT secret FROM t", session=object())
|
|
31
|
+
assert [item[0] for item in sent] == ["/api/airflow/v1/events"] * 4
|
|
32
|
+
blob = str(sent)
|
|
33
|
+
assert "secret" not in blob
|
|
34
|
+
assert "queued row" not in blob
|
|
35
|
+
assert sent[0][1]["state"] == dag_run_payload(DagRun(), "running")["state"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_starting_a_non_scheduler_does_not_raise():
|
|
39
|
+
listener.on_starting(component=object())
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
from rowmonitor_airflow.metadata import (
|
|
4
|
+
DAG_RUN_KEYS,
|
|
5
|
+
HEARTBEAT_KEYS,
|
|
6
|
+
TASK_KEYS,
|
|
7
|
+
dag_run_payload,
|
|
8
|
+
heartbeat_body,
|
|
9
|
+
snapshot_dag,
|
|
10
|
+
task_failure_payload,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DagRun:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.dag_id = "orders"
|
|
17
|
+
self.run_id = "scheduled__2026-11-12T05:00:00+00:00"
|
|
18
|
+
self.run_type = "scheduled"
|
|
19
|
+
self.logical_date = datetime(2026, 11, 12, 5, tzinfo=timezone.utc)
|
|
20
|
+
self.start_date = self.logical_date
|
|
21
|
+
self.end_date = self.logical_date + timedelta(minutes=2)
|
|
22
|
+
self.conf = {"password": "secret"}
|
|
23
|
+
self.note = "row 42"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Task:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.dag_id = "orders"
|
|
29
|
+
self.task_id = "load"
|
|
30
|
+
self.run_id = "scheduled__2026-11-12T05:00:00+00:00"
|
|
31
|
+
self.try_number = 2
|
|
32
|
+
self.start_date = datetime(2026, 11, 12, 5, tzinfo=timezone.utc)
|
|
33
|
+
self.end_date = None
|
|
34
|
+
self.log = "SELECT secret FROM customers"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_dag_run_payload_is_metadata_only():
|
|
38
|
+
payload = dag_run_payload(DagRun(), "success")
|
|
39
|
+
assert set(payload) == set(DAG_RUN_KEYS)
|
|
40
|
+
assert payload["state"] == "success"
|
|
41
|
+
assert payload["duration_ms"] == 120_000
|
|
42
|
+
blob = str(payload)
|
|
43
|
+
assert "secret" not in blob
|
|
44
|
+
assert "customers" not in blob
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_task_failure_does_not_include_the_exception_or_log():
|
|
48
|
+
payload = task_failure_payload(Task())
|
|
49
|
+
assert set(payload) == set(TASK_KEYS)
|
|
50
|
+
assert "secret" not in str(payload)
|
|
51
|
+
assert payload["state"] == "failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_heartbeat_skips_paused_and_caps_the_list():
|
|
55
|
+
class Row:
|
|
56
|
+
def __init__(self, dag_id, paused):
|
|
57
|
+
self.dag_id = dag_id
|
|
58
|
+
self.is_paused = paused
|
|
59
|
+
self.timetable_description = "0 * * * *"
|
|
60
|
+
self.next_dagrun = datetime(2026, 11, 12, 6, tzinfo=timezone.utc)
|
|
61
|
+
|
|
62
|
+
rows = [Row(f"dag_{index}", False) for index in range(3)]
|
|
63
|
+
rows.append(Row("paused", True))
|
|
64
|
+
body = heartbeat_body(rows, limit=2)
|
|
65
|
+
assert body["truncated"] is True
|
|
66
|
+
assert len(body["dags"]) == 2
|
|
67
|
+
assert set(body["dags"][0]) == set(HEARTBEAT_KEYS)
|
|
68
|
+
assert snapshot_dag(Row("paused", True)) is None
|