This is an automated email from the ASF dual-hosted git repository.
eladkal 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 70dbf40a480 Make Elasticsearch/OpensearchTaskHandler to render log
well (#53639)
70dbf40a480 is described below
commit 70dbf40a4805506d2ddd6de4ef5c5fd764d9a3d8
Author: Owen Leung <[email protected]>
AuthorDate: Fri Jul 25 22:58:48 2025 +0800
Make Elasticsearch/OpensearchTaskHandler to render log well (#53639)
* Config Elasticsearch/OpensearchTaskHandler to render log well
---
.../providers/elasticsearch/log/es_task_handler.py | 14 ++--
.../unit/elasticsearch/log/test_es_task_handler.py | 38 ++++++----
.../providers/opensearch/log/os_task_handler.py | 31 +++++++-
.../opensearch/tests/unit/opensearch/conftest.py | 83 ++--------------------
.../unit/opensearch/log/test_os_task_handler.py | 40 +++--------
5 files changed, 78 insertions(+), 128 deletions(-)
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 85a4e03b113..4001331e8f7 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
@@ -67,6 +67,7 @@ LOG_LINE_DEFAULTS = {"exc_text": "", "stack_info": ""}
# not exist, the task handler should use the log_id_template attribute instead.
USE_PER_RUN_LOG_ID = hasattr(DagRun, "get_log_template")
+TASK_LOG_FIELDS = ["timestamp", "event", "level", "chan", "logger"]
VALID_ES_CONFIG_KEYS =
set(inspect.signature(elasticsearch.Elasticsearch.__init__).parameters.keys())
# Remove `self` from the valid set of kwargs
@@ -160,11 +161,11 @@ class ElasticsearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMix
es_kwargs = es_kwargs or {}
if es_kwargs == "default_es_kwargs":
es_kwargs = get_es_kwargs_from_config()
- host = self.format_url(host)
+ self.host = self.format_url(host)
super().__init__(base_log_folder)
self.closed = False
- self.client = elasticsearch.Elasticsearch(host, **es_kwargs)
+ self.client = elasticsearch.Elasticsearch(self.host, **es_kwargs)
# in airflow.cfg, host of elasticsearch has to be
http://dockerhostXxxx:9200
self.frontend = frontend
@@ -287,7 +288,7 @@ class ElasticsearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMix
def _group_logs_by_host(self, response: ElasticSearchResponse) ->
dict[str, list[Hit]]:
grouped_logs = defaultdict(list)
for hit in response:
- key = getattr_nested(hit, self.host_field, None) or "default_host"
+ key = getattr_nested(hit, self.host_field, None) or self.host
grouped_logs[key].append(hit)
return grouped_logs
@@ -382,8 +383,13 @@ class ElasticsearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMix
StructuredLogMessage(event="::endgroup::"),
]
+ # Flatten all hits, filter to only desired fields, and
construct StructuredLogMessage objects
message = header + [
- StructuredLogMessage(event=concat_logs(hits)) for hits in
logs_by_host.values()
+ StructuredLogMessage(
+ **{k: v for k, v in hit.to_dict().items() if k.lower()
in TASK_LOG_FIELDS}
+ )
+ for hits in logs_by_host.values()
+ for hit in hits
]
else:
message = [
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 2d90c110832..51f00f0d505 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
@@ -117,7 +117,12 @@ class TestElasticsearchTaskHandler:
self.index_name = "test_index"
self.doc_type = "log"
self.test_message = "some random stuff"
- self.body = {"message": self.test_message, "log_id": self.LOG_ID,
"offset": 1}
+ self.body = {
+ "message": self.test_message,
+ "log_id": self.LOG_ID,
+ "offset": 1,
+ "event": self.test_message,
+ }
self.es.index(index=self.index_name, doc_type=self.doc_type,
body=self.body, id=1)
def teardown_method(self):
@@ -210,7 +215,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -238,7 +243,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -365,7 +370,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -388,7 +393,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -438,7 +443,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -528,7 +533,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost:9200"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == "some random stuff"
@@ -593,6 +598,7 @@ class TestElasticsearchTaskHandler:
self.body = {
"message": self.test_message,
+ "event": self.test_message,
"log_id":
f"{self.DAG_ID}-{self.TASK_ID}-2016_01_01T00_00_00_000000-1",
"offset": 1,
"asctime": "2020-12-24 19:25:00,962",
@@ -606,12 +612,13 @@ class TestElasticsearchTaskHandler:
logs, _ = self.es_task_handler.read(
ti, 1, {"offset": 0, "last_log_timestamp": str(ts), "end_of_log":
False}
)
- expected_message = "[2020-12-24 19:25:00,962] {taskinstance.py:851}
INFO - some random stuff - "
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
- assert logs[2].event == expected_message
+ assert logs[2].event == self.test_message
else:
- assert logs[0][0][1] == expected_message
+ assert (
+ logs[0][0][1] == "[2020-12-24 19:25:00,962]
{taskinstance.py:851} INFO - some random stuff - "
+ )
@pytest.mark.db_test
def test_read_with_json_format_with_custom_offset_and_host_fields(self,
ti):
@@ -626,6 +633,7 @@ class TestElasticsearchTaskHandler:
self.body = {
"message": self.test_message,
+ "event": self.test_message,
"log_id":
f"{self.DAG_ID}-{self.TASK_ID}-2016_01_01T00_00_00_000000-1",
"log": {"offset": 1},
"host": {"name": "somehostname"},
@@ -640,12 +648,13 @@ class TestElasticsearchTaskHandler:
logs, _ = self.es_task_handler.read(
ti, 1, {"offset": 0, "last_log_timestamp": str(ts), "end_of_log":
False}
)
- expected_message = "[2020-12-24 19:25:00,962] {taskinstance.py:851}
INFO - some random stuff - "
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
- assert logs[2].event == expected_message
+ assert logs[2].event == self.test_message
else:
- assert logs[0][0][1] == expected_message
+ assert (
+ logs[0][0][1] == "[2020-12-24 19:25:00,962]
{taskinstance.py:851} INFO - some random stuff - "
+ )
@pytest.mark.db_test
def test_read_with_custom_offset_and_host_fields(self, ti):
@@ -658,6 +667,7 @@ class TestElasticsearchTaskHandler:
self.body = {
"message": self.test_message,
+ "event": self.test_message,
"log_id": self.LOG_ID,
"log": {"offset": 1},
"host": {"name": "somehostname"},
@@ -670,7 +680,7 @@ class TestElasticsearchTaskHandler:
if AIRFLOW_V_3_0_PLUS:
pass
else:
- assert self.test_message == logs[0][0][1]
+ assert logs[0][0][1] == "some random stuff"
@pytest.mark.db_test
def test_close(self, ti):
diff --git
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
index 57c68e7ab9c..ea7d72f152d 100644
---
a/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
+++
b/providers/opensearch/src/airflow/providers/opensearch/log/os_task_handler.py
@@ -26,6 +26,7 @@ from collections.abc import Callable
from datetime import datetime
from operator import attrgetter
from typing import TYPE_CHECKING, Any, Literal, cast
+from urllib.parse import urlparse
import pendulum
from opensearchpy import OpenSearch
@@ -57,6 +58,7 @@ else:
USE_PER_RUN_LOG_ID = hasattr(DagRun, "get_log_template")
LOG_LINE_DEFAULTS = {"exc_text": "", "stack_info": ""}
+TASK_LOG_FIELDS = ["timestamp", "event", "level", "chan", "logger"]
def getattr_nested(obj, item, default):
@@ -174,6 +176,7 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
self.write_stdout = write_stdout
self.json_format = json_format
self.json_fields = [label.strip() for label in json_fields.split(",")]
+ self.host = self.format_url(host)
self.host_field = host_field
self.offset_field = offset_field
self.index_patterns = index_patterns
@@ -184,7 +187,6 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
http_auth=(username, password),
**os_kwargs,
)
- # client = OpenSearch(hosts=[{"host": host, "port": port}],
http_auth=(username, password), use_ssl=True, verify_certs=True,
ca_cert="/opt/airflow/root-ca.pem", ssl_assert_hostname = False, ssl_show_warn
= False)
self.formatter: logging.Formatter
self.handler: logging.FileHandler | logging.StreamHandler
self._doc_type_map: dict[Any, Any] = {}
@@ -425,8 +427,13 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
StructuredLogMessage(event="::endgroup::"),
]
+ # Flatten all hits, filter to only desired fields, and
construct StructuredLogMessage objects
message = header + [
- StructuredLogMessage(event=concat_logs(hits)) for hits in
logs_by_host.values()
+ StructuredLogMessage(
+ **{k: v for k, v in hit.to_dict().items() if k.lower()
in TASK_LOG_FIELDS}
+ )
+ for hits in logs_by_host.values()
+ for hit in hits
]
else:
message = [(host, concat_logs(hits)) for host, hits in
logs_by_host.items()] # type: ignore[misc]
@@ -583,7 +590,7 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
def _group_logs_by_host(self, response: OpensearchResponse) -> dict[str,
list[Hit]]:
grouped_logs = defaultdict(list)
for hit in response:
- key = getattr_nested(hit, self.host_field, None) or "default_host"
+ key = getattr_nested(hit, self.host_field, None) or self.host
grouped_logs[key].append(hit)
return grouped_logs
@@ -621,3 +628,21 @@ class OpensearchTaskHandler(FileTaskHandler,
ExternalLoggingMixin, LoggingMixin)
def log_name(self) -> str:
"""The log name."""
return self.LOG_NAME
+
+ @staticmethod
+ def format_url(host: str) -> str:
+ """
+ Format the given host string to ensure it starts with 'http' and check
if it represents a valid URL.
+
+ :params host: The host string to format and check.
+ """
+ parsed_url = urlparse(host)
+
+ if parsed_url.scheme not in ("http", "https"):
+ host = "http://" + host
+ parsed_url = urlparse(host)
+
+ if not parsed_url.netloc:
+ raise ValueError(f"'{host}' is not a valid URL.")
+
+ return host
diff --git a/providers/opensearch/tests/unit/opensearch/conftest.py
b/providers/opensearch/tests/unit/opensearch/conftest.py
index 8b29d61d761..e1efa0cd9c0 100644
--- a/providers/opensearch/tests/unit/opensearch/conftest.py
+++ b/providers/opensearch/tests/unit/opensearch/conftest.py
@@ -63,100 +63,29 @@ class MockClient:
"hits": [
{
"_id": "jdeZT4kBjAZqZnexVUxk",
- "_index": ".ds-filebeat-8.8.2-2023.07.09-000001",
- "_score": 2.482621,
"_source": {
- "@timestamp": "2023-07-13T14:13:15.140Z",
- "asctime": "2023-07-09T07:47:43.907+0000",
- "container": {"id": "airflow"},
"dag_id": "example_bash_operator",
- "ecs": {"version": "8.0.0"},
"execution_date": "2023_07_09T07_47_32_000000",
- "filename": "taskinstance.py",
- "input": {"type": "log"},
"levelname": "INFO",
- "lineno": 1144,
- "log": {
- "file": {
- "path":
"/opt/airflow/Documents/GitHub/airflow/logs/"
- "dag_id=example_bash_operator'"
-
"/run_id=owen_run_run/task_id=run_after_loop/attempt=1.log"
- },
- "offset": 0,
- },
- "offset": 1688888863907337472,
- "log_id":
"example_bash_operator-run_after_loop-owen_run_run--1-1",
- "message": "Dependencies all met for "
- "dep_context=non-requeueable deps "
- "ti=<TaskInstance: "
- "example_bash_operator.run_after_loop "
- "owen_run_run [queued]>",
+ "message": "Some Message 1",
+ "event": "Some Message 1",
"task_id": "run_after_loop",
"try_number": "1",
+ "offset": 0,
},
"_type": "_doc",
},
{
"_id": "qteZT4kBjAZqZnexVUxl",
- "_index": ".ds-filebeat-8.8.2-2023.07.09-000001",
- "_score": 2.482621,
"_source": {
- "@timestamp": "2023-07-13T14:13:15.141Z",
- "asctime": "2023-07-09T07:47:43.917+0000",
- "container": {"id": "airflow"},
"dag_id": "example_bash_operator",
- "ecs": {"version": "8.0.0"},
"execution_date": "2023_07_09T07_47_32_000000",
- "filename": "taskinstance.py",
- "input": {"type": "log"},
"levelname": "INFO",
- "lineno": 1347,
- "log": {
- "file": {
- "path":
"/opt/airflow/Documents/GitHub/airflow/logs/"
- "dag_id=example_bash_operator"
-
"/run_id=owen_run_run/task_id=run_after_loop/attempt=1.log"
- },
- "offset": 988,
- },
- "offset": 1688888863917961216,
- "log_id":
"example_bash_operator-run_after_loop-owen_run_run--1-1",
- "message": "Starting attempt 1 of 1",
- "task_id": "run_after_loop",
- "try_number": "1",
- },
- "_type": "_doc",
- },
- {
- "_id": "v9eZT4kBjAZqZnexVUx2",
- "_index": ".ds-filebeat-8.8.2-2023.07.09-000001",
- "_score": 2.482621,
- "_source": {
- "@timestamp": "2023-07-13T14:13:15.143Z",
- "asctime": "2023-07-09T07:47:43.928+0000",
- "container": {"id": "airflow"},
- "dag_id": "example_bash_operator",
- "ecs": {"version": "8.0.0"},
- "execution_date": "2023_07_09T07_47_32_000000",
- "filename": "taskinstance.py",
- "input": {"type": "log"},
- "levelname": "INFO",
- "lineno": 1368,
- "log": {
- "file": {
- "path":
"/opt/airflow/Documents/GitHub/airflow/logs/"
- "dag_id=example_bash_operator"
-
"/run_id=owen_run_run/task_id=run_after_loop/attempt=1.log"
- },
- "offset": 1372,
- },
- "offset": 1688888863928218880,
- "log_id":
"example_bash_operator-run_after_loop-owen_run_run--1-1",
- "message": "Executing <Task(BashOperator): "
- "run_after_loop> on 2023-07-09 "
- "07:47:32+00:00",
+ "message": "Another Some Message 2",
+ "event": "Another Some Message 2",
"task_id": "run_after_loop",
"try_number": "1",
+ "offset": 1,
},
"_type": "_doc",
},
diff --git
a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
index 4faeb2223f9..c976182f2c6 100644
--- a/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
+++ b/providers/opensearch/tests/unit/opensearch/log/test_os_task_handler.py
@@ -43,14 +43,11 @@ from airflow.utils.timezone import datetime
from tests_common.test_utils.config import conf_vars
from tests_common.test_utils.db import clear_db_dags, clear_db_runs
-from tests_common.test_utils.paths import AIRFLOW_PROVIDERS_ROOT_PATH
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS
from unit.opensearch.conftest import MockClient
opensearchpy = pytest.importorskip("opensearchpy")
-ES_PROVIDER_YAML_FILE = AIRFLOW_PROVIDERS_ROOT_PATH / "elasticsearch" /
"provider.yaml"
-
def get_ti(dag_id, task_id, logical_date, create_task_instance):
ti = create_task_instance(
@@ -134,12 +131,7 @@ class TestOpensearchTaskHandler:
for hosted_log in logs_by_host.values():
message = concat_logs(hosted_log)
- assert (
- message == "Dependencies all met for dep_context=non-requeueable"
- " deps ti=<TaskInstance: example_bash_operator.run_after_loop
owen_run_run [queued]>\n"
- "Starting attempt 1 of 1\nExecuting <Task(BashOperator):
run_after_loop> "
- "on 2023-07-09 07:47:32+00:00"
- )
+ assert message == "Some Message 1\nAnother Some Message 2"
def test_client(self):
assert self.os_task_handler.index_patterns == "_all"
@@ -197,20 +189,16 @@ class TestOpensearchTaskHandler:
ti, 1, {"offset": 0, "last_log_timestamp": str(ts), "end_of_log":
False}
)
- expected_msg = (
- "Dependencies all met for dep_context=non-requeueable"
- " deps ti=<TaskInstance: example_bash_operator.run_after_loop
owen_run_run [queued]>\n"
- "Starting attempt 1 of 1\nExecuting <Task(BashOperator):
run_after_loop> "
- "on 2023-07-09 07:47:32+00:00"
- )
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
+ expected_msg = "Some Message 1"
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == expected_msg
metadata = metadatas
else:
+ expected_msg = "Some Message 1\nAnother Some Message 2"
assert len(logs) == 1
assert len(logs) == len(metadatas)
assert len(logs[0]) == 1
@@ -229,20 +217,16 @@ class TestOpensearchTaskHandler:
ti, 1, {"offset": 0, "last_log_timestamp": str(ts),
"end_of_log": False}
)
- expected_msg = (
- "Dependencies all met for dep_context=non-requeueable"
- " deps ti=<TaskInstance: example_bash_operator.run_after_loop
owen_run_run [queued]>\n"
- "Starting attempt 1 of 1\nExecuting <Task(BashOperator):
run_after_loop> "
- "on 2023-07-09 07:47:32+00:00"
- )
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
+ expected_msg = "Some Message 1"
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == expected_msg
metadata = metadatas
else:
+ expected_msg = "Some Message 1\nAnother Some Message 2"
assert len(logs) == 1
assert len(logs) == len(metadatas)
assert len(logs[0]) == 1
@@ -370,21 +354,17 @@ class TestOpensearchTaskHandler:
def test_read_with_none_metadata(self, ti):
logs, metadatas = self.os_task_handler.read(ti, 1)
- expected_message = (
- "Dependencies all met for dep_context=non-requeueable"
- " deps ti=<TaskInstance: example_bash_operator.run_after_loop
owen_run_run [queued]>\n"
- "Starting attempt 1 of 1\nExecuting <Task(BashOperator):
run_after_loop> "
- "on 2023-07-09 07:47:32+00:00"
- )
if AIRFLOW_V_3_0_PLUS:
logs = list(logs)
+ expected_message = "Some Message 1"
assert logs[0].event == "::group::Log message source details"
- assert logs[0].sources == ["default_host"]
+ assert logs[0].sources == ["http://localhost"]
assert logs[1].event == "::endgroup::"
assert logs[2].event == expected_message
metadata = metadatas
else:
+ expected_message = "Some Message 1\nAnother Some Message 2"
assert len(logs) == 1
assert len(logs) == len(metadatas)
assert len(logs[0]) == 1