This is an automated email from the ASF dual-hosted git repository.
kaxil 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 cd9f233f6d4 Add support for forwarding CeleryExecutor task logs to
stdout (#69597)
cd9f233f6d4 is described below
commit cd9f233f6d47374bcab853986b1588123c47318f
Author: Jayachandra Kasarla <[email protected]>
AuthorDate: Wed Oct 14 05:54:31 2026 +0530
Add support for forwarding CeleryExecutor task logs to stdout (#69597)
---
airflow-core/newsfragments/69597.feature.rst | 1 +
.../src/airflow/config_templates/config.yml | 14 +++
.../src/airflow/executors/base_executor.py | 7 +-
.../tests/unit/executors/test_base_executor.py | 46 ++++++++
providers/celery/docs/celery_executor.rst | 51 +++++++++
providers/celery/provider.yaml | 14 +++
.../celery/executors/celery_executor_utils.py | 27 ++++-
.../airflow/providers/celery/get_provider_info.py | 7 ++
.../unit/celery/executors/test_celery_executor.py | 124 ++++++++++++++-------
9 files changed, 248 insertions(+), 43 deletions(-)
diff --git a/airflow-core/newsfragments/69597.feature.rst
b/airflow-core/newsfragments/69597.feature.rst
new file mode 100644
index 00000000000..f1457ab2dfc
--- /dev/null
+++ b/airflow-core/newsfragments/69597.feature.rst
@@ -0,0 +1 @@
+Add ``[logging] task_logs_to_stdout`` core config option to forward task
subprocess stdout/stderr to the worker's own stdout, so a container-level log
collector (e.g. Kubernetes/Loki) captures task logs in addition to the task-log
handler and the UI. It is only consulted by components that do not already
decide this themselves (currently the Celery worker and the Edge3 worker's fork
path); executors that always forward task logs to stdout, such as the
LocalExecutor and the Kubernetes, EC [...]
diff --git a/airflow-core/src/airflow/config_templates/config.yml
b/airflow-core/src/airflow/config_templates/config.yml
index 12089e4c869..2ef0ebdd916 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -939,6 +939,20 @@ logging:
type: boolean
example: "True"
default: "False"
+ task_logs_to_stdout:
+ description: |
+ Also forward task subprocess stdout/stderr to the worker's own stdout,
so task logs reach
+ a container-level log collector (e.g. Kubernetes/Loki) in addition to
the task-log handler
+ and the UI. This is not a global switch: it is only consulted when a
caller leaves
+ ``subprocess_logs_to_stdout`` out of ``BaseExecutor.run_workload``.
Executors that already pass
+ ``True`` explicitly (the LocalExecutor and the per-task entrypoint
used by the Kubernetes, ECS,
+ Batch and Lambda executors) always forward task logs to stdout and
ignore this setting. Currently
+ it is read by the Celery worker (which resolves ``[celery]
task_logs_to_stdout`` first, then this
+ setting) and by the Edge3 worker's fork path on Airflow 3.4+.
+ version_added: 3.4.0
+ type: boolean
+ example: "True"
+ default: "False"
uvicorn_logging_level:
description: |
Logging level for uvicorn (API server and serve-logs).
diff --git a/airflow-core/src/airflow/executors/base_executor.py
b/airflow-core/src/airflow/executors/base_executor.py
index c00c8f70e79..0e86013e0a7 100644
--- a/airflow-core/src/airflow/executors/base_executor.py
+++ b/airflow-core/src/airflow/executors/base_executor.py
@@ -860,7 +860,7 @@ class BaseExecutor(LoggingMixin):
*,
server: str | None = None,
dry_run: bool = False,
- subprocess_logs_to_stdout: bool = False,
+ subprocess_logs_to_stdout: bool | None = None,
proctitle: str | None = None,
) -> int:
"""
@@ -873,10 +873,15 @@ class BaseExecutor(LoggingMixin):
:param server: Base URL of the API server (used by task workloads).
:param dry_run: If True, execute without actual task execution
(simulate run).
:param subprocess_logs_to_stdout: Should task logs also be sent to
stdout via the main logger.
+ When not passed (``None``), falls back to the core ``[logging]
task_logs_to_stdout``
+ setting, so executors that do not need a worker-level override can
rely on this default.
:param proctitle: Process title to set for this workload. If not
provided, defaults to
``"airflow supervisor: <workload.display_name>"``.
:return: Exit code of the process.
"""
+ if subprocess_logs_to_stdout is None:
+ subprocess_logs_to_stdout = conf.getboolean("logging",
"task_logs_to_stdout", fallback=False)
+
try:
if sys.platform != "darwin":
from setproctitle import setproctitle
diff --git a/airflow-core/tests/unit/executors/test_base_executor.py
b/airflow-core/tests/unit/executors/test_base_executor.py
index 7b2d1051eb2..a44cde67846 100644
--- a/airflow-core/tests/unit/executors/test_base_executor.py
+++ b/airflow-core/tests/unit/executors/test_base_executor.py
@@ -941,6 +941,52 @@ def
test_run_workload_passes_team_name_to_connection_test_supervisor(mock_superv
)
[email protected]("airflow.sdk.execution_time.supervisor.supervise_task")
[email protected](
+ ("config_overrides", "explicit_arg", "expected"),
+ [
+ pytest.param({}, None, False, id="unset-defaults-to-false"),
+ pytest.param({("logging", "task_logs_to_stdout"): "True"}, None, True,
id="logging-conf-true"),
+ pytest.param({("logging", "task_logs_to_stdout"): "False"}, None,
False, id="logging-conf-false"),
+ pytest.param(
+ {("logging", "task_logs_to_stdout"): "True"}, False, False,
id="explicit-false-overrides-conf"
+ ),
+ pytest.param(
+ {("logging", "task_logs_to_stdout"): "False"}, True, True,
id="explicit-true-overrides-conf"
+ ),
+ ],
+)
+def test_run_workload_subprocess_logs_to_stdout(
+ mock_supervise_task, config_overrides, explicit_arg, expected
+):
+ """An omitted subprocess_logs_to_stdout falls back to [logging]
task_logs_to_stdout; an explicit one wins."""
+ mock_supervise_task.return_value = 0
+ wl = workloads.ExecuteTask(
+ ti=workloads.TaskInstance(
+ id="00000000-0000-0000-0000-000000000001",
+ dag_version_id="00000000-0000-0000-0000-000000000002",
+ task_id="test_task",
+ dag_id="test_dag",
+ run_id="test_run",
+ try_number=1,
+ map_index=-1,
+ pool_slots=1,
+ queue="default",
+ priority_weight=1,
+ ),
+ dag_rel_path="test_dag.py",
+ bundle_info=BundleInfo(name="test-bundle", version=None),
+ token="test-token",
+ log_path="test.log",
+ )
+ kwargs = {} if explicit_arg is None else {"subprocess_logs_to_stdout":
explicit_arg}
+
+ with conf_vars(config_overrides):
+ BaseExecutor.run_workload(wl,
server="http://localhost:8080/execution/", **kwargs)
+
+ assert mock_supervise_task.call_args.kwargs["subprocess_logs_to_stdout"]
is expected
+
+
@mock.patch.dict("os.environ", {}, clear=True)
class TestExecutorConf:
"""Test ExecutorConf shim class that provides team-specific configuration
access."""
diff --git a/providers/celery/docs/celery_executor.rst
b/providers/celery/docs/celery_executor.rst
index 18637f47e55..31a1619ffb0 100644
--- a/providers/celery/docs/celery_executor.rst
+++ b/providers/celery/docs/celery_executor.rst
@@ -236,6 +236,11 @@ components) or override it for the worker alone with the
``[celery]`` section:
[celery]
json_logs = True
+A Celery worker runs up to ``worker_concurrency`` tasks at once, so lines from
concurrent
+tasks interleave on the worker's stdout. Raw ``print``/stderr lines are
forwarded with
+only ``logger="task.stdout"`` and carry no task instance ID, so a log
collector cannot
+attribute them to a task.
+
The lookup order is:
1. ``[celery] json_logs`` — if set, takes precedence.
@@ -253,6 +258,52 @@ code.
``[celery] json_logs = True`` is the safe way to enable JSON logs
regardless
of the core version.
+Task log forwarding to stdout
+------------------------------
+
+Task logs are normally only written to the task-log handler (and surfaced in
the
+UI). To also forward each task's subprocess stdout/stderr to the Celery
worker's
+own stdout — so a container-level log collector (e.g. Kubernetes/Loki) captures
+them too — enable it via the ``[logging]`` section or override it for the
Celery
+worker alone with the ``[celery]`` section. ``[logging] task_logs_to_stdout``
is not a
+global switch: it only applies to components that read it, or that leave
+``subprocess_logs_to_stdout`` unset when calling ``run_workload`` (currently
the Celery
+worker and the Edge3 worker's fork path). Executors that always forward task
logs to stdout, such as the
+LocalExecutor and the Kubernetes, ECS, Batch and Lambda executors, ignore it:
+
+.. code-block:: ini
+
+ # Default for components that don't set the behaviour themselves (Celery,
Edge3):
+ [logging]
+ task_logs_to_stdout = True
+
+ # Or override for the Celery worker only, leaving other components
unchanged:
+ [celery]
+ task_logs_to_stdout = True
+
+A Celery worker runs up to ``worker_concurrency`` tasks at once, so lines from
concurrent
+tasks interleave on the worker's stdout. Raw ``print``/stderr lines are
forwarded with
+only ``logger="task.stdout"`` and carry no task instance ID, so a log
collector cannot
+attribute them to a task.
+
+The lookup order is:
+
+1. ``[celery] task_logs_to_stdout`` — if set, takes precedence.
+2. ``[logging] task_logs_to_stdout`` — used when the celery-specific key is
absent.
+3. ``False`` — the default when neither key is configured.
+
+.. note::
+
+ This is Airflow 3+ only. On ``apache-airflow<3.0.0``, Celery tasks are
routed
+ through ``execute_command`` and never reach ``supervise``, so setting
either key
+ has no effect there.
+
+ ``[logging] task_logs_to_stdout`` is consulted natively by Airflow core
starting
+ in 3.4.0. On earlier 3.x versions this provider resolves the ``[celery]``/
+ ``[logging]`` fallback itself, so the global key still takes effect on
+ Airflow 3.0–3.3 as long as this provider version is installed — it is not
+ limited to 3.4+.
+
.. _celery_executor:queue:
Queues
diff --git a/providers/celery/provider.yaml b/providers/celery/provider.yaml
index e26ecc6971e..80277f56fc1 100644
--- a/providers/celery/provider.yaml
+++ b/providers/celery/provider.yaml
@@ -232,6 +232,20 @@ config:
type: boolean
example: ~
default: ~
+ task_logs_to_stdout:
+ description: |
+ Also forward task subprocess stdout/stderr to the Celery worker's
own stdout,
+ so task logs reach a container-level log collector (e.g.
Kubernetes/Loki) in
+ addition to the task-log handler and the UI. When set, this takes
precedence over the global
+ ``[logging] task_logs_to_stdout`` setting, allowing the Celery
worker to override
+ it independently. When unset (the default), the value falls back to
+ ``[logging] task_logs_to_stdout``, which itself defaults to
``False``. Airflow 3+
+ only. On apache-airflow<3.0.0 tasks are still routed through
``execute_command``
+ rather than ``supervise``.
+ version_added: ~
+ type: boolean
+ example: ~
+ default: ~
broker_url:
description: |
The Celery broker URL. Celery supports multiple broker types. See:
diff --git
a/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
b/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
index 39d52deb1dd..c1355c2ac55 100644
---
a/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
+++
b/providers/celery/src/airflow/providers/celery/executors/celery_executor_utils.py
@@ -98,6 +98,20 @@ OPERATION_TIMEOUT = conf.getfloat("celery",
"operation_timeout")
CELERY_FETCH_ERR_MSG_HEADER = "Error fetching Celery task state"
+def _celery_task_logs_to_stdout_override() -> bool | None:
+ """
+ Resolve the ``[celery] task_logs_to_stdout`` worker-level override.
+
+ Returns ``None`` when unset so callers can fall back to the core
+ ``[logging] task_logs_to_stdout`` default, mirroring the ``[celery]
json_logs`` /
+ ``[logging] json_logs`` two-level lookup.
+ """
+ value = conf.get("celery", "task_logs_to_stdout", fallback="")
+ if value and value.lower() != "none":
+ return conf.getboolean("celery", "task_logs_to_stdout")
+ return None
+
+
@cache
def get_celery_configuration() -> dict[str, Any]:
"""Get the Celery configuration dictionary."""
@@ -240,7 +254,13 @@ def execute_workload(input: str) -> None:
log.info("[%s] Executing workload in Celery: %s", celery_task_id, workload)
try:
- BaseExecutor.run_workload(workload)
+ subprocess_logs_to_stdout = _celery_task_logs_to_stdout_override()
+ if subprocess_logs_to_stdout is None:
+ subprocess_logs_to_stdout = conf.getboolean("logging",
"task_logs_to_stdout", fallback=False)
+ BaseExecutor.run_workload(
+ workload,
+ subprocess_logs_to_stdout=subprocess_logs_to_stdout,
+ )
except Exception as e:
from airflow.sdk.exceptions import TaskAlreadyRunningError
@@ -277,6 +297,10 @@ def _execute_workload_pre_3_3(input: str) -> None:
try:
if isinstance(workload, workloads.ExecuteTask):
+ subprocess_logs_to_stdout = _celery_task_logs_to_stdout_override()
+ if subprocess_logs_to_stdout is None:
+ # No run_workload() to apply the core default on this pre-3.3
path, so resolve it here.
+ subprocess_logs_to_stdout = conf.getboolean("logging",
"task_logs_to_stdout", fallback=False)
supervise(
# This is the "wrong" ti type, but it duck types the same.
TODO: Create a protocol for this.
ti=workload.ti, # type: ignore[arg-type]
@@ -285,6 +309,7 @@ def _execute_workload_pre_3_3(input: str) -> None:
token=workload.token,
server=conf.get("core", "execution_api_server_url",
fallback=default_execution_api_server),
log_path=workload.log_path,
+ subprocess_logs_to_stdout=subprocess_logs_to_stdout,
)
else:
raise ValueError(f"CeleryExecutor does not know how to handle
{type(workload)}")
diff --git a/providers/celery/src/airflow/providers/celery/get_provider_info.py
b/providers/celery/src/airflow/providers/celery/get_provider_info.py
index 7fd3ba29bde..ec9be555b00 100644
--- a/providers/celery/src/airflow/providers/celery/get_provider_info.py
+++ b/providers/celery/src/airflow/providers/celery/get_provider_info.py
@@ -117,6 +117,13 @@ def get_provider_info():
"example": None,
"default": None,
},
+ "task_logs_to_stdout": {
+ "description": "Also forward task subprocess
stdout/stderr to the Celery worker's own stdout,\nso task logs reach a
container-level log collector (e.g. Kubernetes/Loki) in\naddition to the
task-log handler and the UI. When set, this takes precedence over the
global\n``[logging] task_logs_to_stdout`` setting, allowing the Celery worker
to override\nit independently. When unset (the default), the value falls back
to\n``[logging] task_logs_to_stdout``, which itself d [...]
+ "version_added": None,
+ "type": "boolean",
+ "example": None,
+ "default": None,
+ },
"broker_url": {
"description": "The Celery broker URL. Celery supports
multiple broker types.
See:\nhttps://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/index.html#broker-overview\n",
"version_added": None,
diff --git
a/providers/celery/tests/unit/celery/executors/test_celery_executor.py
b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
index bbd1493b32f..39d7bf0175b 100644
--- a/providers/celery/tests/unit/celery/executors/test_celery_executor.py
+++ b/providers/celery/tests/unit/celery/executors/test_celery_executor.py
@@ -23,8 +23,9 @@ import os
import signal
import sys
from datetime import timedelta
+from pathlib import Path
from unittest import mock
-from uuid import uuid4
+from uuid import UUID, uuid4
# Leave this it is used by the test worker.
import celery.contrib.testing.tasks # noqa: F401
@@ -1270,16 +1271,15 @@ def test_process_workloads_routes_execute_callback(
mock_send_workloads.assert_called_once_with([(workload.callback.key,
workload, expected_queue, None)])
[email protected](not AIRFLOW_V_3_0_PLUS, reason="execute_workload is only
used for Airflow 3+")
[email protected](AIRFLOW_V_3_3_PLUS, reason="pre-3.3 compatibility path
only applies before Airflow 3.3")
-def test_execute_workload_runs_execute_task_before_airflow_3_3():
- """execute_workload routes serialized ExecuteTask payloads to supervise on
Airflow 3.0-3.2."""
[email protected]
+def execute_task_workload_json() -> str:
+ """A serialized ExecuteTask workload usable by execute_workload tests."""
from airflow.executors import workloads
workload = workloads.ExecuteTask(
ti=workloads.TaskInstance(
- id="00000000-0000-0000-0000-000000000001",
- dag_version_id="00000000-0000-0000-0000-000000000002",
+ id=UUID("00000000-0000-0000-0000-000000000001"),
+ dag_version_id=UUID("00000000-0000-0000-0000-000000000002"),
task_id="test_task",
dag_id="test_dag",
run_id="test_run",
@@ -1289,21 +1289,31 @@ def
test_execute_workload_runs_execute_task_before_airflow_3_3():
queue="default",
priority_weight=1,
),
- dag_rel_path="test_dag.py",
+ dag_rel_path=Path("test_dag.py"),
bundle_info=workloads.BundleInfo(name="test-bundle", version=None),
token="test-token",
log_path="test.log",
)
+ return workload.model_dump_json()
+
+
[email protected]
+def mock_celery_app():
+ """Patch celery_executor_utils.app with a worker whose current_task id is
fixed."""
mock_current_task = mock.MagicMock()
mock_current_task.request.id = "test-celery-task-id"
mock_app = mock.MagicMock()
mock_app.current_task = mock_current_task
+ with mock.patch.object(celery_executor_utils, "app", mock_app):
+ yield mock_app
- with (
- mock.patch.object(celery_executor_utils, "app", mock_app),
- mock.patch("airflow.sdk.execution_time.supervisor.supervise") as
mock_supervise,
- ):
-
celery_executor_utils.execute_workload.__wrapped__(workload.model_dump_json())
+
[email protected](not AIRFLOW_V_3_0_PLUS, reason="execute_workload is only
used for Airflow 3+")
[email protected](AIRFLOW_V_3_3_PLUS, reason="pre-3.3 compatibility path
only applies before Airflow 3.3")
+def
test_execute_workload_runs_execute_task_before_airflow_3_3(execute_task_workload_json,
mock_celery_app):
+ """execute_workload routes serialized ExecuteTask payloads to supervise on
Airflow 3.0-3.2."""
+ with mock.patch("airflow.sdk.execution_time.supervisor.supervise") as
mock_supervise:
+
celery_executor_utils.execute_workload.__wrapped__(execute_task_workload_json)
mock_supervise.assert_called_once()
assert mock_supervise.call_args.kwargs["ti"].task_id == "test_task"
@@ -1312,39 +1322,71 @@ def
test_execute_workload_runs_execute_task_before_airflow_3_3():
assert mock_supervise.call_args.kwargs["log_path"] == "test.log"
[email protected](not AIRFLOW_V_3_3_PLUS, reason="3.3+ path uses
BaseExecutor.run_workload")
-def test_execute_workload_runs_base_executor_workload_on_airflow_3_3_plus():
- """execute_workload routes serialized ExecuteTask payloads to BaseExecutor
on Airflow 3.3+."""
- from airflow.executors import workloads
-
- workload = workloads.ExecuteTask(
- ti=workloads.TaskInstance(
- id="00000000-0000-0000-0000-000000000001",
- dag_version_id="00000000-0000-0000-0000-000000000002",
- task_id="test_task",
- dag_id="test_dag",
- run_id="test_run",
- try_number=1,
- map_index=-1,
- pool_slots=1,
- queue="default",
- priority_weight=1,
[email protected](not AIRFLOW_V_3_0_PLUS, reason="execute_workload is only
used for Airflow 3+")
[email protected](AIRFLOW_V_3_3_PLUS, reason="pre-3.3 compatibility path
only applies before Airflow 3.3")
[email protected](
+ ("config_overrides", "expected"),
+ [
+ pytest.param({}, False, id="unset-defaults-to-false"),
+ pytest.param({("logging", "task_logs_to_stdout"): "True"}, True,
id="global-fallback"),
+ pytest.param({("celery", "task_logs_to_stdout"): "True"}, True,
id="celery-override-only"),
+ pytest.param(
+ {("logging", "task_logs_to_stdout"): "True", ("celery",
"task_logs_to_stdout"): "False"},
+ False,
+ id="celery-overrides-global",
),
- dag_rel_path="test_dag.py",
- bundle_info=workloads.BundleInfo(name="test-bundle", version=None),
- token="test-token",
- log_path="test.log",
- )
- mock_current_task = mock.MagicMock()
- mock_current_task.request.id = "test-celery-task-id"
- mock_app = mock.MagicMock()
- mock_app.current_task = mock_current_task
+ ],
+)
+def test_execute_workload_forwards_task_logs_to_stdout_before_airflow_3_3(
+ execute_task_workload_json, mock_celery_app, config_overrides, expected
+):
+ """Before Airflow 3.3, supervise() receives the resolved
[celery]/[logging] task_logs_to_stdout value."""
+ with (
+ conf_vars(config_overrides),
+ mock.patch("airflow.sdk.execution_time.supervisor.supervise") as
mock_supervise,
+ ):
+
celery_executor_utils.execute_workload.__wrapped__(execute_task_workload_json)
+
+ assert mock_supervise.call_args.kwargs["subprocess_logs_to_stdout"] is
expected
+
[email protected](not AIRFLOW_V_3_3_PLUS, reason="3.3+ path uses
BaseExecutor.run_workload")
[email protected](
+ ("config_overrides", "expected"),
+ [
+ pytest.param({}, False, id="unset-defaults-to-false"),
+ pytest.param({("logging", "task_logs_to_stdout"): "True"}, True,
id="global-fallback"),
+ pytest.param({("celery", "task_logs_to_stdout"): "True"}, True,
id="celery-override-only"),
+ pytest.param({("celery", "task_logs_to_stdout"): "False"}, False,
id="celery-override-false"),
+ pytest.param(
+ {("logging", "task_logs_to_stdout"): "True", ("celery",
"task_logs_to_stdout"): "False"},
+ False,
+ id="celery-overrides-global",
+ ),
+ ],
+)
+def test_execute_workload_forwards_task_logs_to_stdout_on_airflow_3_3_plus(
+ execute_task_workload_json, mock_celery_app, config_overrides, expected
+):
+ """On Airflow 3.3+, run_workload() receives the resolved
[celery]/[logging] task_logs_to_stdout
+ value, since older 3.3.x releases predate core's own [logging] fallback in
run_workload().
+ """
with (
- mock.patch.object(celery_executor_utils, "app", mock_app),
+ conf_vars(config_overrides),
mock.patch("airflow.executors.base_executor.BaseExecutor.run_workload") as
mock_run_workload,
):
-
celery_executor_utils.execute_workload.__wrapped__(workload.model_dump_json())
+
celery_executor_utils.execute_workload.__wrapped__(execute_task_workload_json)
+
+ assert mock_run_workload.call_args.kwargs["subprocess_logs_to_stdout"] is
expected
+
+
[email protected](not AIRFLOW_V_3_3_PLUS, reason="3.3+ path uses
BaseExecutor.run_workload")
+def test_execute_workload_runs_base_executor_workload_on_airflow_3_3_plus(
+ execute_task_workload_json, mock_celery_app
+):
+ """execute_workload routes serialized ExecuteTask payloads to BaseExecutor
on Airflow 3.3+."""
+ with
mock.patch("airflow.executors.base_executor.BaseExecutor.run_workload") as
mock_run_workload:
+
celery_executor_utils.execute_workload.__wrapped__(execute_task_workload_json)
mock_run_workload.assert_called_once()
decoded_workload = mock_run_workload.call_args.args[0]