This is an automated email from the ASF dual-hosted git repository.
josh-fell 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 ac263314c1f Surface dbt Cloud failure details in Airflow task logs
(#70171)
ac263314c1f is described below
commit ac263314c1f766117a7dbfbef9766868e83380fe
Author: Senior Data Engineer <[email protected]>
AuthorDate: Mon Sep 14 07:36:34 2026 -0600
Surface dbt Cloud failure details in Airflow task logs (#70171)
* Surface dbt Cloud failure details in Airflow task logs
Log run status, status messages, and failed run steps when a dbt Cloud
job run reaches ERROR or CANCELLED in the hook, operator, and sensor.
Closes #46923
* fix(dbt-cloud): repair CI after failure-detail logging
- Mock log_job_run_failure_details in deferrable operator failure test
- Expect extra get_job_run call when logging ERROR/CANCELLED runs
- Fix sensor test account_id assertion; add newsfragment; ruff format
* fix(dbt-cloud): rename newsfragment to match PR number 70171
* Remove newsfragment per maintainer review
Simple provider improvements do not require newsfragments in this repo.
* fix(dbt-cloud): drop redundant fallback on failure logging helper
log_job_run_failure_details only needs account_id when it calls get_job_run,
which already applies @fallback_to_default_account.
* test(dbt-cloud): expect raw account_id in failure logging helper
get_job_run applies @fallback_to_default_account;
log_job_run_failure_details passes account_id through unchanged.
---
.../src/airflow/providers/dbt/cloud/hooks/dbt.py | 56 +++++++++++++++
.../airflow/providers/dbt/cloud/operators/dbt.py | 3 +
.../src/airflow/providers/dbt/cloud/sensors/dbt.py | 2 +
.../cloud/tests/unit/dbt/cloud/hooks/test_dbt.py | 81 ++++++++++++++++++++++
.../tests/unit/dbt/cloud/operators/test_dbt.py | 11 ++-
.../cloud/tests/unit/dbt/cloud/sensors/test_dbt.py | 7 +-
6 files changed, 157 insertions(+), 3 deletions(-)
diff --git a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
index 719b5181bfe..40129cf41b4 100644
--- a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
+++ b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/hooks/dbt.py
@@ -825,6 +825,61 @@ class DbtCloudHook(HttpHook):
return job_run_status
+ @staticmethod
+ def _format_run_step_failure(step: dict[str, Any]) -> str:
+ details = [f"step {step.get('index')}: {step.get('name',
'<unknown>')}"]
+ if status_humanized := step.get("status_humanized"):
+ details.append(f"status={status_humanized}")
+ elif status := step.get("status"):
+ details.append(f"status_code={status}")
+ for field in ("status_message", "log", "logs", "debug_logs"):
+ if value := step.get(field):
+ details.append(f"{field}={value}")
+ return " | ".join(details)
+
+ def log_job_run_failure_details(
+ self,
+ run_id: int,
+ account_id: int | None = None,
+ job_run: dict[str, Any] | None = None,
+ ) -> None:
+ """
+ Log dbt Cloud run failure context in Airflow task logs.
+
+ Fetches run metadata (including run steps) when ``job_run`` is not
provided.
+ """
+ if job_run is None:
+ job_run = self.get_job_run(
+ run_id=run_id,
+ account_id=account_id,
+ include_related=["run_steps"],
+ ).json()["data"]
+
+ run_status = job_run.get("status")
+ if run_status is not None:
+ try:
+ status_name = DbtCloudJobRunStatus(run_status).name
+ except ValueError:
+ status_name = str(run_status)
+ self.log.error("dbt Cloud job run %s ended with status %s.",
run_id, status_name)
+
+ for field in ("status_message", "status_humanized", "status_msg"):
+ if message := job_run.get(field):
+ self.log.error("dbt Cloud job run %s: %s", run_id, message)
+ break
+
+ run_steps = job_run.get("run_steps") or []
+ error_steps = [step for step in run_steps if step.get("status") ==
DbtCloudJobRunStatus.ERROR.value]
+
+ if error_steps:
+ for step in error_steps:
+ self.log.error("dbt Cloud failed step — %s",
self._format_run_step_failure(step))
+ elif run_steps:
+ self.log.error(
+ "dbt Cloud last run step — %s",
+ self._format_run_step_failure(run_steps[-1]),
+ )
+
def wait_for_job_run_status(
self,
run_id: int,
@@ -863,6 +918,7 @@ class DbtCloudHook(HttpHook):
# Reached terminal failure before expected state.
if DbtCloudJobRunStatus.is_terminal(job_run_status):
+ self.log_job_run_failure_details(run_id=run_id,
account_id=account_id)
raise DbtCloudJobRunException(
f"Job run {run_id} reached terminal status "
f"{DbtCloudJobRunStatus(job_run_status).name} "
diff --git
a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/operators/dbt.py
b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/operators/dbt.py
index b38d9a0f3cd..23948af8702 100644
--- a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/operators/dbt.py
+++ b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/operators/dbt.py
@@ -209,6 +209,7 @@ class DbtCloudRunJobOperator(BaseOperator):
DbtCloudJobRunStatus.CANCELLED.value,
DbtCloudJobRunStatus.ERROR.value,
):
+ self.hook.log_job_run_failure_details(run_id=self.run_id,
account_id=self.account_id)
raise DbtCloudJobRunException(f"Job run {self.run_id} has failed
or has been cancelled.")
return None
@@ -287,8 +288,10 @@ class DbtCloudRunJobOperator(BaseOperator):
"""Execute when the trigger fires - returns immediately."""
self.run_id = event["run_id"]
if event["status"] == "cancelled":
+ self.hook.log_job_run_failure_details(run_id=int(self.run_id),
account_id=self.account_id)
raise DbtCloudJobRunException(f"Job run {self.run_id} has been
cancelled.")
if event["status"] == "error":
+ self.hook.log_job_run_failure_details(run_id=int(self.run_id),
account_id=self.account_id)
raise DbtCloudJobRunException(f"Job run {self.run_id} has failed.")
# Enforce execution_timeout semantics in deferrable mode by cancelling
the job.
diff --git a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/sensors/dbt.py
b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/sensors/dbt.py
index 1ef13ab61f7..2b40dd8724f 100644
--- a/providers/dbt/cloud/src/airflow/providers/dbt/cloud/sensors/dbt.py
+++ b/providers/dbt/cloud/src/airflow/providers/dbt/cloud/sensors/dbt.py
@@ -79,10 +79,12 @@ class DbtCloudJobRunSensor(BaseSensorOperator):
job_run_status = self.hook.get_job_run_status(run_id=self.run_id,
account_id=self.account_id)
if job_run_status == DbtCloudJobRunStatus.ERROR.value:
+ self.hook.log_job_run_failure_details(run_id=self.run_id,
account_id=self.account_id)
message = f"Job run {self.run_id} has failed."
raise DbtCloudJobRunException(message)
if job_run_status == DbtCloudJobRunStatus.CANCELLED.value:
+ self.hook.log_job_run_failure_details(run_id=self.run_id,
account_id=self.account_id)
message = f"Job run {self.run_id} has been cancelled."
raise DbtCloudJobRunException(message)
diff --git a/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
b/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
index 951e8505496..1c573d8a36a 100644
--- a/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
+++ b/providers/dbt/cloud/tests/unit/dbt/cloud/hooks/test_dbt.py
@@ -1080,6 +1080,81 @@ class TestDbtCloudHook:
)
hook._paginate.assert_not_called()
+ def test_format_run_step_failure(self):
+ step = {
+ "index": 2,
+ "name": "Run dbt",
+ "status": DbtCloudJobRunStatus.ERROR.value,
+ "status_humanized": "Error",
+ "status_message": "dbt run failed",
+ }
+ formatted = DbtCloudHook._format_run_step_failure(step)
+ assert "step 2: Run dbt" in formatted
+ assert "status=Error" in formatted
+ assert "status_message=dbt run failed" in formatted
+
+ @pytest.mark.parametrize(
+ argnames=("conn_id", "account_id"),
+ argvalues=[(ACCOUNT_ID_CONN, None), (NO_ACCOUNT_ID_CONN, ACCOUNT_ID)],
+ ids=["default_account", "explicit_account"],
+ )
+ def test_log_job_run_failure_details_with_error_steps(self, conn_id,
account_id):
+ hook = DbtCloudHook(conn_id)
+ job_run = {
+ "status": DbtCloudJobRunStatus.ERROR.value,
+ "status_message": "Run failed",
+ "run_steps": [
+ {"index": 1, "name": "Clone git repo", "status": 10,
"status_humanized": "Success"},
+ {
+ "index": 2,
+ "name": "Run dbt",
+ "status": DbtCloudJobRunStatus.ERROR.value,
+ "status_humanized": "Error",
+ "status_message": "Compilation Error",
+ },
+ ],
+ }
+
+ with patch.object(hook.log, "error") as mock_log_error:
+ hook.log_job_run_failure_details(run_id=RUN_ID,
account_id=account_id, job_run=job_run)
+
+ mock_log_error.assert_any_call("dbt Cloud job run %s ended with status
%s.", RUN_ID, "ERROR")
+ mock_log_error.assert_any_call("dbt Cloud job run %s: %s", RUN_ID,
"Run failed")
+ mock_log_error.assert_any_call(
+ "dbt Cloud failed step — %s",
+ DbtCloudHook._format_run_step_failure(job_run["run_steps"][1]),
+ )
+
+ @pytest.mark.parametrize(
+ argnames=("conn_id", "account_id"),
+ argvalues=[(ACCOUNT_ID_CONN, None), (NO_ACCOUNT_ID_CONN, ACCOUNT_ID)],
+ ids=["default_account", "explicit_account"],
+ )
+ @patch.object(DbtCloudHook, "get_job_run")
+ def test_log_job_run_failure_details_fetches_run_steps(self,
mock_get_job_run, conn_id, account_id):
+ hook = DbtCloudHook(conn_id)
+ job_run = {
+ "status": DbtCloudJobRunStatus.ERROR.value,
+ "run_steps": [
+ {
+ "index": 1,
+ "name": "Run dbt",
+ "status": DbtCloudJobRunStatus.ERROR.value,
+ "status_humanized": "Error",
+ }
+ ],
+ }
+ mock_get_job_run.return_value.json.return_value = {"data": job_run}
+
+ with patch.object(hook.log, "error"):
+ hook.log_job_run_failure_details(run_id=RUN_ID,
account_id=account_id)
+
+ mock_get_job_run.assert_called_once_with(
+ run_id=RUN_ID,
+ account_id=account_id,
+ include_related=["run_steps"],
+ )
+
wait_for_job_run_status_test_args = [
(DbtCloudJobRunStatus.SUCCESS.value,
DbtCloudJobRunStatus.SUCCESS.value, True),
(DbtCloudJobRunStatus.ERROR.value, DbtCloudJobRunStatus.SUCCESS.value,
"exception"),
@@ -1117,15 +1192,21 @@ class TestDbtCloudHook:
with (
patch.object(DbtCloudHook, "get_job_run_status") as
mock_job_run_status,
+ patch.object(DbtCloudHook, "log_job_run_failure_details") as
mock_log_failure_details,
patch("airflow.providers.dbt.cloud.hooks.dbt.time.sleep",
side_effect=fake_sleep),
):
mock_job_run_status.return_value = job_run_status
if expected_output not in ("timeout", "exception"):
assert hook.wait_for_job_run_status(**config) ==
expected_output
+ mock_log_failure_details.assert_not_called()
else:
with pytest.raises(DbtCloudJobRunException):
hook.wait_for_job_run_status(**config)
+ if expected_output == "exception":
+
mock_log_failure_details.assert_called_once_with(run_id=RUN_ID, account_id=None)
+ else:
+ mock_log_failure_details.assert_not_called()
@pytest.mark.parametrize(
argnames=("conn_id", "account_id"),
diff --git a/providers/dbt/cloud/tests/unit/dbt/cloud/operators/test_dbt.py
b/providers/dbt/cloud/tests/unit/dbt/cloud/operators/test_dbt.py
index 67d3fc33100..134ad6b6ecf 100644
--- a/providers/dbt/cloud/tests/unit/dbt/cloud/operators/test_dbt.py
+++ b/providers/dbt/cloud/tests/unit/dbt/cloud/operators/test_dbt.py
@@ -158,6 +158,7 @@ class TestDbtCloudRunJobOperator:
"airflow.providers.dbt.cloud.hooks.dbt.DbtCloudHook.get_job_run_status",
return_value=DbtCloudJobRunStatus.ERROR.value,
)
+
@patch("airflow.providers.dbt.cloud.hooks.dbt.DbtCloudHook.log_job_run_failure_details")
@patch("airflow.providers.dbt.cloud.operators.dbt.DbtCloudRunJobOperator.defer")
@patch("airflow.providers.dbt.cloud.hooks.dbt.DbtCloudHook.get_connection")
@patch(
@@ -165,7 +166,7 @@ class TestDbtCloudRunJobOperator:
return_value=mock_response_json(DEFAULT_ACCOUNT_JOB_RUN_RESPONSE),
)
def test_execute_failed_before_getting_deferred(
- self, mock_trigger_job_run, mock_dbt_hook, mock_defer,
mock_job_run_status
+ self, mock_trigger_job_run, mock_dbt_hook, mock_defer,
mock_log_failure_details, mock_job_run_status
):
dbt_op = DbtCloudRunJobOperator(
dbt_cloud_conn_id=ACCOUNT_ID_CONN,
@@ -558,7 +559,13 @@ class TestDbtCloudRunJobOperator:
)
if job_run_status in DbtCloudJobRunStatus.TERMINAL_STATUSES.value:
- assert mock_get_job_run.call_count == 1
+ if job_run_status in (
+ DbtCloudJobRunStatus.ERROR.value,
+ DbtCloudJobRunStatus.CANCELLED.value,
+ ):
+ assert mock_get_job_run.call_count == 2
+ else:
+ assert mock_get_job_run.call_count == 1
else:
# When the job run status is not in a terminal status or
"Success", the operator will
# continue to call ``get_job_run()`` until a ``timeout``
number of seconds has passed
diff --git a/providers/dbt/cloud/tests/unit/dbt/cloud/sensors/test_dbt.py
b/providers/dbt/cloud/tests/unit/dbt/cloud/sensors/test_dbt.py
index bc3a8e542fb..9435fad5d2b 100644
--- a/providers/dbt/cloud/tests/unit/dbt/cloud/sensors/test_dbt.py
+++ b/providers/dbt/cloud/tests/unit/dbt/cloud/sensors/test_dbt.py
@@ -87,8 +87,11 @@ class TestDbtCloudJobRunSensor:
(30, "exception"), # CANCELLED
],
)
+ @patch.object(DbtCloudHook, "log_job_run_failure_details")
@patch.object(DbtCloudHook, "get_job_run_status")
- def test_poke_with_exception(self, mock_job_run_status, job_run_status,
expected_poke_result):
+ def test_poke_with_exception(
+ self, mock_job_run_status, mock_log_failure_details, job_run_status,
expected_poke_result
+ ):
mock_job_run_status.return_value = job_run_status
# The sensor should fail if the job run status is 20 (aka Error) or 30
(aka Cancelled).
@@ -100,6 +103,8 @@ class TestDbtCloudJobRunSensor:
with pytest.raises(DbtCloudJobRunException, match=error_message):
self.sensor.poke({})
+ mock_log_failure_details.assert_called_once_with(run_id=RUN_ID,
account_id=ACCOUNT_ID)
+
@mock.patch("airflow.providers.dbt.cloud.sensors.dbt.DbtCloudHook")
@mock.patch("airflow.providers.dbt.cloud.sensors.dbt.DbtCloudJobRunSensor.defer")
def test_dbt_cloud_job_run_sensor_finish_before_deferred(self, mock_defer,
mock_hook):