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

o-nikolas 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 dfabf3b7193 Add team_name tagging to multi team dag metrics (#69943)
dfabf3b7193 is described below

commit dfabf3b7193c07291bd9217582d2bbc23b005797
Author: Teghveer Singh Ateliey <[email protected]>
AuthorDate: Fri Jul 24 18:08:55 2026 -0500

    Add team_name tagging to multi team dag metrics (#69943)
    
    Added team_name tag to the following:
    connection_test.reaped
    connection_test.success
    connection_test.failed
    connection_test.hook_duration
    scheduler.executor_heartbeat_duration
    triggers.blocked_main_thread
    
    Added a team_name field to TestConnection class. This is to share team_name 
from
    the scheduler to connection test supervisor without changing the execution 
API.
---
 airflow-core/docs/core-concepts/multi-team.rst     |  11 +-
 .../src/airflow/executors/base_executor.py         |   1 +
 .../airflow/executors/workloads/connection_test.py |   3 +
 .../src/airflow/jobs/scheduler_job_runner.py       |  18 +++-
 .../src/airflow/jobs/triggerer_job_runner.py       |   5 +-
 .../tests/unit/executors/test_base_executor.py     |  30 ++++++
 airflow-core/tests/unit/jobs/test_scheduler_job.py |  94 ++++++++++++++++-
 airflow-core/tests/unit/jobs/test_triggerer_job.py |  12 ++-
 .../edge3/worker_api/v2-edge-generated.yaml        |   5 +
 .../execution_time/connection_test_supervisor.py   |  13 ++-
 .../test_connection_test_supervisor.py             | 112 +++++++++++++++++++++
 11 files changed, 291 insertions(+), 13 deletions(-)

diff --git a/airflow-core/docs/core-concepts/multi-team.rst 
b/airflow-core/docs/core-concepts/multi-team.rst
index b45b0379b7f..ab5a008cd4d 100644
--- a/airflow-core/docs/core-concepts/multi-team.rst
+++ b/airflow-core/docs/core-concepts/multi-team.rst
@@ -1060,9 +1060,11 @@ Dags, and global components emit the same metrics 
without a ``team_name`` tag.
 
 The ``team_name`` tag is applied to metrics across the following components:
 
-- **Triggerer**: heartbeat, capacity, and trigger-outcome metrics (for 
example, ``triggerer_heartbeat``,
-  ``triggers.running``, ``triggers.succeeded``).
-- **Executors**: executor slot gauges (for example, ``executor.open_slots``, 
``executor.queued_tasks``).
+- **Triggerer**: heartbeat, capacity, blocked-main-thread, and trigger-outcome 
metrics (for example,
+  ``triggerer_heartbeat``, ``triggers.running``, ``triggers.succeeded``,
+  ``triggers.blocked_main_thread``).
+- **Executors**: executor slot gauges and scheduler-observed executor 
heartbeat timing (for example,
+  ``executor.open_slots``, ``executor.queued_tasks``, 
``scheduler.executor_heartbeat_duration``).
 - **Scheduler**: pool slot gauges for team-scoped pools plus task- and 
asset-scheduling counters (for
   example, ``pool.open_slots``, ``scheduler.tasks.killed_externally``, 
``asset.triggered_dagruns``).
 - **Dag runs**: dag run timing and lifecycle metrics (for example, 
``dagrun.duration.<state>``,
@@ -1073,6 +1075,9 @@ The ``team_name`` tag is applied to metrics across the 
following components:
   ``dag_processing.processor_timeouts``, 
``dag_processing.callback_only_count``).
 - **Callbacks**: callback execution counters (``callback_success`` / 
``callback_failure``, optionally
   prefixed).
+- **Connection tests**: per-request worker and reaper metrics for team-owned 
connection tests (for
+  example, ``connection_test.success``, ``connection_test.failed``, 
``connection_test.hook_duration``,
+  ``connection_test.reaped``). Instance-wide connection-test queue gauges 
remain untagged.
 
 .. note::
 
diff --git a/airflow-core/src/airflow/executors/base_executor.py 
b/airflow-core/src/airflow/executors/base_executor.py
index ebd850ad944..8030e9c1445 100644
--- a/airflow-core/src/airflow/executors/base_executor.py
+++ b/airflow-core/src/airflow/executors/base_executor.py
@@ -747,6 +747,7 @@ class BaseExecutor(LoggingMixin):
                 timeout=workload.timeout,
                 token=workload.token,
                 server=server,
+                team_name=workload.team_name,
             )
         raise ValueError(f"Unknown workload type: {type(workload).__name__}")
 
diff --git a/airflow-core/src/airflow/executors/workloads/connection_test.py 
b/airflow-core/src/airflow/executors/workloads/connection_test.py
index 5f71d6327ae..d3b411af076 100644
--- a/airflow-core/src/airflow/executors/workloads/connection_test.py
+++ b/airflow-core/src/airflow/executors/workloads/connection_test.py
@@ -37,6 +37,7 @@ class TestConnection(BaseWorkloadSchema):
     connection_id: str
     timeout: int
     queue: str | None = None
+    team_name: str | None = None
 
     type: Literal["TestConnection"] = Field(init=False, 
default="TestConnection")
 
@@ -70,6 +71,7 @@ class TestConnection(BaseWorkloadSchema):
         connection_id: str,
         timeout: int,
         queue: str | None = None,
+        team_name: str | None = None,
         generator: JWTGenerator | None = None,
     ) -> TestConnection:
         return cls(
@@ -77,5 +79,6 @@ class TestConnection(BaseWorkloadSchema):
             connection_id=connection_id,
             timeout=timeout,
             queue=queue,
+            team_name=team_name,
             token=cls.generate_token(str(connection_test_id), generator),
         )
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index cb4c8f64552..a9efa1c03d6 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -1844,7 +1844,12 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 for executor in self.executors:
                     with stats.timer(
                         "scheduler.executor_heartbeat_duration",
-                        tags={"executor": type(executor).__name__},
+                        tags=prune_dict(
+                            {
+                                "executor": type(executor).__name__,
+                                "team_name": executor.team_name,
+                            }
+                        ),
                     ):
                         executor.heartbeat()
 
@@ -4002,6 +4007,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 connection_id=ct.connection_id,
                 timeout=timeout,
                 queue=ct.queue,
+                team_name=team_name,
                 generator=executor.jwt_generator,
             )
             executor.queue_workload(workload, session=session)
@@ -4047,7 +4053,15 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
                 prior_state_value,
                 ct.team_name,
             )
-            stats.incr("connection_test.reaped", tags={"prior_state": 
prior_state_value})
+            stats.incr(
+                "connection_test.reaped",
+                tags=prune_dict(
+                    {
+                        "prior_state": prior_state_value,
+                        "team_name": ct.team_name if self._multi_team else 
None,
+                    }
+                ),
+            )
             key = ConnectionTestKey(id=str(ct.id))
             for executor in self.executors:
                 if executor.supports_connection_test:
diff --git a/airflow-core/src/airflow/jobs/triggerer_job_runner.py 
b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
index 759735242ab..f851e491a69 100644
--- a/airflow-core/src/airflow/jobs/triggerer_job_runner.py
+++ b/airflow-core/src/airflow/jobs/triggerer_job_runner.py
@@ -1600,7 +1600,10 @@ class TriggerRunner:
                     time_elapsed,
                     self.blocked_main_thread_warning_threshold,
                 )
-                stats.incr("triggers.blocked_main_thread")
+                stats.incr(
+                    "triggers.blocked_main_thread",
+                    tags=prune_dict({"team_name": self.team_name}),
+                )
 
     async def run_trigger(
         self,
diff --git a/airflow-core/tests/unit/executors/test_base_executor.py 
b/airflow-core/tests/unit/executors/test_base_executor.py
index 8bc7cfd73db..8858eac59f3 100644
--- a/airflow-core/tests/unit/executors/test_base_executor.py
+++ b/airflow-core/tests/unit/executors/test_base_executor.py
@@ -497,10 +497,40 @@ def 
test_queue_connection_test_workload_accepted_when_supported():
         connection_test_id=uuid4(),
         connection_id="test_conn",
         timeout=60,
+        team_name="team_a",
     )
     executor.queue_workload(wl, session=mock.MagicMock(spec=Session))
     assert len(executor.queued_connection_tests) == 1
     assert executor.queued_connection_tests[wl.key] is wl
+    assert wl.team_name == "team_a"
+
+
[email protected](
+    
"airflow.sdk.execution_time.connection_test_supervisor.supervise_connection_test",
+    autospec=True,
+)
+def 
test_run_workload_passes_team_name_to_connection_test_supervisor(mock_supervise):
+    """BaseExecutor.run_workload forwards TestConnection.team_name to the 
supervisor."""
+    mock_supervise.return_value = 0
+    test_id = uuid4()
+    wl = workloads.TestConnection.make(
+        connection_test_id=test_id,
+        connection_id="test_conn",
+        timeout=60,
+        team_name="team_a",
+    )
+    wl.token = "test-token"
+
+    BaseExecutor.run_workload(wl, server="http://localhost:8080/execution/";)
+
+    mock_supervise.assert_called_once_with(
+        connection_test_id=test_id,
+        connection_id="test_conn",
+        timeout=60,
+        token="test-token",
+        server="http://localhost:8080/execution/";,
+        team_name="team_a",
+    )
 
 
 def test_trigger_connection_tests_skipped_when_not_supported():
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py 
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 782bcf81c4a..4add965a6ab 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -1373,6 +1373,8 @@ class TestSchedulerJob:
 
     def test_executor_heartbeat_emits_timer(self, mock_executors, 
configure_testing_dag_bundle):
         with configure_testing_dag_bundle(os.devnull):
+            mock_executors[0].team_name = "team_a"
+            mock_executors[1].team_name = None
             scheduler_job = Job()
             self.job_runner = SchedulerJobRunner(job=scheduler_job, num_runs=1)
             with patch("airflow.jobs.scheduler_job_runner.stats.timer") as 
mock_timer:
@@ -1385,7 +1387,10 @@ class TestSchedulerJob:
             ]
             assert len(heartbeat_calls) == len(self.job_runner.executors)
             for executor, timer_call in zip(self.job_runner.executors, 
heartbeat_calls):
-                assert timer_call.kwargs.get("tags") == {"executor": 
type(executor).__name__}
+                expected_tags = {"executor": type(executor).__name__}
+                if executor.team_name:
+                    expected_tags["team_name"] = executor.team_name
+                assert timer_call.kwargs.get("tags") == expected_tags
 
     def test_executor_events_processed(self, mock_executors, 
configure_testing_dag_bundle):
         with configure_testing_dag_bundle(os.devnull):
@@ -12659,6 +12664,48 @@ class TestDispatchConnectionTests:
 
         assert mock_load.call_args.kwargs["team_name"] == "team_a"
 
+    @pytest.mark.parametrize(
+        ("multi_team", "row_team_name", "expected_workload_team"),
+        [
+            pytest.param(True, "team_a", "team_a", id="multi_team_with_team"),
+            pytest.param(True, None, None, id="multi_team_without_team"),
+            pytest.param(False, "team_a", None, 
id="single_team_ignores_row_team"),
+        ],
+    )
+    @mock.patch.dict(
+        os.environ,
+        {
+            "AIRFLOW__CONNECTION_TEST__MAX_CONCURRENCY": "4",
+            "AIRFLOW__CONNECTION_TEST__TIMEOUT": "60",
+        },
+    )
+    def test_dispatch_puts_team_name_on_workload(
+        self,
+        scheduler_job_runner_for_connection_tests,
+        session,
+        multi_team,
+        row_team_name,
+        expected_workload_team,
+    ):
+        """Queued TestConnection workloads carry team_name only in multi-team 
mode."""
+        runner = scheduler_job_runner_for_connection_tests
+        runner._multi_team = multi_team
+
+        session.add(
+            ConnectionTestRequest(
+                conn_type="test_type",
+                connection_id="team_conn",
+                team_name=row_team_name,
+            )
+        )
+        session.commit()
+
+        runner._enqueue_connection_tests(session=session)
+
+        queued = list(runner.executor.queued_connection_tests.values())
+        assert len(queued) == 1
+        assert queued[0].team_name == expected_workload_team
+
     @mock.patch.dict(
         os.environ,
         {
@@ -13038,6 +13085,51 @@ class TestReapStaleConnectionTests:
         assert session.get(ConnectionTestRequest, ct_success.id).state == 
ConnectionTestState.SUCCESS
         assert session.get(ConnectionTestRequest, ct_failed.id).state == 
ConnectionTestState.FAILED
 
+    @pytest.mark.parametrize(
+        ("multi_team", "team_name", "expected_tags"),
+        [
+            pytest.param(
+                True,
+                "team_alpha",
+                {"prior_state": "queued", "team_name": "team_alpha"},
+                id="with_team",
+            ),
+            pytest.param(True, None, {"prior_state": "queued"}, 
id="multi_team_no_team_on_row"),
+            pytest.param(False, "team_alpha", {"prior_state": "queued"}, 
id="single_team_ignores_row_team"),
+        ],
+    )
+    @mock.patch.dict(os.environ, {"AIRFLOW__CONNECTION_TEST__TIMEOUT": "60"})
+    def test_reap_emits_team_name_tag(
+        self,
+        scheduler_job_runner_for_connection_tests,
+        session,
+        multi_team,
+        team_name,
+        expected_tags,
+    ):
+        """Reaper connection_test.reaped metric includes team_name only when 
multi-team is on."""
+        runner = scheduler_job_runner_for_connection_tests
+        runner._multi_team = multi_team
+        initial_time = timezone.utcnow()
+
+        with time_machine.travel(initial_time, tick=False):
+            ct = ConnectionTestRequest(
+                conn_type="test_type",
+                connection_id="reap_team_conn",
+                team_name=team_name,
+            )
+            ct.state = ConnectionTestState.QUEUED
+            session.add(ct)
+            session.commit()
+
+        with (
+            time_machine.travel(initial_time + timedelta(seconds=200), 
tick=False),
+            mock.patch("airflow.jobs.scheduler_job_runner.stats.incr") as 
mock_stats_incr,
+        ):
+            runner._reap_stale_connection_tests(session=session)
+
+        mock_stats_incr.assert_called_once_with("connection_test.reaped", 
tags=expected_tags)
+
 
 @pytest.mark.need_serialized_dag
 @pytest.mark.usefixtures("clear_asset_partition_rows")
diff --git a/airflow-core/tests/unit/jobs/test_triggerer_job.py 
b/airflow-core/tests/unit/jobs/test_triggerer_job.py
index fa4d68f5fff..31a77b50b65 100644
--- a/airflow-core/tests/unit/jobs/test_triggerer_job.py
+++ b/airflow-core/tests/unit/jobs/test_triggerer_job.py
@@ -1157,10 +1157,18 @@ class TestTriggerRunner:
         mock_stats_incr.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_block_watchdog_logs_when_threshold_is_exceeded(self) -> 
None:
+    @pytest.mark.parametrize(
+        ("team_name", "expected_tags"),
+        [
+            pytest.param("team_a", {"team_name": "team_a"}, id="with_team"),
+            pytest.param(None, {}, id="without_team"),
+        ],
+    )
+    async def test_block_watchdog_logs_when_threshold_is_exceeded(self, 
team_name, expected_tags) -> None:
         with conf_vars({("triggerer", 
"blocked_main_thread_warning_threshold"): "0.5"}):
             trigger_runner = TriggerRunner()
 
+        trigger_runner.team_name = team_name
         trigger_runner.log = AsyncMock()
 
         async def fake_sleep(_):
@@ -1178,7 +1186,7 @@ class TestTriggerRunner:
         assert "configured warning threshold" in log_message
         assert elapsed == pytest.approx(0.6)
         assert threshold == 0.5
-        mock_stats_incr.assert_called_once_with("triggers.blocked_main_thread")
+        
mock_stats_incr.assert_called_once_with("triggers.blocked_main_thread", 
tags=expected_tags)
 
     def test_run_inline_trigger_canceled(self, session) -> None:
         trigger_runner = TriggerRunner()
diff --git 
a/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml 
b/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
index 4bd6a68af43..13cacba97be 100644
--- 
a/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
+++ 
b/providers/edge3/src/airflow/providers/edge3/worker_api/v2-edge-generated.yaml
@@ -1385,6 +1385,11 @@ components:
           - type: string
           - type: 'null'
           title: Queue
+        team_name:
+          anyOf:
+          - type: string
+          - type: 'null'
+          title: Team Name
         type:
           type: string
           const: TestConnection
diff --git 
a/task-sdk/src/airflow/sdk/execution_time/connection_test_supervisor.py 
b/task-sdk/src/airflow/sdk/execution_time/connection_test_supervisor.py
index aca24556e29..06a7c3735f7 100644
--- a/task-sdk/src/airflow/sdk/execution_time/connection_test_supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/connection_test_supervisor.py
@@ -44,6 +44,7 @@ def supervise_connection_test(
     timeout: int,
     token: str,
     server: str,
+    team_name: str | None = None,
 ) -> int:
     """Execute a connection test on the worker and report the result via the 
Execution API."""
     client = Client(base_url=server, token=token)
@@ -51,6 +52,7 @@ def supervise_connection_test(
     bind_contextvars(connection_test_id=str(connection_test_id), 
connection_id=connection_id)
     log.info("Starting connection test", timeout=timeout)
     start = time.monotonic()
+    tags = {"team_name": team_name} if team_name else {}
 
     try:
         r = client.connection_tests.get_connection(connection_test_id)
@@ -75,7 +77,7 @@ def supervise_connection_test(
         os.environ["_AIRFLOW_PROCESS_CONTEXT"] = "client"
         try:
             with (
-                stats.timer("connection_test.hook_duration"),
+                stats.timer("connection_test.hook_duration", tags=tags),
                 TimeoutPosix(
                     seconds=timeout,
                     error_message=f"Connection test timed out after 
{timeout}s",
@@ -95,7 +97,10 @@ def supervise_connection_test(
 
         state = ConnectionTestState.SUCCESS if success else 
ConnectionTestState.FAILED
         client.connection_tests.update_state(connection_test_id, state, 
message)
-        stats.incr("connection_test.success" if success else 
"connection_test.failed")
+        stats.incr(
+            "connection_test.success" if success else "connection_test.failed",
+            tags=tags,
+        )
         log.info(
             "Connection test finished",
             state=state.value,
@@ -107,7 +112,7 @@ def supervise_connection_test(
             timeout=timeout,
             duration=round(time.monotonic() - start, 3),
         )
-        stats.incr("connection_test.failed")
+        stats.incr("connection_test.failed", tags=tags)
         client.connection_tests.update_state(
             connection_test_id,
             ConnectionTestState.FAILED,
@@ -118,7 +123,7 @@ def supervise_connection_test(
             "Connection test failed unexpectedly",
             duration=round(time.monotonic() - start, 3),
         )
-        stats.incr("connection_test.failed")
+        stats.incr("connection_test.failed", tags=tags)
         client.connection_tests.update_state(
             connection_test_id,
             ConnectionTestState.FAILED,
diff --git 
a/task-sdk/tests/task_sdk/execution_time/test_connection_test_supervisor.py 
b/task-sdk/tests/task_sdk/execution_time/test_connection_test_supervisor.py
index 8f48cfa8e6a..d53844b6803 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_connection_test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_connection_test_supervisor.py
@@ -262,3 +262,115 @@ class TestSuperviseConnectionTest:
 
         assert "unrelated" not in observed
         assert observed.get("unrelated_error") == "AirflowNotFoundException"
+
+    @pytest.mark.parametrize(
+        ("team_name", "hook_result", "expected_metric", "expected_tags"),
+        [
+            pytest.param(
+                "team_alpha",
+                (True, "Connection OK"),
+                "connection_test.success",
+                {"team_name": "team_alpha"},
+                id="success_with_team",
+            ),
+            pytest.param(
+                "team_alpha",
+                (False, "Connection refused"),
+                "connection_test.failed",
+                {"team_name": "team_alpha"},
+                id="failure_with_team",
+            ),
+            pytest.param(
+                None,
+                (True, "Connection OK"),
+                "connection_test.success",
+                {},
+                id="success_without_team",
+            ),
+        ],
+    )
+    
@mock.patch("airflow.sdk.execution_time.connection_test_supervisor.stats.incr")
+    
@mock.patch("airflow.sdk.execution_time.connection_test_supervisor.stats.timer")
+    def test_emits_team_name_on_completion(
+        self,
+        mock_timer,
+        mock_incr,
+        MockClient,
+        team_name,
+        hook_result,
+        expected_metric,
+        expected_tags,
+    ):
+        mock_client = MockClient.return_value
+        mock_client.connection_tests.get_connection.return_value = 
ConnectionTestConnectionResponse(
+            conn_id="test_conn",
+            conn_type="http",
+            host="httpbin.org",
+        )
+        mock_timer.return_value.__enter__ = mock.Mock(return_value=None)
+        mock_timer.return_value.__exit__ = mock.Mock(return_value=False)
+
+        with mock.patch(
+            "airflow.sdk.definitions.connection.Connection.test_connection",
+            autospec=True,
+            return_value=hook_result,
+        ):
+            _call(team_name=team_name)
+
+        mock_timer.assert_called_once_with("connection_test.hook_duration", 
tags=expected_tags)
+        mock_incr.assert_called_once_with(expected_metric, tags=expected_tags)
+
+    @pytest.mark.parametrize(
+        ("team_name", "exception", "expected_tags"),
+        [
+            pytest.param(
+                "team_alpha",
+                AirflowTaskTimeout("Connection test timed out"),
+                {"team_name": "team_alpha"},
+                id="timeout_with_team",
+            ),
+            pytest.param(
+                "team_alpha",
+                RuntimeError("Something broke"),
+                {"team_name": "team_alpha"},
+                id="exception_with_team",
+            ),
+            pytest.param(None, RuntimeError("Something broke"), {}, 
id="exception_without_team"),
+        ],
+    )
+    
@mock.patch("airflow.sdk.execution_time.connection_test_supervisor.stats.incr")
+    def test_emits_team_name_on_failure_paths(
+        self,
+        mock_incr,
+        MockClient,
+        team_name,
+        exception,
+        expected_tags,
+    ):
+        mock_client = MockClient.return_value
+        mock_client.connection_tests.get_connection.return_value = 
ConnectionTestConnectionResponse(
+            conn_id="test_conn",
+            conn_type="http",
+        )
+
+        with mock.patch(
+            "airflow.sdk.definitions.connection.Connection.test_connection",
+            autospec=True,
+            side_effect=exception,
+        ):
+            _call(team_name=team_name)
+
+        mock_incr.assert_called_once_with("connection_test.failed", 
tags=expected_tags)
+
+    
@mock.patch("airflow.sdk.execution_time.connection_test_supervisor.stats.incr")
+    def test_emits_team_name_on_pre_fetch_failure(self, mock_incr, MockClient):
+        """Failures before GET /connection still include workload team_name on 
the failed metric."""
+        mock_client = MockClient.return_value
+        mock_client.connection_tests.get_connection.side_effect = 
RuntimeError("not found")
+
+        _call(team_name="team_alpha")
+
+        mock_incr.assert_called_once_with(
+            "connection_test.failed",
+            tags={"team_name": "team_alpha"},
+        )

Reply via email to