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 9068fba9cb7 Fix remote processor injection happening before dictConfig
runs in configure_logging (#66633)
9068fba9cb7 is described below
commit 9068fba9cb79496d726fc78dd57f6400b7544482
Author: Idris Ibrahim <[email protected]>
AuthorDate: Thu Oct 8 08:50:29 2026 +0100
Fix remote processor injection happening before dictConfig runs in
configure_logging (#66633)
In configure_logging(), getattr(remote, 'processors') was called before
dictConfig() ran. dictConfig() calls _clearExistingHandlers() which closes
every handler in logging._handlerList — including the remote handler built
moments earlier. This caused CloudWatch/Watchtower logs to be silently
dropped when using the Task SDK with remote logging on ECS/Kubernetes
workers.
Fix: move the remote processor injection to after dictConfig() has run via
a second structlog.configure() call. Also gate on not sending_to_supervisor
to avoid unmasked events from the task subprocess, and add a None default
to getattr() to handle third-party RemoteLogIO objects.
Provider-side fix: #68779
Closes #66475
* Keep remote task log handlers alive during setup
dictConfig can close a remote handler before it receives its first
task-log record, silently dropping remote logs. The logging setup must
avoid that lifecycle conflict while remaining compatible with remote
handlers that do not provide custom processors.
* Cover remote handlers without custom processors
Handlers supplied by third-party remote logging implementations may not
define custom processors. Keep the regression coverage explicit so that
compatibility does not silently change.
* Cover remote handlers without custom processors
Handlers supplied by third-party remote logging implementations may not
define custom processors. Keep the regression coverage explicit so that
compatibility does not silently change.
---
airflow-core/newsfragments/66633.bugfix.rst | 1 +
task-sdk/src/airflow/sdk/log.py | 15 +++--
task-sdk/tests/task_sdk/test_log.py | 89 +++++++++++++++++++++++++++++
3 files changed, 101 insertions(+), 4 deletions(-)
diff --git a/airflow-core/newsfragments/66633.bugfix.rst
b/airflow-core/newsfragments/66633.bugfix.rst
new file mode 100644
index 00000000000..5ea85be3152
--- /dev/null
+++ b/airflow-core/newsfragments/66633.bugfix.rst
@@ -0,0 +1 @@
+Fix remote task logs being silently dropped when ``dictConfig`` closes a
remote logging handler during configuration.
diff --git a/task-sdk/src/airflow/sdk/log.py b/task-sdk/src/airflow/sdk/log.py
index e105fd70d76..849a7eca209 100644
--- a/task-sdk/src/airflow/sdk/log.py
+++ b/task-sdk/src/airflow/sdk/log.py
@@ -74,7 +74,7 @@ def logging_processors(
if mask_secrets:
extra_processors += (mask_logs,)
- if (remote := load_remote_log_handler()) and (remote_processors :=
getattr(remote, "processors")):
+ if (remote := load_remote_log_handler()) and (remote_processors :=
getattr(remote, "processors", None)):
extra_processors += remote_processors
procs, _, final_writer = structlog_processors(
@@ -119,9 +119,6 @@ def configure_logging(
if mask_secrets:
extra_processors += (mask_logs,)
- if (remote := load_remote_log_handler()) and (remote_processors :=
getattr(remote, "processors")):
- extra_processors += remote_processors
-
configure_logging(
json_output=json_output,
log_level=log_level,
@@ -134,6 +131,16 @@ def configure_logging(
callsite_parameters=callsite_params,
)
+ # Build the remote handler after dictConfig(), which closes every
previously registered handler.
+ if (
+ not sending_to_supervisor
+ and (remote := load_remote_log_handler())
+ and (remote_processors := getattr(remote, "processors", None))
+ ):
+ current_processors = list(structlog.get_config()["processors"])
+ updated_processors = current_processors[:-1] + list(remote_processors)
+ [current_processors[-1]]
+ structlog.configure(processors=updated_processors)
+
def logger_at_level(name: str, level: int) -> Logger:
"""Create a new logger at the given level."""
diff --git a/task-sdk/tests/task_sdk/test_log.py
b/task-sdk/tests/task_sdk/test_log.py
new file mode 100644
index 00000000000..aa955a87a60
--- /dev/null
+++ b/task-sdk/tests/task_sdk/test_log.py
@@ -0,0 +1,89 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest import mock
+
+import pytest
+import structlog
+
+from airflow.sdk import log as sdk_log
+
+
+class TestConfigureLogging:
+ @pytest.mark.parametrize("sending_to_supervisor", [True, False])
+ @mock.patch("airflow.sdk.log.load_remote_log_handler")
+ @mock.patch("airflow.sdk._shared.logging.configure_logging")
+ def test_injects_remote_processors_after_dictconfig(
+ self, mock_configure_logging, mock_load_remote_log_handler,
sending_to_supervisor
+ ):
+ initial_processor = mock.Mock()
+ final_renderer = mock.Mock()
+ remote_processor = mock.Mock()
+ remote_handler = mock.Mock(spec=["processors"])
+ remote_handler.processors = (remote_processor,)
+ calls = []
+
+ def configure_structlog(**_):
+ calls.append("dictConfig")
+ structlog.configure(processors=[initial_processor, final_renderer])
+
+ def load_remote_handler():
+ calls.append("load_remote_log_handler")
+ return remote_handler
+
+ mock_configure_logging.side_effect = configure_structlog
+ mock_load_remote_log_handler.side_effect = load_remote_handler
+ original_processors = structlog.get_config()["processors"]
+ sdk_log.configure_logging.cache_clear()
+
+ try:
+
sdk_log.configure_logging(sending_to_supervisor=sending_to_supervisor)
+
+ assert mock_configure_logging.called
+ if sending_to_supervisor:
+ assert calls == ["dictConfig"]
+ assert structlog.get_config()["processors"] ==
[initial_processor, final_renderer]
+ else:
+ assert calls == ["dictConfig", "load_remote_log_handler"]
+ assert structlog.get_config()["processors"] == [
+ initial_processor,
+ remote_processor,
+ final_renderer,
+ ]
+ finally:
+ structlog.configure(processors=original_processors)
+ sdk_log.configure_logging.cache_clear()
+
+ @mock.patch("airflow.sdk._shared.logging.structlog.structlog_processors")
+ @mock.patch("airflow.sdk.log.load_remote_log_handler",
return_value=object())
+ def test_allows_remote_handler_without_processors(
+ self, mock_load_remote_log_handler, mock_structlog_processors
+ ):
+ initial_processor = mock.Mock()
+ final_renderer = mock.Mock()
+ mock_structlog_processors.return_value = ([initial_processor], None,
final_renderer)
+ sdk_log.logging_processors.cache_clear()
+
+ try:
+ processors = sdk_log.logging_processors(json_output=False)
+ finally:
+ sdk_log.logging_processors.cache_clear()
+
+ mock_load_remote_log_handler.assert_called_once_with()
+ assert processors == (initial_processor, sdk_log.mask_logs,
final_renderer)