This is an automated email from the ASF dual-hosted git repository.
ashb 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 2a9d508e0b4 Optionally emit Dag tags as metric tags (#68568)
2a9d508e0b4 is described below
commit 2a9d508e0b4d55f7511edd2fec32b0f1c77e3f4c
Author: Sebastián Ortega <[email protected]>
AuthorDate: Mon Jul 20 23:52:00 2026 +0900
Optionally emit Dag tags as metric tags (#68568)
* Emit Dag tags as metric tags
Add a [metrics] dag_tags_in_metrics option (default False). When enabled,
each
Dag tag becomes a metric tag on Dag-run and task-instance metrics: tags
with a
colon (e.g. env:prod) split into a key/value pair; plain tags (e.g.
production)
become standalone DogStatsd tags, or tag=true in InfluxDB line protocol.
Built-in
keys (dag_id, run_type, task_id, team_name) win on collision.
Tags come from the DagRun's dag_model.tags. The scheduler hot loop
(get_running_dag_runs_to_examine) and the executor-event failure path
eager-load
them (gated on the flag) to avoid per-DagRun queries; other in-session
emission
paths fall back to a lazy load, so the tags reach all Dag-run and
task-instance
metrics. DagRun.dag_tags_for_stats swallows SQLAlchemyError so a
detached/expired
instance degrades to no tags rather than breaking the caller.
TaskInstance.stats_tags reuses DagRun.dag_tags_for_stats and adds task_id
and
run_type; the Task SDK worker reads the in-memory Dag and also adds
run_type, so
ti.* metrics carry a consistent tag set across the worker and scheduler
emitters.
The build_dag_metric_tags helper lives in the shared observability stats
module.
* Address review feedback on Dag tags metric emission
Keep the scheduler hot path free of per-task conf reads. Warm the dag tags
via the existing
TaskInstance -> DagModel join rather than an extra dag_run hop, since the
DagModel is shared in the identity map. Fold the repeated dogstatsd tag-list
guard into a single helper, and consolidate the tag-formatting tests.
* Address further review nits on Dag tags metric emission
Make session a keyword-only argument on get_running_dag_runs_to_examine, in
line with the convention for session parameters. Reduce
TaskInstance.stats_tags
to the dag run's tags plus the task-level ones, keeping the TI's transiently
resolved team_name when present.
* Source task instance team_name metric tag from the dag run
TaskInstance.stats_tags already reuses the dag run's stats tags, so take
team_name from there too rather than from a separate per-task transient
attribute, keeping a single source of truth for the tag.
The scheduling loop now stashes the resolved team on the dag run so
task-instance metrics still carry it. The heartbeat-purge path already tags
team inline, so its now-redundant per-task assignment is dropped.
---
.../src/airflow/config_templates/config.yml | 11 ++
.../src/airflow/jobs/scheduler_job_runner.py | 33 +++--
airflow-core/src/airflow/models/dagrun.py | 52 +++++++-
airflow-core/src/airflow/models/taskinstance.py | 6 +-
airflow-core/tests/unit/jobs/test_scheduler_job.py | 38 ++++--
airflow-core/tests/unit/models/test_dagrun.py | 147 ++++++++++++++++-----
.../tests/unit/models/test_taskinstance.py | 57 ++++++--
.../observability/metrics/datadog_logger.py | 44 ++----
.../airflow_shared/observability/metrics/stats.py | 19 ++-
.../observability/metrics/statsd_logger.py | 5 +-
.../tests/observability/metrics/test_stats.py | 84 +++++++++---
.../src/airflow/sdk/execution_time/task_runner.py | 19 ++-
.../task_sdk/execution_time/test_task_runner.py | 70 ++++++++--
13 files changed, 450 insertions(+), 135 deletions(-)
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index 7a65cedb2da..b3dc3f133d2 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -1280,6 +1280,17 @@ metrics:
type: boolean
example: ~
default: "False"
+ dag_tags_in_metrics:
+ description: |
+ Set to ``True`` to include Dag tags as metric tags on all Dag-run and
task-instance metrics.
+ Tags that contain a colon (e.g. ``env:prod``) are split into a
key/value pair. Plain tags
+ (e.g. ``production``) are emitted as standalone DogStatsd tags, or as
``tag=true`` in InfluxDB
+ line-protocol format. Disabled by default to avoid unexpected
cardinality increases, since Dag
+ tags are free-form, user-defined strings.
+ version_added: 3.4.0
+ type: boolean
+ example: ~
+ default: "False"
otel_on:
description: |
Enables sending metrics to OpenTelemetry.
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index c2f3ee05d73..9b9e85c07e3 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -334,6 +334,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
self._scheduler_use_job_schedule = conf.getboolean("scheduler",
"use_job_schedule", fallback=True)
self._parallelism = conf.getint("core", "parallelism")
self._multi_team = conf.getboolean("core", "multi_team")
+ self._dag_tags_in_metrics = conf.getboolean("metrics",
"dag_tags_in_metrics", fallback=False)
self._max_partition_dag_runs_per_loop = MAX_PARTITION_DAG_RUNS_PER_LOOP
self._dag_id_to_team_name: dict[str, str | None] = {}
@@ -741,10 +742,10 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
list(unique_dag_ids),
)
for ti in task_instances_to_examine:
- # Set team as a transient attribute; team lives on the
Bundle, not
- # on the TI/DagRun schema, so we resolve it at scheduling
time.
+ # Team lives on the Bundle, not the TI/DagRun schema, so
resolve it at scheduling
+ # time and stash it on the dag run, where stats_tags reads
it for metric tagging.
if team := dag_id_to_team_name.get(ti.dag_id):
- ti._team_name = team
+ ti.dag_run._team_name = team
executor_slots_available: dict[ExecutorName, int] = {}
# First get a mapping of executor names to slots they have
available
@@ -1265,6 +1266,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
job_id=self.job.id,
scheduler_dag_bag=self.scheduler_dag_bag,
session=session,
+ eagerly_load_dag_tags=self._dag_tags_in_metrics,
)
except Exception as exc:
stats.incr("scheduler.executor_events.failed",
tags={"exception_class": type(exc).__name__})
@@ -1277,7 +1279,12 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
@classmethod
def process_executor_events(
- cls, executor: BaseExecutor, job_id: int | None, scheduler_dag_bag:
DBDagBag, session: Session
+ cls,
+ executor: BaseExecutor,
+ job_id: int | None,
+ scheduler_dag_bag: DBDagBag,
+ session: Session,
+ eagerly_load_dag_tags: bool = False,
) -> int:
"""
Process task completion events from the executor and update task
instance states.
@@ -1300,6 +1307,9 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
:param job_id: The scheduler job ID, used to detect task requeuing by
other schedulers
:param scheduler_dag_bag: Serialized DAG bag for retrieving task
definitions
:param session: Database session for task instance updates
+ :param eagerly_load_dag_tags: When True, eager-load dag_model.tags so
the per-finished-task
+ metrics carry Dag tags without a per-TI lazy load. The scheduler
passes its cached flag so
+ the hot path never reads conf; other callers (e.g. ``dag.test()``)
leave it at the default.
:return: Number of events processed from the executor event buffer
@@ -1391,6 +1401,12 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
.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.
+ # TI already joins DagModel by dag_id, so warm tags off that
relationship directly rather than
+ # via the dag_run hop; the DagModel is shared in the identity map, so
dag_run.dag_model.tags is free.
+ if eagerly_load_dag_tags:
+ query =
query.options(selectinload(TI.dag_model).selectinload(DagModel.tags))
# row lock this entire set of taskinstances to make sure the scheduler
doesn't fail when we have
# multi-schedulers
locked_query = with_row_locks(query, of=TI, session=session,
skip_locked=True)
@@ -1927,7 +1943,11 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
# examining, rather than making one query per DagRun.
# Materialize into a list because the multi-team block below
iterates
# the result and ScalarResult is a one-pass iterator.
- dag_runs =
list(DagRun.get_running_dag_runs_to_examine(session=session))
+ dag_runs = list(
+ DagRun.get_running_dag_runs_to_examine(
+ session=session,
eagerly_load_dag_tags=self._dag_tags_in_metrics
+ )
+ )
if self._multi_team and dag_runs:
unique_dag_ids = {dr.dag_id for dr in dag_runs}
@@ -3558,9 +3578,6 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
if self._multi_team:
unique_dag_ids = {ti.dag_id for ti in
task_instances_without_heartbeats}
dag_id_to_team_name =
self._get_team_names_for_dag_ids(unique_dag_ids, session)
- for ti in task_instances_without_heartbeats:
- if team := dag_id_to_team_name.get(ti.dag_id):
- ti._team_name = team
else:
dag_id_to_team_name = {}
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index a51f16e51f6..d6610b3663e 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -53,16 +53,25 @@ from sqlalchemy import (
update,
)
from sqlalchemy.dialects import postgresql
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.ext.mutable import MutableDict
-from sqlalchemy.orm import Mapped, declared_attr, joinedload, mapped_column,
relationship, synonym, validates
+from sqlalchemy.orm import (
+ Mapped,
+ declared_attr,
+ joinedload,
+ mapped_column,
+ relationship,
+ synonym,
+ validates,
+)
from sqlalchemy.orm.exc import StaleDataError
from sqlalchemy.sql.expression import false, select
from sqlalchemy.sql.functions import coalesce
from airflow._shared.observability.metrics import stats
+from airflow._shared.observability.metrics.stats import build_dag_metric_tags
from airflow._shared.observability.traces import (
DAGRUN_PARENT_TRACE_CONTEXT_KEY,
TASK_SPAN_DETAIL_LEVEL_KEY,
@@ -576,11 +585,36 @@ class DagRun(Base, LoggingMixin):
)
return session.scalar(select_stmt)
+ def dag_tags_for_stats(self) -> dict[str, str]:
+ """Convert dag tags to metric tags. Tags with ':' become key:value;
others are standalone (empty value)."""
+ if not airflow_conf.getboolean("metrics", "dag_tags_in_metrics",
fallback=False):
+ return {}
+ try:
+ # Lazy-loads dag_model.tags if not already loaded. The scheduler
hot loop
+ # (get_running_dag_runs_to_examine) eager-loads these to avoid a
per-DagRun query; other,
+ # low-frequency emission paths fall back to this lazy load. On a
detached/expired DagRun
+ # the load raises — swallow it so metric tagging never breaks the
caller.
+ if not self.dag_model or not self.dag_model.tags:
+ return {}
+ return build_dag_metric_tags(tag.name for tag in
self.dag_model.tags)
+ except SQLAlchemyError:
+ return {}
+
@property
def stats_tags(self) -> dict[str, str]:
- return prune_dict(
- {"dag_id": self.dag_id, "run_type": self.run_type, "team_name":
getattr(self, "_team_name", None)}
+ # prune_dict strips falsy values, so merge dag tags after it runs so
standalone
+ # tags (empty value) are preserved for DogStatsD emission.
+ base = prune_dict(
+ {
+ "dag_id": self.dag_id,
+ # bare value so it serializes as e.g. "scheduled", not
"dagruntype.scheduled"
+ "run_type": getattr(self.run_type, "value", self.run_type),
+ "team_name": getattr(self, "_team_name", None),
+ }
)
+ dag_tags = self.dag_tags_for_stats()
+ # Built-in keys win on collision; dag tags fill in everything else.
+ return {**dag_tags, **base}
def get_state(self):
return self._state
@@ -702,7 +736,9 @@ class DagRun(Base, LoggingMixin):
@classmethod
@retry_db_transaction
- def get_running_dag_runs_to_examine(cls, session: Session) ->
ScalarResult[DagRun]:
+ def get_running_dag_runs_to_examine(
+ cls, *, session: Session, eagerly_load_dag_tags: bool
+ ) -> ScalarResult[DagRun]:
"""
Return the next DagRuns that the scheduler should attempt to schedule.
@@ -733,6 +769,12 @@ class DagRun(Base, LoggingMixin):
.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. The caller
owns the feature decision;
+ # the scheduler passes its cached flag so the loop never reads conf.
+ if eagerly_load_dag_tags:
+ query =
query.options(joinedload(cls.dag_model).selectinload(DagModel.tags))
+
query = query.where(DagRun.run_after <= func.now())
result = session.scalars(with_row_locks(query, of=cls,
session=session, skip_locked=True)).unique()
diff --git a/airflow-core/src/airflow/models/taskinstance.py
b/airflow-core/src/airflow/models/taskinstance.py
index 1c7de2185e6..0dd10507fc8 100644
--- a/airflow-core/src/airflow/models/taskinstance.py
+++ b/airflow-core/src/airflow/models/taskinstance.py
@@ -100,7 +100,6 @@ from airflow.task.priority_strategy import
validate_and_load_priority_weight_str
from airflow.ti_deps.dep_context import DepContext
from airflow.ti_deps.dependencies_deps import REQUEUEABLE_DEPS, RUNNING_DEPS
from airflow.ti_deps.deps.ready_to_reschedule import ReadyToRescheduleDep
-from airflow.utils.helpers import prune_dict
from airflow.utils.log.logging_mixin import LoggingMixin
from airflow.utils.net import get_hostname
from airflow.utils.platform import getuser
@@ -757,9 +756,8 @@ class TaskInstance(Base, LoggingMixin, BaseWorkload):
@property
def stats_tags(self) -> dict[str, str]:
"""Returns task instance tags."""
- return prune_dict(
- {"dag_id": self.dag_id, "task_id": self.task_id, "team_name":
getattr(self, "_team_name", None)}
- )
+ # Reuse the dag run's tags and add the task-level ones.
+ return {**self.dag_run.stats_tags, "task_id": self.task_id}
@staticmethod
def insert_mapping(
diff --git a/airflow-core/tests/unit/jobs/test_scheduler_job.py
b/airflow-core/tests/unit/jobs/test_scheduler_job.py
index 68f4ec09150..dff3adea48b 100644
--- a/airflow-core/tests/unit/jobs/test_scheduler_job.py
+++ b/airflow-core/tests/unit/jobs/test_scheduler_job.py
@@ -518,8 +518,13 @@ class TestSchedulerJob:
"scheduler.tasks.killed_externally",
tags={"dag_id": dag_id, "task_id": ti1.task_id},
),
- mock.call("operator_failures_EmptyOperator", tags={"dag_id":
dag_id, "task_id": ti1.task_id}),
- mock.call("ti_failures", tags={"dag_id": dag_id, "task_id":
ti1.task_id}),
+ mock.call(
+ "operator_failures_EmptyOperator",
+ tags={"dag_id": dag_id, "task_id": ti1.task_id,
"run_type": "manual"},
+ ),
+ mock.call(
+ "ti_failures", tags={"dag_id": dag_id, "task_id":
ti1.task_id, "run_type": "manual"}
+ ),
],
any_order=True,
)
@@ -645,8 +650,11 @@ class TestSchedulerJob:
"scheduler.tasks.killed_externally",
tags={"dag_id": dag_id, "task_id": task_id},
),
- mock.call("operator_failures_EmptyOperator", tags={"dag_id":
dag_id, "task_id": task_id}),
- mock.call("ti_failures", tags={"dag_id": dag_id, "task_id":
task_id}),
+ mock.call(
+ "operator_failures_EmptyOperator",
+ tags={"dag_id": dag_id, "task_id": task_id, "run_type":
"manual"},
+ ),
+ mock.call("ti_failures", tags={"dag_id": dag_id, "task_id":
task_id, "run_type": "manual"}),
],
any_order=True,
)
@@ -9828,8 +9836,10 @@ class TestSchedulerJob:
assert result2 == mock_executors[1] # Matched by executor name
@conf_vars({("core", "multi_team"): "true"})
- def test_multi_team_sets_team_name_on_task_instances(self, dag_maker,
mock_executors, session):
- """Test that _team_name is set on TaskInstance objects during the
scheduling loop."""
+ @mock.patch("airflow._shared.observability.metrics.stats.timing")
+ def test_multi_team_sets_team_name_on_task_instances(self, mock_timing,
dag_maker, session):
+ """The scheduling loop resolves the bundle's team onto the dag run, so
the QUEUED
+ state-change metric (emitted via TaskInstance.stats_tags) carries
team_name."""
clear_db_teams()
clear_db_dag_bundles()
@@ -9848,19 +9858,21 @@ class TestSchedulerJob:
dr = dag_maker.create_dagrun()
ti = dr.get_task_instance("task_a", session=session)
ti.state = State.SCHEDULED
+ ti.scheduled_dttm = timezone.utcnow()
+ session.merge(ti)
session.flush()
scheduler_job = Job()
self.job_runner = SchedulerJobRunner(job=scheduler_job)
- self.job_runner._multi_team = True
- # Simulate what _executable_task_instances_to_queued does
- dag_id_to_team_name =
self.job_runner._get_team_names_for_dag_ids(["dag_a"], session)
- if team_name := dag_id_to_team_name.get(ti.dag_id):
- ti._team_name = team_name
+ queued_tis =
self.job_runner._executable_task_instances_to_queued(max_tis=32,
session=session)
- assert ti._team_name == "team_a"
- assert ti.stats_tags == {"dag_id": "dag_a", "task_id": "task_a",
"team_name": "team_a"}
+ assert {t.key for t in queued_tis} == {ti.key}
+ scheduled_calls = [
+ c for c in mock_timing.call_args_list if c.args and c.args[0] ==
"task.scheduled_duration"
+ ]
+ assert scheduled_calls, "expected a task.scheduled_duration metric on
QUEUED transition"
+ assert scheduled_calls[0].kwargs["tags"]["team_name"] == "team_a"
@conf_vars({("core", "multi_team"): "true"})
def test_do_scheduling_multi_team_schedules_task_instances(self,
dag_maker, session):
diff --git a/airflow-core/tests/unit/models/test_dagrun.py
b/airflow-core/tests/unit/models/test_dagrun.py
index 64b4947ec4c..c46c30d1194 100644
--- a/airflow-core/tests/unit/models/test_dagrun.py
+++ b/airflow-core/tests/unit/models/test_dagrun.py
@@ -21,7 +21,7 @@ import datetime
from collections import defaultdict
from collections.abc import Mapping
from contextlib import contextmanager
-from functools import reduce
+from functools import partial, reduce
from typing import TYPE_CHECKING
from unittest import mock
from unittest.mock import ANY, call
@@ -1086,10 +1086,12 @@ class TestDagRun:
)
if state == DagRunState.RUNNING:
- func = DagRun.get_running_dag_runs_to_examine
+ fetch = partial(
+ DagRun.get_running_dag_runs_to_examine, session=session,
eagerly_load_dag_tags=False
+ )
else:
- func = DagRun.get_queued_dag_runs_to_set_running
- runs = func(session).all()
+ fetch = partial(DagRun.get_queued_dag_runs_to_set_running, session)
+ runs = fetch().all()
assert runs == [dr]
@@ -1097,7 +1099,7 @@ class TestDagRun:
session.merge(orm_dag)
session.commit()
- runs = func(session).all()
+ runs = fetch().all()
assert runs == []
@mock.patch("airflow._shared.observability.metrics.stats.timing")
@@ -4563,34 +4565,115 @@ class TestDagRunTracing:
assert trace_id != _EXTERNAL_TRACE_ID
-class TestDagRunStatsTagsTeamName:
- def test_stats_tags_without_team_name(self, dag_maker):
- """stats_tags should not include team_name when _team_name is not
set."""
- with dag_maker("test_dag"):
- EmptyOperator(task_id="t1")
- dr = dag_maker.create_dagrun()
- tags = dr.stats_tags
- assert "team_name" not in tags
- assert tags == {"dag_id": "test_dag", "run_type": "manual"}
+def test_stats_tags_without_team_name(dag_maker):
+ """stats_tags omits team_name when _team_name is not set."""
+ with dag_maker("test_dag"):
+ EmptyOperator(task_id="t1")
+ dr = dag_maker.create_dagrun()
+ assert dr.stats_tags == {"dag_id": "test_dag", "run_type": dr.run_type}
- def test_stats_tags_with_team_name(self, dag_maker):
- """stats_tags should include team_name when _team_name is set."""
- with dag_maker("test_dag"):
- EmptyOperator(task_id="t1")
- dr = dag_maker.create_dagrun()
- dr._team_name = "my_team"
- tags = dr.stats_tags
- assert tags["team_name"] == "my_team"
- assert tags == {"dag_id": "test_dag", "run_type": "manual",
"team_name": "my_team"}
-
- def test_stats_tags_with_none_team_name(self, dag_maker):
- """stats_tags should not include team_name when _team_name is None."""
- with dag_maker("test_dag"):
- EmptyOperator(task_id="t1")
- dr = dag_maker.create_dagrun()
- dr._team_name = None
- tags = dr.stats_tags
- assert "team_name" not in tags
+
+def test_stats_tags_with_team_name(dag_maker):
+ """stats_tags includes team_name when _team_name is set."""
+ with dag_maker("test_dag"):
+ EmptyOperator(task_id="t1")
+ dr = dag_maker.create_dagrun()
+ dr._team_name = "my_team"
+ assert dr.stats_tags == {"dag_id": "test_dag", "run_type": dr.run_type,
"team_name": "my_team"}
+
+
+def test_stats_tags_with_none_team_name(dag_maker):
+ """stats_tags omits team_name when _team_name is None."""
+ with dag_maker("test_dag"):
+ EmptyOperator(task_id="t1")
+ dr = dag_maker.create_dagrun()
+ dr._team_name = None
+ assert dr.stats_tags == {"dag_id": "test_dag", "run_type": dr.run_type}
+
+
+def test_stats_tags_dag_tags_disabled_by_default(dag_maker, session):
+ """With the flag off (the default), dag tags must not leak into metrics."""
+ with dag_maker("disabled_tag_dag", tags=["production", "env:prod"],
session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ _ = dr.dag_model.tags
+ assert dr.stats_tags == {"dag_id": "disabled_tag_dag", "run_type":
dr.run_type}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_without_dag_tags(dag_maker, session):
+ with dag_maker("no_tags_dag", session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ _ = dr.dag_model.tags
+ assert dr.stats_tags == {"dag_id": "no_tags_dag", "run_type": dr.run_type}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_with_standalone_dag_tag(dag_maker, session):
+ with dag_maker("standalone_tag_dag", tags=["production"], session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ _ = dr.dag_model.tags # eager-load so _dag_tags_for_stats sees the tags
+ tags = dr.stats_tags
+ assert tags == {"dag_id": "standalone_tag_dag", "run_type": "manual",
"production": ""}
+ # run_type is the bare value, not a DagRunType enum (serializes as
"manual", not "dagruntype.manual")
+ assert type(tags["run_type"]) is str
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_with_key_value_dag_tag(dag_maker, session):
+ with dag_maker("kv_tag_dag", tags=["env:staging"], session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ _ = dr.dag_model.tags
+ assert dr.stats_tags == {"dag_id": "kv_tag_dag", "run_type": dr.run_type,
"env": "staging"}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_builtin_keys_win_on_collision(dag_maker, session):
+ with dag_maker("collision_dag", tags=["dag_id:sneaky"], session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ _ = dr.dag_model.tags
+ # built-in dag_id wins over the colliding "dag_id:sneaky" tag
+ assert dr.stats_tags == {"dag_id": "collision_dag", "run_type":
dr.run_type}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_stats_tags_lazy_loads_dag_tags_when_not_eager_loaded(dag_maker,
session):
+ """When dag_model is not eager-loaded, stats_tags lazy-loads it in-session
so tags still appear."""
+ from sqlalchemy import inspect as sa_inspect
+
+ with dag_maker("lazy_tag_dag", tags=["env:prod"], session=session):
+ pass
+ dr = dag_maker.create_dagrun()
+ session.expire(dr, ["dag_model"]) # not eager-loaded
+ assert "dag_model" in sa_inspect(dr).unloaded
+
+ # lazy fallback loads dag_model.tags in-session, so the tags are still
emitted
+ assert dr.stats_tags == {"dag_id": "lazy_tag_dag", "run_type":
dr.run_type, "env": "prod"}
+
+
+@conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+def test_get_running_dag_runs_to_examine_eager_loads_dag_tags(dag_maker,
session):
+ """With the flag on, the scheduler query eager-loads dag_model.tags so
stats_tags fires no lazy load."""
+ from sqlalchemy import inspect as sa_inspect
+
+ with dag_maker("eager_tag_dag", tags=["env:prod"], session=session):
+ pass
+ dag_maker.create_dagrun(state=DagRunState.RUNNING)
+ session.commit()
+
+ dr = next(
+ r
+ for r in DagRun.get_running_dag_runs_to_examine(session=session,
eagerly_load_dag_tags=True)
+ if r.dag_id == "eager_tag_dag"
+ )
+ # dag_model and its tags are already populated — no lazy load needed at
metric-emission time.
+ assert "dag_model" not in sa_inspect(dr).unloaded
+ assert "tags" not in sa_inspect(dr.dag_model).unloaded
+ assert dr.stats_tags == {"dag_id": "eager_tag_dag", "run_type":
dr.run_type, "env": "prod"}
class TestClearPartitionRuns:
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py
b/airflow-core/tests/unit/models/test_taskinstance.py
index 7f1a193d963..e0d4a1063e9 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -2431,7 +2431,7 @@ class TestTaskInstance:
ti.task = None
ti.state = State.QUEUED
session.flush()
- expected_stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id}
+ expected_stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id,
"run_type": dr.run_type}
assert ti.task is None, "Check critical pre-condition"
@@ -4374,47 +4374,82 @@ def
test_task_instance_repr_does_not_raise_for_deferred_columns(dag_maker, sessi
class TestTaskInstanceStatsTagsTeamName:
def test_stats_tags_without_team_name(self, dag_maker, session):
- """stats_tags should not include team_name when _team_name is not
set."""
+ """stats_tags should not include team_name when the dag run has no
team_name."""
with dag_maker("test_dag"):
EmptyOperator(task_id="my_task")
dr = dag_maker.create_dagrun()
ti = dr.get_task_instance("my_task", session=session)
tags = ti.stats_tags
assert "team_name" not in tags
- assert tags == {"dag_id": "test_dag", "task_id": "my_task"}
+ assert tags == {"dag_id": "test_dag", "task_id": "my_task",
"run_type": dr.run_type}
def test_stats_tags_with_team_name(self, dag_maker, session):
- """stats_tags should include team_name when _team_name is set."""
+ """stats_tags takes team_name from the dag run's stats_tags."""
with dag_maker("test_dag"):
EmptyOperator(task_id="my_task")
dr = dag_maker.create_dagrun()
+ dr._team_name = "my_team"
ti = dr.get_task_instance("my_task", session=session)
- ti._team_name = "my_team"
tags = ti.stats_tags
assert tags["team_name"] == "my_team"
- assert tags == {"dag_id": "test_dag", "task_id": "my_task",
"team_name": "my_team"}
+ assert tags == {
+ "dag_id": "test_dag",
+ "task_id": "my_task",
+ "team_name": "my_team",
+ "run_type": dr.run_type,
+ }
def test_stats_tags_with_none_team_name(self, dag_maker, session):
- """stats_tags should not include team_name when _team_name is None."""
+ """stats_tags should not include team_name when the dag run's
team_name is None."""
with dag_maker("test_dag"):
EmptyOperator(task_id="my_task")
dr = dag_maker.create_dagrun()
+ dr._team_name = None
ti = dr.get_task_instance("my_task", session=session)
- ti._team_name = None
tags = ti.stats_tags
assert "team_name" not in tags
+ @conf_vars({("metrics", "dag_tags_in_metrics"): "True"})
+ def test_stats_tags_match_worker_tag_set(self, dag_maker, session):
+ """ti_failures (and other ti.* metrics) are emitted from both the
worker
+ (RuntimeTaskInstance.stats_tags) and the scheduler
(TaskInstance.stats_tags); both must produce
+ the same tag set. The worker side is asserted in test_task_runner.py
+ (test_stats_tags_with_standalone_and_key_value_tags); this guards the
scheduler side against drift:
+ dag tags + dag_id + task_id + run_type.
+ """
+ with dag_maker("parity_dag", tags=["env:prod", "production"],
session=session):
+ EmptyOperator(task_id="t1")
+ dr = dag_maker.create_dagrun()
+ ti = dr.get_task_instance("t1", session=session)
+ tags = ti.stats_tags
+ assert tags == {
+ "dag_id": "parity_dag",
+ "task_id": "t1",
+ "run_type": "manual",
+ "env": "prod",
+ "production": "",
+ }
+ # run_type must be the bare value (not a DagRunType enum) so it
serializes identically to
+ # the worker side, e.g. "manual" not "dagruntype.manual".
+ assert type(tags["run_type"]) is str
+
@pytest.mark.parametrize(
("team_name", "expected_tags"),
[
pytest.param(
"my_team",
- {"dag_id": "test_dag", "task_id": "my_task", "team_name":
"my_team", "queue": "default"},
+ {
+ "dag_id": "test_dag",
+ "task_id": "my_task",
+ "team_name": "my_team",
+ "queue": "default",
+ "run_type": "manual",
+ },
id="with_team",
),
pytest.param(
None,
- {"dag_id": "test_dag", "task_id": "my_task", "queue":
"default"},
+ {"dag_id": "test_dag", "task_id": "my_task", "queue":
"default", "run_type": "manual"},
id="without_team",
),
],
@@ -4430,7 +4465,7 @@ class TestTaskInstanceStatsTagsTeamName:
ti.state = TaskInstanceState.SCHEDULED
ti.scheduled_dttm = timezone.utcnow()
if team_name:
- ti._team_name = team_name
+ dr._team_name = team_name
session.merge(ti)
session.flush()
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/datadog_logger.py
b/shared/observability/src/airflow_shared/observability/metrics/datadog_logger.py
index 129354c3a43..b1fda8208aa 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/datadog_logger.py
+++
b/shared/observability/src/airflow_shared/observability/metrics/datadog_logger.py
@@ -58,6 +58,15 @@ class SafeDogStatsdLogger:
self.stat_name_handler = stat_name_handler
self.statsd_influxdb_enabled = statsd_influxdb_enabled
+ def _build_tags_list(self, tags: dict[str, str] | None) -> list[str]:
+ if not (self.metrics_tags and isinstance(tags, dict)):
+ return []
+ return [
+ (f"{key}:{value}" if value != "" else key)
+ for key, value in tags.items()
+ if self.metric_tags_validator.test(key)
+ ]
+
@validate_stat
def incr(
self,
@@ -68,12 +77,7 @@ class SafeDogStatsdLogger:
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)
if self.metrics_validator.test(stat):
return self.dogstatsd.increment(metric=stat, value=count,
tags=tags_list, sample_rate=rate)
return None
@@ -88,12 +92,7 @@ class SafeDogStatsdLogger:
tags: dict[str, str] | None = None,
) -> None:
"""Decrement 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)
if self.metrics_validator.test(stat):
return self.dogstatsd.decrement(metric=stat, value=count,
tags=tags_list, sample_rate=rate)
return None
@@ -109,12 +108,7 @@ class SafeDogStatsdLogger:
tags: dict[str, str] | None = None,
) -> None:
"""Gauge 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)
if self.metrics_validator.test(stat):
return self.dogstatsd.gauge(metric=stat, value=value,
tags=tags_list, sample_rate=rate)
return None
@@ -128,12 +122,7 @@ class SafeDogStatsdLogger:
tags: dict[str, str] | None = None,
) -> None:
"""Stats timing."""
- 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)
if self.metrics_validator.test(stat):
if isinstance(dt, datetime.timedelta):
dt = dt.total_seconds() * 1000.0
@@ -148,12 +137,7 @@ class SafeDogStatsdLogger:
**kwargs,
) -> Timer:
"""Timer metric that can be cancelled."""
- 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)
if stat and self.metrics_validator.test(stat):
return Timer(self.dogstatsd.timed(stat, tags=tags_list, **kwargs))
return Timer()
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/stats.py
b/shared/observability/src/airflow_shared/observability/metrics/stats.py
index 5140314922a..7b51e580cd0 100644
--- a/shared/observability/src/airflow_shared/observability/metrics/stats.py
+++ b/shared/observability/src/airflow_shared/observability/metrics/stats.py
@@ -20,7 +20,7 @@ import logging
import os
import re
import socket
-from collections.abc import Callable
+from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, Any
from .base_stats_logger import NoStatsLogger
@@ -35,6 +35,23 @@ log = logging.getLogger(__name__)
_VALID_STAT_NAME_CHARS_RE = re.compile(r"^[a-zA-Z0-9_.-]+$")
_INVALID_STAT_NAME_CHARS_RE = re.compile(r"[^a-zA-Z0-9_.-]")
+
+def build_dag_metric_tags(tag_names: Iterable[str]) -> dict[str, str]:
+ """
+ Convert Dag tag strings into metric tags.
+
+ Tags with a non-empty value after a ``:`` (e.g. ``env:prod``) split into a
+ ``key: value`` pair. Plain tags (e.g. ``production``) and tags with no
value
+ after the colon (e.g. ``env:``) map to an empty string, emitted as a
standalone
+ DogStatsd tag or as ``tag=true`` in InfluxDB line protocol.
+ """
+ result: dict[str, str] = {}
+ for name in tag_names:
+ key, _, value = name.partition(":")
+ result[key] = value
+ return result
+
+
# Module-level singleton state.
_factory: Callable[[], StatsLogger | NoStatsLogger] | None = None
_backend: StatsLogger | NoStatsLogger | None = None
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/statsd_logger.py
b/shared/observability/src/airflow_shared/observability/metrics/statsd_logger.py
index b5f39777241..fd78543e202 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/statsd_logger.py
+++
b/shared/observability/src/airflow_shared/observability/metrics/statsd_logger.py
@@ -52,8 +52,9 @@ def prepare_stat_with_tags(fn: T) -> T:
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
+ if all(c not in [",", "="] for c in f"{v_str}{k}"):
+ stat += f",{k}={v_str}"
else:
log.error("Dropping invalid tag: %s=%s.", k, v)
return fn(self, stat, *args, tags=tags, **kwargs)
diff --git a/shared/observability/tests/observability/metrics/test_stats.py
b/shared/observability/tests/observability/metrics/test_stats.py
index 04f2731248a..9bdb4c47312 100644
--- a/shared/observability/tests/observability/metrics/test_stats.py
+++ b/shared/observability/tests/observability/metrics/test_stats.py
@@ -34,6 +34,7 @@ from airflow_shared.observability.exceptions import
InvalidStatsNameException
from airflow_shared.observability.metrics import datadog_logger, statsd_logger
from airflow_shared.observability.metrics.base_stats_logger import StatsLogger
from airflow_shared.observability.metrics.datadog_logger import
SafeDogStatsdLogger
+from airflow_shared.observability.metrics.stats import build_dag_metric_tags
from airflow_shared.observability.metrics.statsd_logger import SafeStatsdLogger
from airflow_shared.observability.metrics.validators import (
PatternAllowListValidator,
@@ -266,6 +267,20 @@ class TestDogStats:
metric="empty", sample_rate=1, value=1, tags=[]
)
+ @pytest.mark.parametrize(
+ ("tags", "expected"),
+ [
+ ({"env": "prod"}, {"env:prod"}),
+ ({"production": ""}, {"production"}),
+ ({"production": "", "env": "staging"}, {"production",
"env:staging"}),
+ ],
+ )
+ def test_key_value_and_standalone_tags(self, tags, expected):
+ dogstatsd = SafeDogStatsdLogger(self.dogstatsd_client,
metrics_tags=True)
+ dogstatsd.incr("my_metric", tags=tags)
+ call_kwargs = self.dogstatsd_client.increment.call_args
+ assert set(call_kwargs.kwargs["tags"]) == expected
+
class TestStatsAllowAndBlockLists:
@pytest.mark.parametrize(
@@ -464,23 +479,35 @@ class TestStatsWithInfluxDBEnabled:
)
self.statsd_client.incr.assert_called_once_with("test_stats_run.delay", 1, 1)
- def test_increment_counter_with_tags(self):
- self.stats.incr(
- "test_stats_run.delay",
- tags={"key0": 0, "key1": "val1", "key2": "val2"},
- )
-
self.statsd_client.incr.assert_called_once_with("test_stats_run.delay,key0=0,key1=val1",
1, 1)
-
- def test_increment_counter_with_tags_and_forward_slash(self):
- self.stats.incr("test_stats_run.dag", tags={"path":
"/some/path/dag.py"})
-
self.statsd_client.incr.assert_called_once_with("test_stats_run.dag,path=/some/path/dag.py",
1, 1)
-
- def test_does_not_increment_counter_drops_invalid_tags(self):
- self.stats.incr(
- "test_stats_run.delay",
- tags={"key0,": "val0", "key1": "val1", "key2": "val2", "key3":
"val3"},
- )
-
self.statsd_client.incr.assert_called_once_with("test_stats_run.delay,key1=val1",
1, 1)
+ @pytest.mark.parametrize(
+ ("stat", "tags", "expected"),
+ [
+ (
+ "test_stats_run.delay",
+ {"key0": 0, "key1": "val1", "key2": "val2"},
+ "test_stats_run.delay,key0=0,key1=val1",
+ ),
+ (
+ "test_stats_run.dag",
+ {"path": "/some/path/dag.py"},
+ "test_stats_run.dag,path=/some/path/dag.py",
+ ),
+ (
+ "test_stats_run.delay",
+ {"key0,": "val0", "key1": "val1", "key2": "val2", "key3":
"val3"},
+ "test_stats_run.delay,key1=val1",
+ ),
+ # Empty value renders as `=true` in influxdb line protocol.
+ (
+ "test_stats_run.delay",
+ {"production": "", "key1": "val1"},
+ "test_stats_run.delay,production=true,key1=val1",
+ ),
+ ],
+ )
+ def test_increment_counter_with_tags(self, stat, tags, expected):
+ self.stats.incr(stat, tags=tags)
+ self.statsd_client.incr.assert_called_once_with(expected, 1, 1)
def always_invalid(stat_name):
@@ -776,3 +803,26 @@ class TestCustomStatsName:
def teardown_method(self) -> None:
# To avoid side-effect
importlib.reload(airflow_shared.observability.metrics.stats)
+
+
[email protected](
+ ("tag_names", "expected"),
+ [
+ pytest.param([], {}, id="empty"),
+ pytest.param(["production"], {"production": ""}, id="standalone"),
+ pytest.param(["env:prod"], {"env": "prod"}, id="key-value"),
+ pytest.param(
+ ["production", "env:prod", "team:data"],
+ {"production": "", "env": "prod", "team": "data"},
+ id="mixed",
+ ),
+ pytest.param(["a:b:c"], {"a": "b:c"}, id="value-with-colon"),
+ pytest.param(["env:"], {"env": ""}, id="trailing-colon-is-standalone"),
+ ],
+)
+def test_build_dag_metric_tags(tag_names: list[str], expected: dict[str, str])
-> None:
+ assert build_dag_metric_tags(tag_names) == expected
+
+
+def test_build_dag_metric_tags_accepts_generator() -> None:
+ assert build_dag_metric_tags(name for name in ["env:prod"]) == {"env":
"prod"}
diff --git a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
index 97d702e8125..31d0831bbad 100644
--- a/task-sdk/src/airflow/sdk/execution_time/task_runner.py
+++ b/task-sdk/src/airflow/sdk/execution_time/task_runner.py
@@ -45,6 +45,7 @@ from structlog.contextvars import bind_contextvars
from airflow.dag_processing.bundles.base import BaseDagBundle,
BundleVersionLock
from airflow.dag_processing.bundles.manager import DagBundlesManager
from airflow.sdk._shared.observability.metrics import stats
+from airflow.sdk._shared.observability.metrics.stats import
build_dag_metric_tags
from airflow.sdk._shared.observability.traces import get_task_span_detail_level
from airflow.sdk._shared.template_rendering import truncate_rendered_value
from airflow.sdk.api.client import get_hostname, getuser
@@ -259,10 +260,20 @@ 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.update(dag_id=self.dag_id, task_id=self.task_id)
+ if self._ti_context_from_server:
+ # run_type keeps the tag set consistent with the scheduler-side
TaskInstance.stats_tags.
+ # Coerce the DagRunType enum to its bare value so it serializes as
e.g. "scheduled" rather
+ # than "dagruntype.scheduled" (matching the scheduler, which emits
the plain string).
+ run_type = self._ti_context_from_server.dag_run.run_type
+ tags["run_type"] = getattr(run_type, "value", run_type)
+ if self._ti_context_from_server.dag_run.team_name:
+ tags["team_name"] =
self._ti_context_from_server.dag_run.team_name
return tags
def __rich_repr__(self):
diff --git a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
index bee3f67b5a0..0031974b866 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_task_runner.py
@@ -5529,11 +5529,11 @@ class TestTaskInstanceMetrics:
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
# verify ti.start was called in legacy format
- backend.incr.assert_any_call(f"ti.start.{ti.dag_id}.{ti.task_id}")
+ backend.incr.assert_any_call(f"ti.start.{ti.dag_id}.{ti.task_id}",
tags={"run_type": "manual"})
# verify ti.start was called in tagged format
backend.incr.assert_any_call(
"ti.start",
- tags={"dag_id": ti.dag_id, "task_id": ti.task_id},
+ tags={"dag_id": ti.dag_id, "task_id": ti.task_id, "run_type":
"manual"},
)
@pytest.mark.parametrize(
@@ -5558,11 +5558,18 @@ class TestTaskInstanceMetrics:
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
# verify ti.finish was called in legacy format
-
backend.incr.assert_any_call(f"ti.finish.{ti.dag_id}.{ti.task_id}.{expected_state}")
+ backend.incr.assert_any_call(
+ f"ti.finish.{ti.dag_id}.{ti.task_id}.{expected_state}",
tags={"run_type": "manual"}
+ )
# verify ti.finish was called in tagged format
backend.incr.assert_any_call(
"ti.finish",
- tags={"dag_id": ti.dag_id, "task_id": ti.task_id, "state":
expected_state},
+ tags={
+ "dag_id": ti.dag_id,
+ "task_id": ti.task_id,
+ "run_type": "manual",
+ "state": expected_state,
+ },
)
def test_operator_successes_metrics_emitted(self, create_runtime_ti,
mock_supervisor_comms):
@@ -5575,7 +5582,7 @@ class TestTaskInstanceMetrics:
mock_get_backend.return_value = backend
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
- stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id}
+ stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id,
"run_type": "manual"}
# verify operator_successes in legacy format
backend.incr.assert_any_call("operator_successes_PythonOperator",
tags=stats_tags)
@@ -5596,7 +5603,7 @@ class TestTaskInstanceMetrics:
mock_get_backend.return_value = backend
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
- stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id}
+ stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id,
"run_type": "manual"}
# verify operator_failures in legacy format
backend.incr.assert_any_call("operator_failures_PythonOperator",
tags=stats_tags)
@@ -5627,7 +5634,12 @@ class TestTaskInstanceMetrics:
mock_get_backend.return_value = backend
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
- expected = {"dag_id": ti.dag_id, "task_id": ti.task_id,
**expected_tags_extra}
+ expected = {
+ "dag_id": ti.dag_id,
+ "task_id": ti.task_id,
+ "run_type": "manual",
+ **expected_tags_extra,
+ }
backend.incr.assert_any_call("ti.start", tags=expected)
@pytest.mark.parametrize(
@@ -5649,7 +5661,12 @@ class TestTaskInstanceMetrics:
mock_get_backend.return_value = backend
run(ti, context=ti.get_template_context(), log=mock.MagicMock())
- stats_tags = {"dag_id": ti.dag_id, "task_id": ti.task_id,
"team_name": "team_a"}
+ stats_tags = {
+ "dag_id": ti.dag_id,
+ "task_id": ti.task_id,
+ "run_type": "manual",
+ "team_name": "team_a",
+ }
backend.incr.assert_any_call(
operator_metric,
tags={**stats_tags, "operator_name": "PythonOperator"},
@@ -6438,3 +6455,40 @@ class TestRegisterDeserializationAllowedClasses:
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
+
+ with DAG("tagged_dag", tags=tags):
+ task = BaseOperator(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):
+ tags = _make_dag_tagged_ti(create_runtime_ti, []).stats_tags
+ assert tags == {"dag_id": "tagged_dag", "task_id": "t", "run_type":
"manual"}
+ # run_type must be a plain str, not a DagRunType enum member: DagRunType
is a str-enum, so the
+ # dict equality above passes either way, but the enum serializes as
"dagruntype.manual" on the wire.
+ assert type(tags["run_type"]) is str
+
+
+@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",
+ }