This is an automated email from the ASF dual-hosted git repository.

ferruzzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 39ab6006ed2 Add team name tags to Edge worker metrics (#69149)
39ab6006ed2 is described below

commit 39ab6006ed26949a7e686cb8140b887c77159f03
Author: Shivam Rastogi <[email protected]>
AuthorDate: Fri Jul 10 09:51:27 2026 -0700

    Add team name tags to Edge worker metrics (#69149)
---
 .../providers/edge3/executors/edge_executor.py     |  5 +-
 .../airflow/providers/edge3/models/edge_worker.py  | 22 +++++---
 .../providers/edge3/worker_api/routes/jobs.py      |  8 ++-
 .../providers/edge3/worker_api/routes/worker.py    |  9 ++-
 .../unit/edge3/executors/test_edge_executor.py     | 42 +++++++++++---
 .../unit/edge3/worker_api/routes/test_jobs.py      | 63 ++++++++++++++++++++-
 .../unit/edge3/worker_api/routes/test_worker.py    | 64 +++++++++++++++++++++-
 7 files changed, 187 insertions(+), 26 deletions(-)

diff --git 
a/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py 
b/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
index 5c582781a45..e6f7af33eb0 100644
--- a/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
+++ b/providers/edge3/src/airflow/providers/edge3/executors/edge_executor.py
@@ -219,7 +219,7 @@ class EdgeExecutor(BaseExecutor):
             sysinfo.pop("status_text", None)  # Remove old status text if 
exists
             worker.sysinfo = sysinfo
             self.log.warning("Worker %s is lifeless. Setting state to %s", 
worker.worker_name, worker.state)
-            reset_metrics(worker.worker_name)
+            reset_metrics(worker.worker_name, team_name=worker.team_name)
 
         return changed
 
@@ -254,8 +254,9 @@ class EdgeExecutor(BaseExecutor):
                     "task_id": job.task_id,
                     "queue": job.queue,
                     "state": str(TaskInstanceState.FAILED),
+                    "team_name": job.team_name,
                 }
-                Stats.incr("edge_worker.ti.finish", tags=tags)
+                Stats.incr("edge_worker.ti.finish", tags=prune_dict(tags))
 
         return bool(lifeless_jobs)
 
diff --git a/providers/edge3/src/airflow/providers/edge3/models/edge_worker.py 
b/providers/edge3/src/airflow/providers/edge3/models/edge_worker.py
index 78c60307ea1..fb800b8b245 100644
--- a/providers/edge3/src/airflow/providers/edge3/models/edge_worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/models/edge_worker.py
@@ -28,6 +28,7 @@ from sqlalchemy.orm import Mapped
 from airflow.providers.common.compat.sdk import AirflowException, Stats, 
timezone
 from airflow.providers.common.compat.sqlalchemy.orm import mapped_column
 from airflow.providers.edge3.models.edge_base import Base
+from airflow.utils.helpers import prune_dict
 from airflow.utils.log.logging_mixin import LoggingMixin
 from airflow.utils.providers_configuration_loader import 
providers_configuration_loaded
 from airflow.utils.session import NEW_SESSION, provide_session
@@ -156,6 +157,7 @@ def set_metrics(
     free_concurrency: int,
     queues: list[str] | None,
     sysinfo: dict[str, str | int | float | datetime],
+    team_name: str | None = None,
 ) -> None:
     """Set metric of edge worker."""
     queues = queues if queues else []
@@ -178,30 +180,31 @@ def set_metrics(
         "concurrency",
         "free_concurrency",
     }
+    metric_tags = prune_dict({"worker_name": worker_name, "team_name": 
team_name})
 
     Stats.gauge(
         "edge_worker.status",
         sysinfo.get("status", logging.NOTSET),  # type: ignore
-        tags={"worker_name": worker_name},
+        tags=metric_tags,
     )
-    Stats.gauge("edge_worker.connected", int(connected), tags={"worker_name": 
worker_name})
-    Stats.gauge("edge_worker.maintenance", int(maintenance), 
tags={"worker_name": worker_name})
-    Stats.gauge("edge_worker.jobs_active", jobs_active, tags={"worker_name": 
worker_name})
-    Stats.gauge("edge_worker.concurrency", concurrency, tags={"worker_name": 
worker_name})
-    Stats.gauge("edge_worker.free_concurrency", free_concurrency, 
tags={"worker_name": worker_name})
+    Stats.gauge("edge_worker.connected", int(connected), tags=metric_tags)
+    Stats.gauge("edge_worker.maintenance", int(maintenance), tags=metric_tags)
+    Stats.gauge("edge_worker.jobs_active", jobs_active, tags=metric_tags)
+    Stats.gauge("edge_worker.concurrency", concurrency, tags=metric_tags)
+    Stats.gauge("edge_worker.free_concurrency", free_concurrency, 
tags=metric_tags)
     Stats.gauge(
         "edge_worker.num_queues",
         len(queues),
-        tags={"worker_name": worker_name, "queues": ",".join(queues)},
+        tags={**metric_tags, "queues": ",".join(queues)},
     )
 
     for key in additional_keys:
         value = sysinfo.get(key)
         if isinstance(value, (int, float)):
-            Stats.gauge(f"edge_worker.{key}", value, tags={"worker_name": 
worker_name})
+            Stats.gauge(f"edge_worker.{key}", value, tags=metric_tags)
 
 
-def reset_metrics(worker_name: str) -> None:
+def reset_metrics(worker_name: str, team_name: str | None = None) -> None:
     """Reset metrics of worker."""
     set_metrics(
         worker_name=worker_name,
@@ -213,6 +216,7 @@ def reset_metrics(worker_name: str) -> None:
         sysinfo={
             "status": logging.NOTSET,
         },
+        team_name=team_name,
     )
 
 
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
index 7ed84b180d7..cf992b7dc8d 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/jobs.py
@@ -36,6 +36,7 @@ from airflow.providers.edge3.worker_api.datamodels import (
     WorkerApiDocs,
     WorkerQueuesBody,
 )
+from airflow.utils.helpers import prune_dict
 from airflow.utils.state import TaskInstanceState
 
 if TYPE_CHECKING:
@@ -104,7 +105,9 @@ def fetch(
     job.last_update = timezone.utcnow()
     session.commit()
     # Edge worker does not backport emitted Airflow metrics, so export some 
metrics
-    tags = {"dag_id": job.dag_id, "task_id": job.task_id, "queue": job.queue}
+    tags = prune_dict(
+        {"dag_id": job.dag_id, "task_id": job.task_id, "queue": job.queue, 
"team_name": job.team_name}
+    )
     Stats.incr("edge_worker.ti.start", tags=tags)
     return EdgeJobFetched(
         dag_id=job.dag_id,
@@ -157,8 +160,9 @@ def state(
                 "task_id": job.task_id,
                 "queue": job.queue,
                 "state": str(state),
+                "team_name": job.team_name,
             }
-            Stats.incr("edge_worker.ti.finish", tags=tags)
+            Stats.incr("edge_worker.ti.finish", tags=prune_dict(tags))
 
     query2 = (
         update(EdgeJobModel)
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py 
b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
index 6ca8e794e99..2e42ae8902b 100644
--- a/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
+++ b/providers/edge3/src/airflow/providers/edge3/worker_api/routes/worker.py
@@ -38,6 +38,7 @@ from airflow.providers.edge3.worker_api.datamodels import (
     WorkerSetStateReturn,
     WorkerStateBody,
 )
+from airflow.utils.helpers import prune_dict
 
 worker_router = AirflowRouter(
     tags=["Worker"],
@@ -244,7 +245,12 @@ def set_state(
     worker.sysinfo = body.sysinfo
     worker.last_update = timezone.utcnow()
     session.commit()
-    Stats.incr("edge_worker.heartbeat_count", 1, 1, tags={"worker_name": 
worker_name})
+    Stats.incr(
+        "edge_worker.heartbeat_count",
+        1,
+        1,
+        tags=prune_dict({"worker_name": worker_name, "team_name": 
worker.team_name}),
+    )
     concurrency: int = body.sysinfo.get("concurrency", -1)  # type: ignore
     free_concurrency: int = body.sysinfo.get("free_concurrency", -1)  # type: 
ignore
     set_metrics(
@@ -255,6 +261,7 @@ def set_state(
         free_concurrency=free_concurrency,
         queues=worker.queues,
         sysinfo=body.sysinfo,
+        team_name=worker.team_name,
     )
     versions_match = _assert_version(body.sysinfo)  # Exception only after 
worker state is in the DB
     return WorkerSetStateReturn(
diff --git a/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py 
b/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
index 8975eb7ad94..2f135957c47 100644
--- a/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
+++ b/providers/edge3/tests/unit/edge3/executors/test_edge_executor.py
@@ -67,9 +67,23 @@ class TestEdgeExecutor:
 
         return (executor, key)
 
+    @pytest.mark.parametrize(
+        ("executor_kwargs", "job_team_name", "expected_tags"),
+        [
+            ({}, None, {}),
+            pytest.param(
+                {"team_name": "team_a"},
+                "team_a",
+                {"team_name": "team_a"},
+                marks=pytest.mark.skipif(
+                    not AIRFLOW_V_3_2_PLUS, reason="team_name is only 
available in Airflow 3.2+"
+                ),
+            ),
+        ],
+    )
     @patch(f"{Stats.__module__}.Stats.incr")
-    def test_sync_orphaned_tasks(self, mock_stats_incr):
-        executor = EdgeExecutor()
+    def test_sync_orphaned_tasks(self, mock_stats_incr, executor_kwargs, 
job_team_name, expected_tags):
+        executor = EdgeExecutor(**executor_kwargs)
 
         delta_to_purge = timedelta(minutes=conf.getint("edge", 
"job_fail_purge") + 1)
         delta_to_orphaned_config_name = "task_instance_heartbeat_timeout"
@@ -97,20 +111,23 @@ class TestEdgeExecutor:
                         command="mock",
                         concurrency_slots=1,
                         last_update=last_update,
+                        team_name=job_team_name,
                     )
                 )
                 session.commit()
 
+        expected_tags = {
+            "dag_id": "test_dag",
+            "queue": "default",
+            "state": "failed",
+            "task_id": "started_running_orphaned",
+            **expected_tags,
+        }
         executor.sync()
 
         mock_stats_incr.assert_called_with(
             "edge_worker.ti.finish",
-            tags={
-                "dag_id": "test_dag",
-                "queue": "default",
-                "state": "failed",
-                "task_id": "started_running_orphaned",
-            },
+            tags=expected_tags,
         )
         assert mock_stats_incr.call_count == 1
 
@@ -549,10 +566,17 @@ class TestEdgeExecutorMultiTeam:
 
         with time_machine.travel(datetime(2023, 1, 1, 1, 0, 0, 
tzinfo=timezone.utc), tick=False):
             with conf_vars({("edge", "heartbeat_interval"): "10"}):
-                with create_session() as session:
+                with (
+                    create_session() as session,
+                    patch(
+                        
"airflow.providers.edge3.executors.edge_executor.reset_metrics"
+                    ) as mock_reset_metrics,
+                ):
                     executor_a._check_worker_liveness(session)
                     session.commit()
 
+        mock_reset_metrics.assert_called_once_with("worker_team_a", 
team_name="team_a")
+
         with create_session() as session:
             workers = {w.worker_name: w for w in 
session.scalars(select(EdgeWorkerModel)).all()}
             assert workers["worker_team_a"].state == EdgeWorkerState.UNKNOWN
diff --git a/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py 
b/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
index 26a06ba470f..0b09e47b633 100644
--- a/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
+++ b/providers/edge3/tests/unit/edge3/worker_api/routes/test_jobs.py
@@ -98,6 +98,7 @@ class TestJobsApiRoutes:
                 queue=QUEUE,
                 concurrency_slots=1,
                 command="execute",
+                team_name="team_a",
             )
             session.add(job)
             session.commit()
@@ -130,6 +131,7 @@ class TestJobsApiRoutes:
                     "queue": QUEUE,
                     "state": TaskInstanceState.SUCCESS,
                     "task_id": TASK_ID,
+                    "team_name": "team_a",
                 },
             )
             assert mock_stats_incr.call_count == 1
@@ -138,7 +140,45 @@ class TestJobsApiRoutes:
             assert db_job is not None
             assert db_job.state == TaskInstanceState.SUCCESS
 
-    def test_fetch_filters_by_worker_team_name(self, session: Session):
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_state_finish_metric_omits_team_name_for_global_job(self, 
mock_stats_incr, session: Session):
+        with create_session() as session:
+            job = EdgeJobModel(
+                dag_id=DAG_ID,
+                task_id=TASK_ID,
+                run_id=RUN_ID,
+                try_number=1,
+                map_index=-1,
+                state=TaskInstanceState.RUNNING,
+                queue=QUEUE,
+                concurrency_slots=1,
+                command="execute",
+            )
+            session.add(job)
+            session.commit()
+
+            state(
+                dag_id=DAG_ID,
+                task_id=TASK_ID,
+                run_id=RUN_ID,
+                try_number=1,
+                map_index=-1,
+                state=TaskInstanceState.SUCCESS,
+                session=session,
+            )
+
+            mock_stats_incr.assert_called_once_with(
+                "edge_worker.ti.finish",
+                tags={
+                    "dag_id": DAG_ID,
+                    "queue": QUEUE,
+                    "state": TaskInstanceState.SUCCESS,
+                    "task_id": TASK_ID,
+                },
+            )
+
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_fetch_filters_by_worker_team_name(self, mock_stats_incr, session: 
Session):
         with create_session() as session:
             session.add(
                 EdgeWorkerModel(
@@ -177,6 +217,15 @@ class TestJobsApiRoutes:
             assert result is not None
             assert result.dag_id == "dag_a"
             assert result.task_id == "task_a"
+            mock_stats_incr.assert_called_once_with(
+                "edge_worker.ti.start",
+                tags={
+                    "dag_id": "dag_a",
+                    "queue": QUEUE,
+                    "task_id": "task_a",
+                    "team_name": "team_a",
+                },
+            )
 
     def test_fetch_unknown_worker_raises_404(self, session: Session):
         body = WorkerQueuesBody(free_concurrency=1, queues=[QUEUE], 
team_name="team_a")
@@ -187,7 +236,8 @@ class TestJobsApiRoutes:
         assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
         assert exc_info.value.detail == "Worker not found"
 
-    def test_fetch_without_team_name_returns_any_team(self, session: Session):
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_fetch_without_team_name_returns_any_team(self, mock_stats_incr, 
session: Session):
         """When a worker has no team_name, no team filter is applied so any 
queued job can be returned."""
         with create_session() as session:
             session.add(
@@ -232,6 +282,15 @@ class TestJobsApiRoutes:
             assert result3 is None
             fetched_dag_ids = {result1.dag_id, result2.dag_id}
             assert fetched_dag_ids == {"dag_a", "dag_b"}
+            mock_stats_incr.assert_any_call(
+                "edge_worker.ti.start",
+                tags={"dag_id": "dag_a", "queue": QUEUE, "task_id": "task_a", 
"team_name": "team_a"},
+            )
+            mock_stats_incr.assert_any_call(
+                "edge_worker.ti.start",
+                tags={"dag_id": "dag_b", "queue": QUEUE, "task_id": "task_b"},
+            )
+            assert mock_stats_incr.call_count == 2
 
 
 @pytest.mark.skipif(not AIRFLOW_V_3_3_PLUS, reason="The tests should be 
skipped for Airflow < 3.3")
diff --git a/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py 
b/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
index 099ba7e8827..f42d93950f3 100644
--- a/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
+++ b/providers/edge3/tests/unit/edge3/worker_api/routes/test_worker.py
@@ -20,13 +20,14 @@ from collections.abc import Sequence
 from datetime import datetime
 from pathlib import Path
 from typing import TYPE_CHECKING
+from unittest.mock import patch
 
 import pytest
 from fastapi import HTTPException
 from sqlalchemy import delete, select
 
 from airflow import __version__ as airflow_version
-from airflow.providers.common.compat.sdk import timezone
+from airflow.providers.common.compat.sdk import Stats, timezone
 from airflow.providers.edge3 import __version__ as edge_provider_version
 from airflow.providers.edge3.cli.worker import EdgeWorker
 from airflow.providers.edge3.models.edge_worker import (
@@ -363,6 +364,67 @@ class TestWorkerApiRoutes:
         assert worker[0].queues == queues
         assert return_queues == ["default", "default2"]
 
+    @pytest.mark.parametrize(
+        ("worker_team_name", "expected_worker_tags"),
+        [
+            pytest.param("team_a", {"worker_name": "test2_worker", 
"team_name": "team_a"}, id="team"),
+            pytest.param(None, {"worker_name": "test2_worker"}, id="global"),
+        ],
+    )
+    @patch(f"{Stats.__module__}.Stats.gauge")
+    @patch(f"{Stats.__module__}.Stats.incr")
+    def test_set_state_metrics_team_name_tags(
+        self,
+        mock_stats_incr,
+        mock_stats_gauge,
+        session: Session,
+        cli_worker: EdgeWorker,
+        worker_team_name: str | None,
+        expected_worker_tags: dict[str, str],
+    ):
+        queues = ["default", "default2"]
+        rwm = EdgeWorkerModel(
+            worker_name="test2_worker",
+            state=EdgeWorkerState.IDLE,
+            queues=queues,
+            first_online=timezone.utcnow(),
+            team_name=worker_team_name,
+        )
+        session.add(rwm)
+        session.commit()
+
+        body = WorkerStateBody(
+            state=EdgeWorkerState.RUNNING,
+            jobs_active=1,
+            queues=["default2"],
+            sysinfo={**self.MOCK_SYSINFO, "disk_usage": 42.5, "status_text": 
"ok"},
+        )
+        set_state("test2_worker", body, session)
+
+        mock_stats_incr.assert_called_once_with(
+            "edge_worker.heartbeat_count",
+            1,
+            1,
+            tags=expected_worker_tags,
+        )
+        mock_stats_gauge.assert_any_call(
+            "edge_worker.status",
+            self.MOCK_SYSINFO["status"],
+            tags=expected_worker_tags,
+        )
+        mock_stats_gauge.assert_any_call("edge_worker.connected", 1, 
tags=expected_worker_tags)
+        mock_stats_gauge.assert_any_call("edge_worker.maintenance", 0, 
tags=expected_worker_tags)
+        mock_stats_gauge.assert_any_call("edge_worker.jobs_active", 1, 
tags=expected_worker_tags)
+        mock_stats_gauge.assert_any_call("edge_worker.concurrency", 8, 
tags=expected_worker_tags)
+        mock_stats_gauge.assert_any_call("edge_worker.free_concurrency", 8, 
tags=expected_worker_tags)
+        mock_stats_gauge.assert_any_call(
+            "edge_worker.num_queues",
+            len(queues),
+            tags={**expected_worker_tags, "queues": ",".join(queues)},
+        )
+        mock_stats_gauge.assert_any_call("edge_worker.disk_usage", 42.5, 
tags=expected_worker_tags)
+        assert mock_stats_gauge.call_count == 8
+
     def test_set_state_returns_concurrency(self, session: Session, cli_worker: 
EdgeWorker):
         """set_state includes the DB-stored concurrency override in its 
response."""
         rwm = EdgeWorkerModel(

Reply via email to