You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by ja...@apache.org on 2026/06/22 01:37:09 UTC

(airflow) branch main updated: Propagate resolved task log level to language SDK runtimes (#68712)

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

jason810496 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 db6103c0acd Propagate resolved task log level to language SDK runtimes (#68712)
db6103c0acd is described below

commit db6103c0acdbe4b0e7d90dd2df5c6ba786f3507f
Author: Jason(Zhe-You) Liu <68...@users.noreply.github.com>
AuthorDate: Mon Jun 22 10:36:59 2026 +0900

    Propagate resolved task log level to language SDK runtimes (#68712)
    
    * Propagate resolved task log level to language SDK runtimes
    
    A language-SDK runtime (Java, Go) launched by a coordinator cannot read
    Airflow's configuration, so it could only guess the task log level from
    inherited environment variables. The subprocess starts before the
    StartupDetails handshake arrives, so carrying the level in that message
    would reach it too late. Passing the supervisor-resolved level in the
    environment at launch makes it available before the runtime configures
    logging.
    
    * Propagate per-logger namespace levels to language SDK runtimes
    
    The root log level alone does not reflect a user's per-logger overrides
    (`[logging] namespace_levels`), so a runtime would still emit logs the
    worker is configured to suppress. Passing the levels alongside the root
    level keeps the runtime's filtering consistent with the rest of Airflow.
    Each language SDK reads and applies these on its own, so the contract is
    documented for SDK authors.
    
    * Remove logging metion in user-facing logs
    
    This should simply work, so we don't need to get into them.
    
    * Always set AIRFLOW__LOGGING__NAMESPACE_LEVELS
    
    Even when the configuration is empty, we can still set the environ
    without harm. This would actually be easier to handle in the runtime.
    
    ---------
    
    Co-authored-by: Tzu-ping Chung <ur...@gmail.com>
---
 .../src/airflow/sdk/coordinators/_subprocess.py    | 11 +++++++++++
 .../tests/task_sdk/coordinators/test_subprocess.py | 22 ++++++++++++++++++++++
 2 files changed, 33 insertions(+)

diff --git a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
index bac9f828b78..9550fdd0bc4 100644
--- a/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
+++ b/task-sdk/src/airflow/sdk/coordinators/_subprocess.py
@@ -41,6 +41,7 @@ import attrs
 import psutil
 import structlog
 
+from airflow.sdk.configuration import conf
 from airflow.sdk.execution_time.coordinator import BaseCoordinator
 from airflow.sdk.execution_time.supervisor import ActivitySubprocess, NeverRaised, ProcessTracker
 
@@ -293,6 +294,15 @@ class _PopenActivitySubprocess(ActivitySubprocess):
             stdout_r, stdout_w = tracker.track(*socket.socketpair())
             stderr_r, stderr_w = tracker.track(*socket.socketpair())
 
+            # A language SDK runtime cannot read Airflow's config, so propagate the
+            # resolved log levels via the environment at launch. StartupDetails
+            # arrives too late, the logs might already be produced by then.
+            env = {
+                **os.environ,
+                "AIRFLOW__LOGGING__LOGGING_LEVEL": conf.get("logging", "logging_level", fallback="INFO"),
+                "AIRFLOW__LOGGING__NAMESPACE_LEVELS": conf.get("logging", "namespace_levels", fallback=""),
+            }
+
             proc = subprocess.Popen(
                 [
                     *command,
@@ -301,6 +311,7 @@ class _PopenActivitySubprocess(ActivitySubprocess):
                 ],
                 stdout=stdout_w.fileno(),
                 stderr=stderr_w.fileno(),
+                env=env,
             )
             tracker.track(proc)
             for soc in tracker.untrack(stdout_w, stderr_w):
diff --git a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
index 8bdd905c6b0..5a89c73e780 100644
--- a/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
+++ b/task-sdk/tests/task_sdk/coordinators/test_subprocess.py
@@ -45,6 +45,7 @@ from airflow.sdk.coordinators._subprocess import (
 from airflow.sdk.execution_time.coordinator import BaseCoordinator
 from airflow.sdk.execution_time.supervisor import ActivitySubprocess
 
+from tests_common.test_utils.config import conf_vars
 from tests_common.test_utils.version_compat import AIRFLOW_V_3_3_PLUS
 
 if not AIRFLOW_V_3_3_PLUS:
@@ -763,6 +764,27 @@ class TestPopenActivitySubprocessStart:
         assert kwargs["ti"] is ti
         assert kwargs["dag_rel_path"] == "bundle"
 
+    @conf_vars({("logging", "logging_level"): "DEBUG"})
+    def test_resolved_log_level_passed_to_subprocess_env(self, mock_client):
+        """A language SDK runtime gets the resolved task log level via the environment at launch."""
+        _, popen_mock, _ = self._start_with_mocks(mock_client, command=["/bin/true"])
+        env = popen_mock.call_args.kwargs["env"]
+        assert env["AIRFLOW__LOGGING__LOGGING_LEVEL"] == "DEBUG"
+
+    @conf_vars({("logging", "namespace_levels"): "sqlalchemy=INFO, botocore=WARNING"})
+    def test_namespace_levels_passed_to_subprocess_env(self, mock_client):
+        """Per-logger levels are propagated verbatim for the runtime to parse."""
+        _, popen_mock, _ = self._start_with_mocks(mock_client, command=["/bin/true"])
+        env = popen_mock.call_args.kwargs["env"]
+        assert env["AIRFLOW__LOGGING__NAMESPACE_LEVELS"] == "sqlalchemy=INFO, botocore=WARNING"
+
+    @conf_vars({("logging", "namespace_levels"): ""})
+    def test_namespace_levels_omitted_when_unset(self, mock_client):
+        """An empty value has no pairs to parse, so the variable is left out."""
+        _, popen_mock, _ = self._start_with_mocks(mock_client, command=["/bin/true"])
+        env = popen_mock.call_args.kwargs["env"]
+        assert env["AIRFLOW__LOGGING__NAMESPACE_LEVELS"] == ""
+
     def test_register_pipe_readers_called_with_four_sockets(self, mock_client):
         """Both socketpair read-ends and both TCP sockets must be registered, with a data kwarg."""
         with (