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 3986d828ec3 Prevent malformed Elasticsearch log entries from crashing
task log fetch (#69306)
3986d828ec3 is described below
commit 3986d828ec377d70e5c63252da61ab172916f1e9
Author: Hemkumar Chheda <[email protected]>
AuthorDate: Wed Jul 15 12:46:26 2026 +0530
Prevent malformed Elasticsearch log entries from crashing task log fetch
(#69306)
* Prevent malformed Elasticsearch log entries from crashing task log fetch
A single stored log entry with a non-string `event` field (for example,
a task that logs a list or dict as the sole message argument) currently
crashes the entire task-log-fetch request with an unhandled
pydantic.ValidationError, instead of degrading gracefully.
_read() built StructuredLogMessage objects from stored Elasticsearch
hits without catching validation failures. This catches ValidationError
per hit and falls back to a stringified event, matching the existing
fallback pattern in file_task_handler.py's _log_stream_to_parsed_log_stream.
closes: #69304
* Fix Elasticsearch compat tests on Airflow 2.11
The new StructuredLogMessage fallback tests exercise an Airflow 3-only
type, but the provider compatibility matrix still runs this provider against
Airflow 2.11.1. Guarding those tests the same way as the runtime path keeps the
compatibility job focused on behavior that actually exists in that release line.
* Fix Elasticsearch compat test for finished tasks
The malformed log-entry regression test was unintentionally exercising
Airflow 3's live-log delegation path because the test task instance was still
running. Marking the task instance successful keeps the test on the
Elasticsearch-read path it is intended to cover, which matches the provider
compatibility and lowest-dependencies jobs.
* Lower malformed-log fallback logging to debug level
_safe_build_structured_log_message runs once per stored hit inside the
_read comprehension, so logging the fallback at warning level fires once
per malformed line within a single log-fetch request. Drop it to debug
so a request with many malformed entries does not flood the logs.
---
.../providers/elasticsearch/log/es_task_handler.py | 28 +++++++++++++-
.../unit/elasticsearch/log/test_es_task_handler.py | 45 ++++++++++++++++++++++
2 files changed, 72 insertions(+), 1 deletion(-)
diff --git
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
index fa05b2a0b34..2fb610186e9 100644
---
a/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
+++
b/providers/elasticsearch/src/airflow/providers/elasticsearch/log/es_task_handler.py
@@ -40,6 +40,7 @@ import elasticsearch
import pendulum
from elasticsearch import helpers
from elasticsearch.exceptions import NotFoundError
+from pydantic import ValidationError
import airflow.logging_config as alc
from airflow.exceptions import AirflowProviderDeprecationWarning
@@ -76,6 +77,8 @@ else:
EsLogMsgType = list[tuple[str, str]] # type: ignore[assignment,misc]
+logger = logging.getLogger(__name__)
+
LOG_LINE_DEFAULTS = {"exc_text": "", "stack_info": ""}
# Elasticsearch hosted log type
@@ -140,6 +143,29 @@ def _build_log_fields(hit_dict: dict[str, Any]) ->
dict[str, Any]:
return fields
+def _safe_build_structured_log_message(hit_dict: dict[str, Any]) ->
StructuredLogMessage:
+ """
+ Build a StructuredLogMessage from a stored Elasticsearch hit, tolerating
malformed fields.
+
+ A single malformed stored log entry (for example a non-string ``event``
produced by
+ logging a list or dict as the sole message argument) must not fail the
entire
+ log-fetch request. Fall back to a stringified event, mirroring the
fallback used for
+ unparsable raw log lines in ``_log_stream_to_parsed_log_stream``.
+ """
+ fields = _build_log_fields(hit_dict)
+ try:
+ return StructuredLogMessage(**fields)
+ except ValidationError:
+ logger.debug(
+ "Failed to parse stored log entry into StructuredLogMessage;
falling back to "
+ "stringified event. Offending fields: %s",
+ fields,
+ )
+ return StructuredLogMessage(
+ event=str(fields.get("event", hit_dict)),
timestamp=fields.get("timestamp")
+ )
+
+
VALID_ES_CONFIG_KEYS =
set(inspect.signature(elasticsearch.Elasticsearch.__init__).parameters.keys())
# Remove `self` from the valid set of kwargs
VALID_ES_CONFIG_KEYS.remove("self")
@@ -472,7 +498,7 @@ class ElasticsearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMix
# Flatten all hits, filter to only desired fields, and
construct StructuredLogMessage objects
message = header + [
- StructuredLogMessage(**_build_log_fields(hit.to_dict()))
+ _safe_build_structured_log_message(hit.to_dict())
for hits in logs_by_host.values()
for hit in hits
]
diff --git
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
index b923d8b7c6f..9f7091d90bb 100644
---
a/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
+++
b/providers/elasticsearch/tests/unit/elasticsearch/log/test_es_task_handler.py
@@ -43,6 +43,7 @@ from airflow.providers.elasticsearch.log.es_task_handler
import (
_clean_date,
_format_error_detail,
_render_log_id,
+ _safe_build_structured_log_message,
_strip_userinfo,
get_es_kwargs_from_config,
getattr_nested,
@@ -388,6 +389,32 @@ class TestElasticsearchTaskHandler:
assert metadata["offset"] == "1"
assert not metadata["end_of_log"]
+ @pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="StructuredLogMessage
fallback is Airflow 3+ only")
+ @pytest.mark.db_test
+ def test_read_with_malformed_event_falls_back_to_stringified_event(self,
ti):
+ ti.state = TaskInstanceState.SUCCESS
+ malformed_event = ["not", "a", "string"]
+ malformed_source = {
+ "message": self.test_message,
+ "event": malformed_event,
+ "log_id": self.LOG_ID,
+ "offset": 2,
+ }
+ response = _make_es_response(self.es_task_handler.io,
self.base_log_source, malformed_source)
+
+ with patch.object(self.es_task_handler.io, "_es_read",
return_value=response):
+ with
patch("airflow.providers.elasticsearch.log.es_task_handler.logger") as
mock_logger:
+ logs, metadatas = self.es_task_handler.read(ti, 1)
+
+ metadata = _assert_log_events(
+ logs,
+ metadatas,
+ expected_events=[self.test_message, str(malformed_event)],
+ expected_sources=["http://localhost:9200"],
+ )
+ assert not metadata["end_of_log"]
+ mock_logger.debug.assert_called_once()
+
@pytest.mark.db_test
@pytest.mark.skipif(not AIRFLOW_V_3_0_PLUS, reason="Live-log delegation
only applies to Airflow 3")
@pytest.mark.parametrize("state", [TaskInstanceState.RUNNING,
TaskInstanceState.DEFERRED])
@@ -973,3 +1000,21 @@ class TestBuildStructuredLogFields:
hit = {"event": "msg", "error_detail": []}
result = _build_log_fields(hit)
assert "error_detail" not in result
+
+
[email protected](not AIRFLOW_V_3_0_PLUS, reason="StructuredLogMessage
fallback is Airflow 3+ only")
+class TestSafeBuildStructuredLogMessage:
+ def test_string_event_returns_unchanged_and_does_not_log(self):
+ hit = {"event": "hello", "level": "info"}
+ with
patch("airflow.providers.elasticsearch.log.es_task_handler.logger") as
mock_logger:
+ result = _safe_build_structured_log_message(hit)
+ assert result.event == "hello"
+ mock_logger.debug.assert_not_called()
+
+ def test_non_string_event_falls_back_to_stringified_event(self):
+ hit = {"event": ["a", "b"], "timestamp": "2024-01-01T00:00:00Z"}
+ with
patch("airflow.providers.elasticsearch.log.es_task_handler.logger") as
mock_logger:
+ result = _safe_build_structured_log_message(hit)
+ assert result.event == str(["a", "b"])
+ assert result.timestamp is not None
+ mock_logger.debug.assert_called_once()