Repository: apache/airflow

Issue:
Add DagProcessor status to health endpoint.
### Description

Add DagProcessor status including latest heartbeat to health endpoint similar to Triggerer status added recently. Related PRs.

https://github.com/apache/airflow/pull/31529
https://github.com/apache/airflow/pull/27755

### Use case/motivation

It helps in dag processor monitoring 

### Related issues

_No response_

### Are you willing to submit a PR?

- [X] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:
@tirkarthi I don't see any downside to adding this information in Healthcheck. However, DagProcessor is not a separate component as opposed to the trigger. If DagProcessor is having some issues it won't affect airflow as a whole. It's probably an issue with a dag and if an instance of airflow is shared with multiple teams, they won't be interested to know if some dag that they don't own has some issues. WDYT?
It's an issue from the perspective that updates to dag or new dags won't be picked up by UI and scheduler till the dag processor is healthy.
On further looking into this I think you can run DagProcessor as a separate process, having this feature would certainly help. 
Ref - https://airflow.apache.org/docs/apache-airflow/stable/authoring-and-scheduling/dagfile-processing.html

Resolving patch:
diff --git a/airflow/api/common/airflow_health.py b/airflow/api/common/airflow_health.py
--- a/airflow/api/common/airflow_health.py
+++ b/airflow/api/common/airflow_health.py
@@ -18,6 +18,7 @@
 
 from typing import Any
 
+from airflow.jobs.dag_processor_job_runner import DagProcessorJobRunner
 from airflow.jobs.scheduler_job_runner import SchedulerJobRunner
 from airflow.jobs.triggerer_job_runner import TriggererJobRunner
 
@@ -30,8 +31,10 @@ def get_airflow_health() -> dict[str, Any]:
     metadatabase_status = HEALTHY
     latest_scheduler_heartbeat = None
     latest_triggerer_heartbeat = None
+    latest_dag_processor_heartbeat = None
     scheduler_status = UNHEALTHY
     triggerer_status: str | None = UNHEALTHY
+    dag_processor_status: str | None = UNHEALTHY
 
     try:
         latest_scheduler_job = SchedulerJobRunner.most_recent_job()
@@ -55,6 +58,18 @@ def get_airflow_health() -> dict[str, Any]:
     except Exception:
         metadatabase_status = UNHEALTHY
 
+    try:
+        latest_dag_processor_job = DagProcessorJobRunner.most_recent_job()
+
+        if latest_dag_processor_job:
+            latest_dag_processor_heartbeat = latest_dag_processor_job.latest_heartbeat.isoformat()
+            if latest_dag_processor_job.is_alive():
+                dag_processor_status = HEALTHY
+        else:
+            dag_processor_status = None
+    except Exception:
+        metadatabase_status = UNHEALTHY
+
     airflow_health_status = {
         "metadatabase": {"status": metadatabase_status},
         "scheduler": {
@@ -65,6 +80,10 @@ def get_airflow_health() -> dict[str, Any]:
             "status": triggerer_status,
             "latest_triggerer_heartbeat": latest_triggerer_heartbeat,
         },
+        "dag_processor": {
+            "status": dag_processor_status,
+            "latest_dag_processor_heartbeat": latest_dag_processor_heartbeat,
+        },
     }
 
     return airflow_health_status
diff --git a/airflow/api_connexion/schemas/health_schema.py b/airflow/api_connexion/schemas/health_schema.py
--- a/airflow/api_connexion/schemas/health_schema.py
+++ b/airflow/api_connexion/schemas/health_schema.py
@@ -41,12 +41,19 @@ class TriggererInfoSchema(BaseInfoSchema):
     latest_triggerer_heartbeat = fields.String(dump_only=True)
 
 
+class DagProcessorInfoSchema(BaseInfoSchema):
+    """Schema for DagProcessor info."""
+
+    latest_dag_processor_heartbeat = fields.String(dump_only=True)
+
+
 class HealthInfoSchema(Schema):
     """Schema for the Health endpoint."""
 
     metadatabase = fields.Nested(MetaDatabaseInfoSchema)
     scheduler = fields.Nested(SchedulerInfoSchema)
     triggerer = fields.Nested(TriggererInfoSchema)
+    dag_processor = fields.Nested(DagProcessorInfoSchema)
 
 
 health_schema = HealthInfoSchema()


Test patch:
diff --git a/tests/api/common/test_airflow_health.py b/tests/api/common/test_airflow_health.py
--- a/tests/api/common/test_airflow_health.py
+++ b/tests/api/common/test_airflow_health.py
@@ -22,6 +22,7 @@
 from airflow.api.common.airflow_health import (
     HEALTHY,
     UNHEALTHY,
+    DagProcessorJobRunner,
     SchedulerJobRunner,
     TriggererJobRunner,
     get_airflow_health,
@@ -31,13 +32,14 @@
 def test_get_airflow_health_only_metadatabase_healthy():
     SchedulerJobRunner.most_recent_job = MagicMock(return_value=None)
     TriggererJobRunner.most_recent_job = MagicMock(return_value=None)
-
+    DagProcessorJobRunner.most_recent_job = MagicMock(return_value=None)
     health_status = get_airflow_health()
 
     expected_status = {
         "metadatabase": {"status": HEALTHY},
         "scheduler": {"status": UNHEALTHY, "latest_scheduler_heartbeat": None},
         "triggerer": {"status": None, "latest_triggerer_heartbeat": None},
+        "dag_processor": {"status": None, "latest_dag_processor_heartbeat": None},
     }
 
     assert health_status == expected_status
@@ -46,6 +48,7 @@ def test_get_airflow_health_only_metadatabase_healthy():
 def test_get_airflow_health_metadatabase_unhealthy():
     SchedulerJobRunner.most_recent_job = MagicMock(side_effect=Exception)
     TriggererJobRunner.most_recent_job = MagicMock(side_effect=Exception)
+    DagProcessorJobRunner.most_recent_job = MagicMock(side_effect=Exception)
 
     health_status = get_airflow_health()
 
@@ -53,6 +56,7 @@ def test_get_airflow_health_metadatabase_unhealthy():
         "metadatabase": {"status": UNHEALTHY},
         "scheduler": {"status": UNHEALTHY, "latest_scheduler_heartbeat": None},
         "triggerer": {"status": UNHEALTHY, "latest_triggerer_heartbeat": None},
+        "dag_processor": {"status": UNHEALTHY, "latest_dag_processor_heartbeat": None},
     }
 
     assert health_status == expected_status
@@ -64,6 +68,7 @@ def test_get_airflow_health_scheduler_healthy_no_triggerer():
     latest_scheduler_job_mock.is_alive = MagicMock(return_value=True)
     SchedulerJobRunner.most_recent_job = MagicMock(return_value=latest_scheduler_job_mock)
     TriggererJobRunner.most_recent_job = MagicMock(return_value=None)
+    DagProcessorJobRunner.most_recent_job = MagicMock(return_value=None)
 
     health_status = get_airflow_health()
 
@@ -74,6 +79,7 @@ def test_get_airflow_health_scheduler_healthy_no_triggerer():
             "latest_scheduler_heartbeat": latest_scheduler_job_mock.latest_heartbeat.isoformat(),
         },
         "triggerer": {"status": None, "latest_triggerer_heartbeat": None},
+        "dag_processor": {"status": None, "latest_dag_processor_heartbeat": None},
     }
 
     assert health_status == expected_status
@@ -83,8 +89,13 @@ def test_get_airflow_health_triggerer_healthy_no_scheduler_job_record():
     latest_triggerer_job_mock = MagicMock()
     latest_triggerer_job_mock.latest_heartbeat = datetime.now()
     latest_triggerer_job_mock.is_alive = MagicMock(return_value=True)
+    latest_dag_processor_job_mock = MagicMock()
+    latest_dag_processor_job_mock.latest_heartbeat = datetime.now()
+    latest_dag_processor_job_mock.is_alive = MagicMock(return_value=True)
+
     SchedulerJobRunner.most_recent_job = MagicMock(return_value=None)
     TriggererJobRunner.most_recent_job = MagicMock(return_value=latest_triggerer_job_mock)
+    DagProcessorJobRunner.most_recent_job = MagicMock(return_value=latest_dag_processor_job_mock)
 
     health_status = get_airflow_health()
 
@@ -95,6 +106,10 @@ def test_get_airflow_health_triggerer_healthy_no_scheduler_job_record():
             "status": HEALTHY,
             "latest_triggerer_heartbeat": latest_triggerer_job_mock.latest_heartbeat.isoformat(),
         },
+        "dag_processor": {
+            "status": HEALTHY,
+            "latest_dag_processor_heartbeat": latest_dag_processor_job_mock.latest_heartbeat.isoformat(),
+        },
     }
 
     assert health_status == expected_status