ashb commented on code in PR #68568:
URL: https://github.com/apache/airflow/pull/68568#discussion_r3536647292


##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -1374,6 +1374,12 @@ def process_executor_events(
             
.options(joinedload(TI.dag_run).selectinload(DagRun.created_dag_version))
             .options(joinedload(TI.dag_version))
         )
+        # When emitting Dag tags as metric tags, eager-load dag_model.tags so 
the per-finished-task
+        # ti_failures / operator_failures / task.*_duration metrics carry them 
without a per-TI lazy load.
+        if conf.getboolean("metrics", "dag_tags_in_metrics", fallback=False):

Review Comment:
   Missed this first time around, but `conf.getboolean` is surprisingly 
expensive (perhaps we should have added caching at the config layer, but for 
this PR), and since this is called for every task instance that reaches 
terminal state, please could we create a `self._dag_tags_in_metrics` which we 
load in `__init__` to avoid calling conf.getboolean in scheduler hot path.



##########
airflow-core/src/airflow/jobs/scheduler_job_runner.py:
##########
@@ -1374,6 +1374,12 @@ def process_executor_events(
             
.options(joinedload(TI.dag_run).selectinload(DagRun.created_dag_version))
             .options(joinedload(TI.dag_version))
         )
+        # When emitting Dag tags as metric tags, eager-load dag_model.tags so 
the per-finished-task
+        # ti_failures / operator_failures / task.*_duration metrics carry them 
without a per-TI lazy load.
+        if conf.getboolean("metrics", "dag_tags_in_metrics", fallback=False):
+            query = query.options(
+                
joinedload(TI.dag_run).joinedload(DagRun.dag_model).selectinload(DagModel.tags)

Review Comment:
   Do we actually need to join to dag table? We've got `dag_id` column on TI 
already and _could_ join directly from TI.dag_id to DagTags table?
   
   Not sure on this one, yes it's "more correct" to do it via dag, and it's a 
pure pk index lookup to DagModel so maybe it's fine as is?



##########
shared/observability/tests/observability/metrics/test_stats.py:
##########
@@ -475,6 +496,12 @@ def 
test_does_not_increment_counter_drops_invalid_tags(self):
         )
         
self.statsd_client.incr.assert_called_once_with("test_stats_run.delay,key1=val1",
 1, 1)
 
+    def test_standalone_tag_empty_value_emitted_as_true(self):
+        self.stats.incr("test_stats_run.delay", tags={"production": "", 
"key1": "val1"})
+        self.statsd_client.incr.assert_called_once_with(
+            "test_stats_run.delay,production=true,key1=val1", 1, 1
+        )

Review Comment:
   Could be added to previous parametrized case as this doesn't seem to be 
related to Allow or Block lists?



##########
shared/observability/src/airflow_shared/observability/metrics/statsd_logger.py:
##########
@@ -52,8 +52,9 @@ def wrapper(
             if stat is not None and tags is not None:
                 for k, v in tags.items():
                     if self.metric_tags_validator.test(k):
-                        if all(c not in [",", "="] for c in f"{v}{k}"):
-                            stat += f",{k}={v}"
+                        v_str = "true" if v == "" else v

Review Comment:
   Does it maybe make more sense to put this in build_dag_metric_tags?



##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -6267,3 +6284,49 @@ def test_bad_declaration_is_skipped_not_fatal(self):
         with patch("airflow.sdk.execution_time.task_runner.allow_class", 
side_effect=ValueError("nope")):
             # Must not raise -- the walk swallows per-class registration 
errors.
             _register_deserialization_allowed_classes(dag, 
structlog.get_logger())
+
+
+def _make_dag_tagged_ti(create_runtime_ti, tags):
+    """Build a RuntimeTaskInstance whose in-memory Dag carries the given 
tags."""
+    from airflow.sdk import DAG
+    from airflow.sdk.bases.operator import BaseOperator
+
+    class _NoopOperator(BaseOperator):
+        def execute(self, context):
+            return None
+
+    with DAG("tagged_dag", tags=tags):
+        task = _NoopOperator(task_id="t")
+    return create_runtime_ti(task=task)
+
+
+def test_stats_tags_dag_tags_disabled_by_default(create_runtime_ti):
+    """With the flag off (the default), dag tags must not leak into metrics."""
+    ti = _make_dag_tagged_ti(create_runtime_ti, ["env:prod", "validation"])
+    assert ti.stats_tags == {"dag_id": "tagged_dag", "task_id": "t", 
"run_type": "manual"}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_without_dag_tags(create_runtime_ti):
+    ti = _make_dag_tagged_ti(create_runtime_ti, [])
+    assert ti.stats_tags == {"dag_id": "tagged_dag", "task_id": "t", 
"run_type": "manual"}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_with_standalone_and_key_value_tags(create_runtime_ti):
+    ti = _make_dag_tagged_ti(create_runtime_ti, ["env:prod", "validation"])
+    assert ti.stats_tags == {
+        "env": "prod",
+        "validation": "",
+        "dag_id": "tagged_dag",
+        "task_id": "t",
+        "run_type": "manual",
+    }
+
+
+def test_stats_tags_run_type_is_bare_string(create_runtime_ti):
+    """run_type must be the bare DagRunType value (e.g. 'manual'), not the 
enum — otherwise it
+    serializes as 'dagruntype.manual' on the wire and disagrees with the 
scheduler-side tag."""
+    run_type = _make_dag_tagged_ti(create_runtime_ti, 
[]).stats_tags["run_type"]
+    assert run_type == "manual"
+    assert type(run_type) is str  # plain str, not a DagRunType enum member

Review Comment:
   ```suggestion
           "run_type": "manual",  # plain str, not a DagRunType enum member
       }
   ```
   
   Nit: No need for an extra test fn when we are already asserting it's a 
string three times. 



##########
airflow-core/src/airflow/models/dagrun.py:
##########
@@ -702,6 +736,12 @@ def get_running_dag_runs_to_examine(cls, session: Session) 
-> ScalarResult[DagRu
             .limit(cls.DEFAULT_DAGRUNS_TO_EXAMINE)
         )
 
+        # When dag tags are emitted as metric tags, eagerly load 
dag_model.tags so stats_tags
+        # does not fire a per-DagRun N+1 lazy load in the scheduler loop. Off 
by default, so the
+        # extra load is only paid when the feature is enabled.
+        if airflow_conf.getboolean("metrics", "dag_tags_in_metrics", 
fallback=False):

Review Comment:
   This one too is called in scheduler hotpath, but this is a class method, so 
I'm not sure where/how to avoid the per-loop conf.getboolean -- see what you 
can come up with?



##########
shared/observability/tests/observability/metrics/test_stats.py:
##########
@@ -259,6 +260,26 @@ def test_decr(self):
             metric="empty", sample_rate=1, value=1, tags=[]
         )
 
+    def test_key_value_tag_emitted_with_colon(self):
+        dogstatsd = SafeDogStatsdLogger(self.dogstatsd_client, 
metrics_tags=True)
+        dogstatsd.incr("my_metric", tags={"env": "prod"})
+        self.dogstatsd_client.increment.assert_called_once_with(
+            metric="my_metric", sample_rate=1, value=1, tags=["env:prod"]
+        )
+
+    def test_standalone_tag_empty_value_emitted_without_colon(self):
+        dogstatsd = SafeDogStatsdLogger(self.dogstatsd_client, 
metrics_tags=True)
+        dogstatsd.incr("my_metric", tags={"production": ""})
+        self.dogstatsd_client.increment.assert_called_once_with(
+            metric="my_metric", sample_rate=1, value=1, tags=["production"]
+        )
+
+    def test_mixed_tags_standalone_and_key_value(self):
+        dogstatsd = SafeDogStatsdLogger(self.dogstatsd_client, 
metrics_tags=True)
+        dogstatsd.incr("my_metric", tags={"production": "", "env": "staging"})
+        call_kwargs = self.dogstatsd_client.increment.call_args
+        assert set(call_kwargs.kwargs["tags"]) == {"production", "env:staging"}

Review Comment:
   Better as a single parametrized test with three cases I think.



##########
task-sdk/tests/task_sdk/execution_time/test_task_runner.py:
##########
@@ -6267,3 +6284,49 @@ def test_bad_declaration_is_skipped_not_fatal(self):
         with patch("airflow.sdk.execution_time.task_runner.allow_class", 
side_effect=ValueError("nope")):
             # Must not raise -- the walk swallows per-class registration 
errors.
             _register_deserialization_allowed_classes(dag, 
structlog.get_logger())
+
+
+def _make_dag_tagged_ti(create_runtime_ti, tags):
+    """Build a RuntimeTaskInstance whose in-memory Dag carries the given 
tags."""
+    from airflow.sdk import DAG
+    from airflow.sdk.bases.operator import BaseOperator
+
+    class _NoopOperator(BaseOperator):
+        def execute(self, context):
+            return None
+
+    with DAG("tagged_dag", tags=tags):
+        task = _NoopOperator(task_id="t")

Review Comment:
   Nit: the operator isn't important, so I'd suggest using BaseOperator instead:
   ```suggestion
       with DAG("tagged_dag", tags=tags):
           task = task = BaseOperator(task_id="t")
   ```



##########
shared/observability/src/airflow_shared/observability/metrics/datadog_logger.py:
##########
@@ -68,12 +79,11 @@ def incr(
         tags: dict[str, str] | None = None,
     ) -> None:
         """Increment stat."""
-        if self.metrics_tags and isinstance(tags, dict):
-            tags_list = [
-                f"{key}:{value}" for key, value in tags.items() if 
self.metric_tags_validator.test(key)
-            ]
-        else:
-            tags_list = []
+        tags_list = (
+            self._build_tags_list(tags, self.metric_tags_validator)
+            if self.metrics_tags and isinstance(tags, dict)
+            else []
+        )

Review Comment:
   This block appears 5 times. Worth DRYing it up? (Your call either way)



##########
task-sdk/src/airflow/sdk/execution_time/task_runner.py:
##########
@@ -254,10 +255,21 @@ class RuntimeTaskInstance(TaskInstance):
 
     @property
     def stats_tags(self) -> dict[str, str]:
-        """Metric tags for this task instance, including team_name when 
available."""
-        tags: dict[str, str] = {"dag_id": self.dag_id, "task_id": self.task_id}
-        if self._ti_context_from_server and 
self._ti_context_from_server.dag_run.team_name:
-            tags["team_name"] = self._ti_context_from_server.dag_run.team_name
+        """Metric tags for this task instance, including dag tags and 
team_name when available."""
+        tags: dict[str, str] = {}
+        if conf.getboolean("metrics", "dag_tags_in_metrics", fallback=False):
+            tags.update(build_dag_metric_tags(self.task.dag.tags))
+        # Built-in keys always win on collision.
+        tags["dag_id"] = self.dag_id
+        tags["task_id"] = self.task_id

Review Comment:
   Nit:
   ```suggestion
           tags.update(dag_id=self.dag_id, task_id=self.task_id)
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to