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

jason810496 pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 4bc20e6e938 [v3-3-test] Clarify ``logging_config_class`` contract and 
document ``REMOTE_TASK_LOG`` (#67104) (#70592)
4bc20e6e938 is described below

commit 4bc20e6e938c4b28e76811323c08c42348e1b9c3
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jul 29 11:40:28 2026 +0800

    [v3-3-test] Clarify ``logging_config_class`` contract and document 
``REMOTE_TASK_LOG`` (#67104) (#70592)
---
 .../advanced-logging-configuration.rst             | 53 ++++++++++++++++--
 .../config_templates/airflow_local_settings.py     |  8 +++
 .../src/airflow/config_templates/config.yml        |  9 ++--
 airflow-core/src/airflow/logging_config.py         | 50 ++++++++++++++++-
 .../tests/unit/logging/test_logging_config.py      | 63 ++++++++++++++++++++++
 pyproject.toml                                     |  8 +--
 scripts/ci/prek/update_airflow_pyproject_toml.py   |  4 +-
 .../logging/src/airflow_shared/logging/remote.py   | 20 ++++++-
 task-sdk/src/airflow/sdk/log.py                    |  7 ++-
 9 files changed, 205 insertions(+), 17 deletions(-)

diff --git 
a/airflow-core/docs/administration-and-deployment/logging-monitoring/advanced-logging-configuration.rst
 
b/airflow-core/docs/administration-and-deployment/logging-monitoring/advanced-logging-configuration.rst
index fa34e74f314..629cb506551 100644
--- 
a/airflow-core/docs/administration-and-deployment/logging-monitoring/advanced-logging-configuration.rst
+++ 
b/airflow-core/docs/administration-and-deployment/logging-monitoring/advanced-logging-configuration.rst
@@ -46,13 +46,14 @@ that Python objects log to loggers that follow naming 
convention of ``<package>.
 You can read more about standard python logging classes (Loggers, Handlers, 
Formatters) in the
 `Python logging documentation <https://docs.python.org/library/logging.html>`_.
 
-Create a custom logging class
------------------------------
+Create a custom logging config
+------------------------------
 
 Configuring your logging classes can be done via the ``logging_config_class`` 
option in ``airflow.cfg`` file.
-This configuration should specify the import path to a configuration 
compatible with
-:func:`logging.config.dictConfig`. If your file is a standard import location, 
then you should set a
-:envvar:`PYTHONPATH` environment variable.
+Despite the option name the value is a dotted import path to a ``dict`` that 
satisfies
+:func:`logging.config.dictConfig` — not a Python class. The ``_class`` suffix 
is
+historical and kept for backwards compatibility. If your file is a standard 
import
+location, then you should set a :envvar:`PYTHONPATH` environment variable.
 
 Follow the steps below to enable custom logging config class:
 
@@ -102,6 +103,48 @@ See :doc:`../modules_management` for details on how Python 
and Airflow manage mo
    You can override the way both standard logs of the components and "task" 
logs are handled.
 
 
+Custom logging configs and remote logging
+-----------------------------------------
+
+When ``[logging] remote_logging = True`` and you point ``logging_config_class``
+at your own module, define two module-level attributes in that module:
+
+* ``REMOTE_TASK_LOG`` — an instance of
+  :class:`~airflow.logging.remote.RemoteLogIO` (or
+  :class:`~airflow.logging.remote.RemoteLogStreamIO`) that uploads task logs
+  and reads them back for the UI.
+* ``DEFAULT_REMOTE_CONN_ID`` — default Airflow connection id used when
+  ``[logging] remote_log_conn_id`` is unset.
+
+If ``REMOTE_TASK_LOG`` is missing, Airflow emits one ``WARNING`` at startup
+and the UI cannot read task logs back from the remote backend.
+
+    .. code-block:: python
+
+      # ~/airflow/config/log_config.py
+      from airflow.logging.remote import RemoteLogIO
+
+
+      class MyRemoteLogIO:
+          @property
+          def processors(self):
+              return ()
+
+          def upload(self, path, ti): ...  # upload local log file at ``path`` 
to your backend
+
+          def read(self, relative_path, ti): ...  # return (source_info, 
log_messages) for the UI
+
+
+      REMOTE_TASK_LOG: RemoteLogIO | None = MyRemoteLogIO()
+      DEFAULT_REMOTE_CONN_ID: str | None = "my_remote_conn"
+
+.. note::
+
+   Define ``REMOTE_TASK_LOG`` in your own module rather than re-exporting it
+   from ``airflow.config_templates.airflow_local_settings``, which is planned
+   for deprecation.
+
+
 Custom logger for Operators, Hooks and Tasks
 --------------------------------------------
 
diff --git 
a/airflow-core/src/airflow/config_templates/airflow_local_settings.py 
b/airflow-core/src/airflow/config_templates/airflow_local_settings.py
index e30e68fb179..c5bb91b682f 100644
--- a/airflow-core/src/airflow/config_templates/airflow_local_settings.py
+++ b/airflow-core/src/airflow/config_templates/airflow_local_settings.py
@@ -50,6 +50,9 @@ DAG_PROCESSOR_LOG_TARGET: str = 
conf.get_mandatory_value("logging", "DAG_PROCESS
 BASE_LOG_FOLDER: str = os.path.expanduser(conf.get_mandatory_value("logging", 
"BASE_LOG_FOLDER"))
 
 # This isn't used anymore, but kept for compat of people who might have 
imported it
+# Default value for the ``[logging] logging_config_class`` option. Plain
+# ``logging.config.dictConfig`` dict; the ``_class`` suffix on the config 
option
+# is historical.
 DEFAULT_LOGGING_CONFIG: dict[str, Any] = {
     "version": 1,
     "disable_existing_loggers": False,
@@ -121,6 +124,11 @@ if EXTRA_LOGGER_NAMES:
 ##################
 
 REMOTE_LOGGING: bool = conf.getboolean("logging", "remote_logging")
+
+# Side-channel attributes read by ``discover_remote_log_handler`` from 
whichever
+# module ``[logging] logging_config_class`` resolves through. Custom modules 
that
+# override that option should define both at module scope to enable remote
+# task-log read-back.
 REMOTE_TASK_LOG: RemoteLogIO | RemoteLogStreamIO | None = None
 DEFAULT_REMOTE_CONN_ID: str | None = None
 
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index 975e6ba1121..27d674b5802 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -921,9 +921,12 @@ logging:
       default: "INFO"
     logging_config_class:
       description: |
-        Logging class
-        Specify the class that will specify the logging configuration
-        This class has to be on the python classpath
+        Dotted import path to a ``logging.config.dictConfig`` dict. The
+        ``_class`` suffix is historical — the target is a dict, not a class.
+
+        Airflow also reads two optional module-level attributes from the
+        enclosing module: ``REMOTE_TASK_LOG`` and ``DEFAULT_REMOTE_CONN_ID``,
+        used to drive remote task-log read-back. See 
:ref:`write-logs-advanced`.
       version_added: 2.0.0
       type: string
       example: "my.path.default_local_settings.LOGGING_CONFIG"
diff --git a/airflow-core/src/airflow/logging_config.py 
b/airflow-core/src/airflow/logging_config.py
index 7f4ae605cb2..da337997de8 100644
--- a/airflow-core/src/airflow/logging_config.py
+++ b/airflow-core/src/airflow/logging_config.py
@@ -36,7 +36,7 @@ class _ActiveLoggingConfig:
     """Private class to hold active logging config variables."""
 
     logging_config_loaded: bool = False
-    remote_task_log: RemoteLogIO | None
+    remote_task_log: RemoteLogIO | None = None
     default_remote_conn_id: str | None = None
 
     @classmethod
@@ -120,6 +120,44 @@ def load_logging_config() -> tuple[dict[str, Any], str]:
     ) or DEFAULT_LOGGING_CONFIG_PATH
 
 
+def _warn_if_missing_remote_task_log(logging_class_path: str) -> None:
+    """
+    Warn if ``[logging] remote_logging`` is on but the user module exposes no 
remote IO.
+
+    Runs *after* ``dictConfig`` has constructed handlers, so deprecated
+    self-registration in provider task handlers (Elasticsearch, OpenSearch) has
+    already had its chance to populate 
``_ActiveLoggingConfig.remote_task_log``.
+    Only fires for user-defined ``logging_config_class`` values; the stock
+    fallback is exempt.
+
+    :param logging_class_path: the resolved ``[logging] logging_config_class``
+        dotted path (already defaulted to :data:`DEFAULT_LOGGING_CONFIG_PATH`).
+    """
+    # An empty path is not a meaningful override -- ``_get_logging_config()`` 
treats it the
+    # same as unset and falls back to ``DEFAULT_LOGGING_CONFIG_PATH``, so it 
must not be
+    # treated as a user-defined logging config class here either.
+    has_user_defined_logging_config_class = (
+        bool(logging_class_path) and logging_class_path != 
DEFAULT_LOGGING_CONFIG_PATH
+    )
+    remote_logging_enabled = conf.getboolean("logging", "remote_logging", 
fallback=False)
+    if not (has_user_defined_logging_config_class and remote_logging_enabled):
+        return
+    if get_remote_task_log() is not None:
+        return
+    # Strip the trailing ``.<config_attr>`` to leave the enclosing module path.
+    # ``logging_class_path`` should always be dotted since ``import_string``
+    # would have raised otherwise, but guard the access defensively.
+    parts = logging_class_path.rsplit(".", 1)
+    modpath = parts[0] if len(parts) == 2 else logging_class_path
+    log.warning(
+        "[logging] remote_logging is enabled but the user-defined logging 
module %r "
+        "does not expose a REMOTE_TASK_LOG attribute, so remote task-log 
read-back is "
+        "disabled. Define REMOTE_TASK_LOG (a RemoteLogIO instance) at module 
scope "
+        "to enable it.",
+        modpath,
+    )
+
+
 def configure_logging():
     from airflow._shared.logging import configure_logging, init_log_folder, 
translate_config_values
 
@@ -171,6 +209,16 @@ def configure_logging():
         # otherwise Airflow would silently fall back on the default config
         raise e
 
+    # Runs after dictConfig so deprecated handler self-registration (ES/OS) has
+    # had its chance to populate _ActiveLoggingConfig.remote_task_log.
+    # The trailing `or DEFAULT_LOGGING_CONFIG_PATH` also covers an explicit
+    # `logging_config_class = ""`, which conf.get() would otherwise return 
as-is.
+    logging_class_path = (
+        conf.get("logging", "logging_config_class", 
fallback=DEFAULT_LOGGING_CONFIG_PATH)
+        or DEFAULT_LOGGING_CONFIG_PATH
+    )
+    _warn_if_missing_remote_task_log(logging_class_path)
+
     validate_logging_config()
 
     new_folder_permissions = int(
diff --git a/airflow-core/tests/unit/logging/test_logging_config.py 
b/airflow-core/tests/unit/logging/test_logging_config.py
index 3fffb376425..f2a715f80a5 100644
--- a/airflow-core/tests/unit/logging/test_logging_config.py
+++ b/airflow-core/tests/unit/logging/test_logging_config.py
@@ -28,11 +28,14 @@ from airflow.logging_config import (
     _ActiveLoggingConfig,
     _get_logging_config,
     _load_logging_config,
+    _warn_if_missing_remote_task_log,
     get_default_remote_conn_id,
     get_remote_task_log,
     load_logging_config,
 )
 
+from tests_common.test_utils.config import conf_vars
+
 
 @pytest.fixture(autouse=True)
 def _reset_active_logging_config(monkeypatch):
@@ -200,3 +203,63 @@ class TestGetDefaultRemoteConnId:
             mocked_conf.get.return_value = None
             assert get_default_remote_conn_id() == "cached_conn"
         mock_load.assert_not_called()
+
+
+class TestWarnIfMissingRemoteTaskLog:
+    @pytest.fixture(autouse=True)
+    def _reset_active_logging_config(self, monkeypatch):
+        monkeypatch.setattr(_ActiveLoggingConfig, "remote_task_log", None, 
raising=False)
+        monkeypatch.setattr(_ActiveLoggingConfig, "logging_config_loaded", 
True, raising=False)
+
+    @pytest.mark.parametrize(
+        ("remote_logging", "logging_class_path", 
"remote_task_log_already_set", "expected_module"),
+        [
+            pytest.param(
+                True,
+                "my_pkg.custom_settings.LOGGING_CONFIG",
+                False,
+                "my_pkg.custom_settings",
+                id="user_module_missing_remote_task_log",
+            ),
+            pytest.param(True, DEFAULT_LOGGING_CONFIG_PATH, False, None, 
id="fallback_path"),
+            pytest.param(
+                False, "my_pkg.custom_settings.LOGGING_CONFIG", False, None, 
id="remote_logging_disabled"
+            ),
+            pytest.param(
+                True, "my_pkg.custom_settings.LOGGING_CONFIG", True, None, 
id="remote_task_log_already_set"
+            ),
+            pytest.param(True, "", False, None, id="empty_logging_class_path"),
+        ],
+    )
+    def test_warn_if_missing_remote_task_log(
+        self, monkeypatch, remote_logging, logging_class_path, 
remote_task_log_already_set, expected_module
+    ):
+        if remote_task_log_already_set:
+            monkeypatch.setattr(_ActiveLoggingConfig, "remote_task_log", 
object(), raising=False)
+        with conf_vars({("logging", "remote_logging"): str(remote_logging)}):
+            with mock.patch("airflow.logging_config.log") as mock_log:
+                _warn_if_missing_remote_task_log(logging_class_path)
+        if expected_module is None:
+            mock_log.warning.assert_not_called()
+        else:
+            mock_log.warning.assert_called_once()
+            assert expected_module in mock_log.warning.call_args.args
+
+    def test_skips_warning_when_resolved_lazily_via_provider_dispatch(self, 
monkeypatch):
+        """The cache may still be cold (no ES/OS handler self-registered 
during dictConfig),
+        but a working setup resolved through ProvidersManager dispatch must 
still suppress
+        the warning -- so the check has to trigger the real resolution via
+        get_remote_task_log() rather than only inspecting the cached field."""
+        monkeypatch.setattr(_ActiveLoggingConfig, "logging_config_loaded", 
False, raising=False)
+        sentinel_handler = object()
+        with (
+            conf_vars({("logging", "remote_logging"): "True"}),
+            mock.patch("airflow.logging_config.resolve_remote_task_log") as 
mock_resolve,
+            mock.patch("airflow.providers_manager.ProvidersManager"),
+            mock.patch("airflow.logging_config.log") as mock_log,
+        ):
+            mock_resolve.return_value = (sentinel_handler, None)
+            
_warn_if_missing_remote_task_log("my_pkg.custom_settings.LOGGING_CONFIG")
+
+        mock_log.warning.assert_not_called()
+        assert _ActiveLoggingConfig.remote_task_log is sentinel_handler
diff --git a/pyproject.toml b/pyproject.toml
index 7eceb2eb5bc..19b006906ca 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -219,7 +219,7 @@ apache-airflow = "airflow.__main__:main"
     "apache-airflow-providers-edge3>=1.0.0"
 ]
 "elasticsearch" = [
-    "apache-airflow-providers-elasticsearch>=6.5.0" # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
+    "apache-airflow-providers-elasticsearch>=6.6.0" # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
 ]
 "exasol" = [
     "apache-airflow-providers-exasol>=4.6.1"
@@ -306,7 +306,7 @@ apache-airflow = "airflow.__main__:main"
     "apache-airflow-providers-openlineage>=2.3.0" # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
 ]
 "opensearch" = [
-    "apache-airflow-providers-opensearch>=1.9.0" # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
+    "apache-airflow-providers-opensearch>=1.9.3" # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
 ]
 "opsgenie" = [
     "apache-airflow-providers-opsgenie>=5.8.0"
@@ -447,7 +447,7 @@ apache-airflow = "airflow.__main__:main"
     "apache-airflow-providers-discord>=3.9.0",
     "apache-airflow-providers-docker>=3.14.1",
     "apache-airflow-providers-edge3>=1.0.0",
-    "apache-airflow-providers-elasticsearch>=6.5.0", # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
+    "apache-airflow-providers-elasticsearch>=6.6.0", # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
     "apache-airflow-providers-exasol>=4.6.1",
     "apache-airflow-providers-fab>=3.6.0", # Set from MIN_VERSION_OVERRIDE in 
update_airflow_pyproject_toml.py
     "apache-airflow-providers-facebook>=3.7.0",
@@ -476,7 +476,7 @@ apache-airflow = "airflow.__main__:main"
     "apache-airflow-providers-openai>=1.5.0",
     "apache-airflow-providers-openfaas>=3.7.0",
     "apache-airflow-providers-openlineage>=2.3.0", # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
-    "apache-airflow-providers-opensearch>=1.9.0", # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
+    "apache-airflow-providers-opensearch>=1.9.3", # Set from 
MIN_VERSION_OVERRIDE in update_airflow_pyproject_toml.py
     "apache-airflow-providers-opsgenie>=5.8.0",
     "apache-airflow-providers-oracle>=3.12.0",
     "apache-airflow-providers-pagerduty>=3.8.1",
diff --git a/scripts/ci/prek/update_airflow_pyproject_toml.py 
b/scripts/ci/prek/update_airflow_pyproject_toml.py
index a7d65331185..2313790e0a5 100755
--- a/scripts/ci/prek/update_airflow_pyproject_toml.py
+++ b/scripts/ci/prek/update_airflow_pyproject_toml.py
@@ -94,8 +94,8 @@ MIN_VERSION_OVERRIDE: dict[str, Version] = {
     "openlineage": parse_version("2.3.0"),
     "git": parse_version("0.0.2"),
     "common.messaging": parse_version("2.0.0"),
-    "elasticsearch": parse_version("6.5.0"),
-    "opensearch": parse_version("1.9.0"),
+    "elasticsearch": parse_version("6.6.0"),
+    "opensearch": parse_version("1.9.3"),
 }
 
 
diff --git a/shared/logging/src/airflow_shared/logging/remote.py 
b/shared/logging/src/airflow_shared/logging/remote.py
index 59af50be6bc..b80a3ceb9cd 100644
--- a/shared/logging/src/airflow_shared/logging/remote.py
+++ b/shared/logging/src/airflow_shared/logging/remote.py
@@ -79,7 +79,25 @@ def discover_remote_log_handler(
     fallback_path: str,
     import_string: Callable[[str], Any],
 ) -> tuple[RemoteLogIO | None, str | None]:
-    """Discover and load the remote log handler from a logging config 
module."""
+    """
+    Look up the optional remote-log handler alongside a logging dictConfig.
+
+    ``[logging] logging_config_class`` is a dotted path to a
+    ``logging.config.dictConfig`` dict. After importing the dict, this helper
+    re-imports the enclosing module and reads two optional module-level
+    attributes via ``getattr``:
+
+    * ``REMOTE_TASK_LOG`` — :class:`RemoteLogIO` / :class:`RemoteLogStreamIO`
+      instance that uploads and reads task logs.
+    * ``DEFAULT_REMOTE_CONN_ID`` — default Airflow connection id for that
+      backend.
+
+    Either may be ``None`` immediately after this call; provider task handlers
+    can still populate ``REMOTE_TASK_LOG`` from inside ``__init__`` when
+    ``dictConfig`` instantiates them (deprecated path). Callers that want to
+    warn on missing remote-log configuration should re-check
+    ``_ActiveLoggingConfig.remote_task_log`` *after* ``dictConfig`` has run.
+    """
     # Sometimes we end up with `""` as the value!
     logging_class_path = logging_class_path or fallback_path
 
diff --git a/task-sdk/src/airflow/sdk/log.py b/task-sdk/src/airflow/sdk/log.py
index 5e775cbcac4..e105fd70d76 100644
--- a/task-sdk/src/airflow/sdk/log.py
+++ b/task-sdk/src/airflow/sdk/log.py
@@ -173,7 +173,12 @@ def init_log_file(local_relative_path: str) -> Path:
 
 
 def _load_logging_config() -> None:
-    """Load and cache the remote logging configuration from SDK config."""
+    """
+    Load and cache the remote logging configuration from SDK config.
+
+    SDK mirror of :func:`airflow.logging_config.load_logging_config` — see that
+    function for the ``logging_config_class`` / ``REMOTE_TASK_LOG`` contract.
+    """
     from airflow.sdk._shared.logging.factory import resolve_remote_task_log
     from airflow.sdk._shared.module_loading import import_string
     from airflow.sdk.configuration import conf

Reply via email to