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

potiuk 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 863440537b7 Keep service logs and OpenLineage e2e noise out of the job 
output (#75811)
863440537b7 is described below

commit 863440537b73c8773392a1a2a57d93d78a9c6cbf
Author: Kacper Muda <[email protected]>
AuthorDate: Wed Oct 28 15:58:06 2026 +0100

    Keep service logs and OpenLineage e2e noise out of the job output (#75811)
    
    * Keep service logs and OpenLineage e2e noise out of the job output
    
    The e2e conftest dumped the full stdout of every compose service into the
    job output on failure, and the OpenLineage task log highlights printed every
    warning and error line. The result is output too long to read, while the
    service logs were not part of the uploaded logs.zip.
    
    Write the service logs to files next to the other uploaded logs, and print
    only the lines tagged as OpenLineage validation errors.
    
    Co-Authored-By: Claude <[email protected]>
    
    * Tell where the e2e logs went instead of printing them
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 .../tests/airflow_e2e_tests/conftest.py            | 20 ++++----
 .../airflow_e2e_tests/openlineage_tests/README.md  | 13 ++---
 .../openlineage_tests/task_logs.py                 | 59 +++++++++-------------
 .../tests/system/openlineage/operator.py           | 32 ++++++++----
 4 files changed, 64 insertions(+), 60 deletions(-)

diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 09331b1645d..ca6477878bc 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -993,20 +993,22 @@ def spin_up_airflow_environment(tmp_path_factory: 
pytest.TempPathFactory):
     except Exception:
         console.print("[red]Failed to start docker compose")
         if _E2ETestState.compose_instance:
-            _print_logs(_E2ETestState.compose_instance)
+            _save_service_logs(_E2ETestState.compose_instance)
             _E2ETestState.compose_instance.stop()
         raise
 
 
-def _print_logs(compose_instance: DockerCompose):
-    containers = compose_instance.get_containers()
-    for container in containers:
+def _save_service_logs(compose_instance: DockerCompose):
+    LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
+    for container in compose_instance.get_containers():
         service = container.Service
         if service:
             stdout, _ = compose_instance.get_logs(service)
-            console.print(f"::group:: {service} Logs")
-            console.print(stdout, style="red", soft_wrap=True, markup=False)
-            console.print("::endgroup::")
+            (LOGS_FOLDER / f"{service}.log").write_text(stdout)
+    console.print(
+        f"[yellow]Service and task logs are not printed here: find them in 
{LOGS_FOLDER} "
+        f"(in CI, the `e2e-test-logs-<mode>` artifact, `logs.zip`)"
+    )
 
 
 def pytest_sessionstart(session: pytest.Session):
@@ -1044,9 +1046,9 @@ def pytest_sessionfinish(session: pytest.Session, 
exitstatus: int | pytest.ExitC
         copytree(_E2ETestState.airflow_logs_path, LOGS_FOLDER, 
dirs_exist_ok=True)
 
     if _E2ETestState.compose_instance:
-        # If any test failures lets print the services logs
+        # If any test failures lets save the services logs next to the 
uploaded task logs
         if any(r["status"] == "failed" for r in test_results):
-            _print_logs(_E2ETestState.compose_instance)
+            _save_service_logs(_E2ETestState.compose_instance)
         if not os.environ.get("SKIP_DOCKER_COMPOSE_DELETION"):
             _E2ETestState.compose_instance.stop()
 
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/README.md 
b/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/README.md
index feb1c169cfb..5ef8c621f48 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/README.md
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/README.md
@@ -54,12 +54,13 @@ breeze testing airflow-e2e-tests --e2e-test-mode openlineage
 - **pytest summary:** `1 passed` means every expected DAG is green. On failure 
it prints the
   offenders, e.g. `... not successful: {'openlineage_docs_file_dag': 
'failed'}`, and logs
   `⚠ DAGs that passed only on retry (flaky first run): [...]`.
-- **On failure, the relevant task logs are printed right in the pytest 
output** (`check_events` plus
-  any task that didn't succeed, filtered to warning/error lines — see 
`task_logs.py`) — no need to
-  open the log artifact for the common case, e.g. the exact mismatch
-  (``Path `job > facets > ...`: expected X but got Y``).
+- **On failure, the OpenLineage validation errors are printed right in the 
pytest output**: only the
+  lines tagged `[OL_VALIDATION_ERROR]` (by `OpenLineageTestOperator`) in the 
logs of `check_events` and of any
+  task that didn't succeed, without the tag — see `task_logs.py`. For example
+  ``Path `job > facets > ...`: expected X but got Y``. Keep the tag in 
`task_logs.py` in sync with
+  `VALIDATION_ERROR_TAG` in the provider's `system/openlineage/operator.py`.
 - **Per-DAG logs** are still written in full under `airflow-e2e-tests/logs/` 
for deeper digging
-  beyond what gets printed.
+  beyond what gets printed, together with one `<service>.log` per compose 
service.
 - With `--skip-docker-compose-deletion`, the stack stays up: get a token from
   `curl -s http://localhost:8080/auth/token` and check `GET 
/api/v2/importErrors` for DAG parse
   errors, or browse the runs in the UI at <http://localhost:8080> (login 
`airflow` / `airflow`).
@@ -90,7 +91,7 @@ when a new DAG is version-gated.
 ```text
 prepare_dags.py             # sources DAGs from the provider system tests into 
dags/ at runtime
 harness.py                  # triggers every DAG via the REST API and collects 
final run states
-task_logs.py                # locates and prints the relevant task logs on 
failure
+task_logs.py                # prints the tagged validation errors of task logs 
on failure
 test_openlineage_e2e.py     # the pytest entrypoint
 dags_extra/                 # harness-only DAGs/modules (warmup DAG, versioned 
bundle) copied into dags/
 ```
diff --git 
a/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/task_logs.py 
b/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/task_logs.py
index f5d3c19170b..53615033e00 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/task_logs.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/openlineage_tests/task_logs.py
@@ -15,12 +15,13 @@
 # specific language governing permissions and limitations
 # under the License.
 """
-Read and format Airflow task logs for display in pytest output on failure.
+Print the OpenLineage validation errors of Airflow task logs in pytest output 
on failure.
 
 Task logs are structlog JSON-lines written by the deployed Airflow stack; they 
land on the host via
-the ``./logs:/opt/airflow/logs`` bind mount in ``docker-compose.yaml``. Rather 
than requiring someone
-to unzip the CI log artifact and open a specific ``attempt=N.log`` file, the 
warning/error highlights
-are printed straight into the failing test's captured output.
+the ``./logs:/opt/airflow/logs`` bind mount in ``docker-compose.yaml``. Full 
logs are too long for the CI
+output, so only the lines tagged with ``VALIDATION_ERROR_TAG`` are printed: 
``OpenLineageTestOperator``
+tags every reason its event validation failed, as do the other helpers of the 
system test Dags. The full
+logs stay in the CI log artifact.
 """
 
 from __future__ import annotations
@@ -34,31 +35,17 @@ from typing import TYPE_CHECKING
 if TYPE_CHECKING:
     from rich.console import Console
 
-LOG_LEVELS_OF_INTEREST = frozenset({"warning", "error", "critical"})
-MAX_HIGHLIGHT_LINES = 100
+# Prefix of the log lines describing OpenLineage validation errors, keep in 
sync with VALIDATION_ERROR_TAG in
+# providers/openlineage/tests/system/openlineage/operator.py
+VALIDATION_ERROR_TAG = "[OL_VALIDATION_ERROR]"
+MAX_ERROR_LINES = 100
 
 _ATTEMPT_NUMBER_PATTERN = re.compile(r"attempt=(\d+)\.log$")
 
 
-def format_log_line(record: dict) -> str:
-    """Render one parsed structlog record as a compact, human-readable line."""
-    level = str(record.get("level", "info")).upper()
-    event = record.get("event", "")
-    lines = [f"[{level}] {event}"]
-    for exc in record.get("error_detail") or []:
-        exc_type = exc.get("exc_type", "Exception")
-        exc_value = exc.get("exc_value", "")
-        lines.append(f"    {exc_type}: {exc_value}")
-        frames = exc.get("frames") or []
-        if frames:
-            frame = frames[-1]
-            lines.append(f"    at 
{Path(frame['filename']).name}:{frame['lineno']} in {frame['name']}")
-    return "\n".join(lines)
-
-
-def filter_log_highlights(raw_lines: Iterable[str], max_lines: int = 
MAX_HIGHLIGHT_LINES) -> list[str]:
-    """Parse structlog JSON lines and keep only warning/error/critical ones, 
formatted for display."""
-    highlights = []
+def extract_validation_errors(raw_lines: Iterable[str], max_lines: int = 
MAX_ERROR_LINES) -> list[str]:
+    """Parse structlog JSON lines and return the tagged validation errors, 
without the tag."""
+    errors = []
     for raw_line in raw_lines:
         stripped_line = raw_line.strip()
         if not stripped_line:
@@ -68,14 +55,13 @@ def filter_log_highlights(raw_lines: Iterable[str], 
max_lines: int = MAX_HIGHLIG
         except json.JSONDecodeError:
             # Task logs aren't guaranteed to be 100% JSON (e.g. stray print 
output) — skip those lines.
             continue
-        if record.get("level") in LOG_LEVELS_OF_INTEREST:
-            highlights.append(format_log_line(record))
-    if not highlights:
-        return ["(no warning/error lines in this task's log)"]
-    if len(highlights) > max_lines:
-        dropped = len(highlights) - max_lines
-        highlights = highlights[:max_lines] + [f"... ({dropped} more lines 
suppressed)"]
-    return highlights
+        for line in str(record.get("event", "")).splitlines():
+            if VALIDATION_ERROR_TAG in line:
+                errors.append(line.split(VALIDATION_ERROR_TAG, 1)[1].strip())
+    if len(errors) > max_lines:
+        dropped = len(errors) - max_lines
+        errors = errors[:max_lines] + [f"... ({dropped} more lines 
suppressed)"]
+    return errors
 
 
 def _attempt_number(path: Path) -> int:
@@ -95,7 +81,7 @@ def find_log_paths(logs_folder: Path, dag_id: str, run_id: 
str, task_id: str) ->
 
 
 def print_task_log(console: Console, logs_folder: Path, dag_id: str, run_id: 
str, task_id: str) -> None:
-    """Print the warning/error highlights of a task's log(s), or a note if 
none are found."""
+    """Print the OpenLineage validation errors of a task's log(s), or a note 
if there are none."""
     console.print(f"[bold cyan]--- {dag_id} / {task_id} / START---[/]")
     log_paths = find_log_paths(logs_folder, dag_id, run_id, task_id)
     if not log_paths:
@@ -106,6 +92,7 @@ def print_task_log(console: Console, logs_folder: Path, 
dag_id: str, run_id: str
     for log_path in log_paths:
         if len(log_paths) > 1:
             console.print(f"[cyan]{log_path.relative_to(logs_folder)}[/]")
-        for line in filter_log_highlights(log_path.read_text().splitlines()):
-            console.print(line)
+        errors = extract_validation_errors(log_path.read_text().splitlines())
+        for line in errors or ["(no OpenLineage validation errors in this 
task's log)"]:
+            console.print(line, markup=False, highlight=False)
     console.print(f"[bold cyan]--- {dag_id} / {task_id} / END---[/]")
diff --git a/providers/openlineage/tests/system/openlineage/operator.py 
b/providers/openlineage/tests/system/openlineage/operator.py
index bcdd66f5d4b..d3b3180f127 100644
--- a/providers/openlineage/tests/system/openlineage/operator.py
+++ b/providers/openlineage/tests/system/openlineage/operator.py
@@ -43,6 +43,14 @@ if TYPE_CHECKING:
 
 log = logging.getLogger(__name__)
 
+# Marks every log line describing why event validation failed. The OpenLineage 
e2e tests (task_logs.py) print
+# only these lines of a failed check, keep the tag in sync with them.
+VALIDATION_ERROR_TAG = "[OL_VALIDATION_ERROR]"
+
+
+def log_validation_error(msg: str, *args: Any) -> None:
+    log.error("%s %s", VALIDATION_ERROR_TAG, msg % args if args else msg)
+
 
 def any(result: Any) -> Any:
     return result
@@ -155,13 +163,15 @@ def match(expected, result, env: Environment, path: list 
| None = None) -> bool:
     if isinstance(expected, dict):
         # Only keys present in expected are checked — extra keys in the actual 
event are ignored.
         if not isinstance(result, dict):
-            log.error("Path `%s`: expected a dict but got `%s` (%s)", 
path_str, result, type(result).__name__)
+            log_validation_error(
+                "Path `%s`: expected a dict but got `%s` (%s)", path_str, 
result, type(result).__name__
+            )
             return False
         for k, v in expected.items():
             # null sentinel: assert the key must NOT appear in the actual 
event.
             if v is None:
                 if k in result:
-                    log.error(
+                    log_validation_error(
                         "Path `%s`: key `%s` must be absent but got `%s` (%s)",
                         path_str,
                         k,
@@ -190,7 +200,7 @@ def match(expected, result, env: Environment, path: list | 
None = None) -> bool:
 
             # At this point v is a plain template (dict, list, or scalar) with 
no sentinels.
             if k not in result:
-                log.error("Path `%s`: key `%s` not found in received event", 
path_str, k)
+                log_validation_error("Path `%s`: key `%s` not found in 
received event", path_str, k)
                 return False
             if not match(v, result[k], env, path + [k]):
                 return False
@@ -198,10 +208,14 @@ def match(expected, result, env: Environment, path: list 
| None = None) -> bool:
         # Lists must match exactly in length and order. Each element is 
compared recursively,
         # so nested sentinels (null, $optional) work inside list items too.
         if not isinstance(result, list):
-            log.error("Path `%s`: expected a list but got `%s` (%s)", 
path_str, result, type(result).__name__)
+            log_validation_error(
+                "Path `%s`: expected a list but got `%s` (%s)", path_str, 
result, type(result).__name__
+            )
             return False
         if len(expected) != len(result):
-            log.error("Path `%s`: expected %d item(s) but got %d", path_str, 
len(expected), len(result))
+            log_validation_error(
+                "Path `%s`: expected %d item(s) but got %d", path_str, 
len(expected), len(result)
+            )
             return False
         for i, x in enumerate(expected):
             if not match(x, result[i], env, path + [i]):
@@ -215,11 +229,11 @@ def match(expected, result, env: Environment, path: list 
| None = None) -> bool:
             try:
                 rendered = env.from_string(expected).render(result=result)
             except (ValueError, TemplateError) as e:
-                log.error("Path `%s`: failed to render template `%s`: %s", 
path_str, expected, e)
+                log_validation_error("Path `%s`: failed to render template 
`%s`: %s", path_str, expected, e)
                 return False
             if str(rendered).lower() == "true" or rendered == result:
                 return True
-            log.error(
+            log_validation_error(
                 "Path `%s`: template `%s` rendered to `%s` but expected `true` 
or actual value `%s` (%s)",
                 path_str,
                 expected,
@@ -230,7 +244,7 @@ def match(expected, result, env: Environment, path: list | 
None = None) -> bool:
             return False
         # Plain string: exact equality check.
         if expected != result:
-            log.error(
+            log_validation_error(
                 "Path `%s`: expected `%s` (str) but got `%s` (%s)",
                 path_str,
                 expected,
@@ -240,7 +254,7 @@ def match(expected, result, env: Environment, path: list | 
None = None) -> bool:
             return False
     elif expected != result:
         # Scalar (int, bool, float, …): exact equality check.
-        log.error(
+        log_validation_error(
             "Path `%s`: expected `%s` (%s) but got `%s` (%s)",
             path_str,
             expected,

Reply via email to