Skip to content

Commit ac33c2c

Browse files
authored
[dagster-dbt] add dbt cloud logs to stdout after command runs (#32941)
## Summary & Motivation Our dbt core integration will show logs to stdout for the dbt command that was run, but we don't do that for dbt cloud. Per the dbt cloud docs, it does not have a streaming log API endpoint, but you can get the logs after the dbt commands complete. Example output: <img width="1290" height="858" alt="image" src="https://github.com/user-attachments/assets/a0ffef97-a353-48ec-8156-0b68ec435927" /> ## How I Tested These Changes BK + pytest (locally) + our dbt cloud test environment ## Changelog * [dagster-dbt] Add logs for dbt cloud to the stdout after the run completes in dbt cloud
1 parent 89f74b3 commit ac33c2c

5 files changed

Lines changed: 389 additions & 18 deletions

File tree

python_modules/libraries/dagster-dbt/dagster_dbt/cloud_v2/cli_invocation.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import sys
12
from collections.abc import Iterator, Mapping, Sequence
23
from typing import Any, Optional, Union
34

@@ -54,6 +55,12 @@ def wait(
5455
self, timeout: Optional[float] = None
5556
) -> Iterator[Union[AssetCheckEvaluation, AssetCheckResult, AssetMaterialization, Output]]:
5657
run = self.run_handler.wait(timeout=timeout)
58+
59+
# Write dbt Cloud run logs to stdout
60+
logs = self.run_handler.get_run_logs()
61+
if logs:
62+
sys.stdout.write(logs)
63+
5764
if "run_results.json" in self.run_handler.list_run_artifacts():
5865
run_results = DbtCloudJobRunResults.from_run_results_json(
5966
run_results_json=self.run_handler.get_run_results()

python_modules/libraries/dagster-dbt/dagster_dbt/cloud_v2/client.py

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _make_request(
9292
data: Optional[Mapping[str, Any]] = None,
9393
params: Optional[Mapping[str, Any]] = None,
9494
session_attr: str = "_get_session",
95-
) -> Mapping[str, Any]:
95+
) -> requests.Response:
9696
url = f"{base_url}/{endpoint}" if endpoint else base_url
9797

9898
num_retries = 0
@@ -107,7 +107,7 @@ def _make_request(
107107
timeout=self.request_timeout,
108108
)
109109
response.raise_for_status()
110-
return response.json()
110+
return response
111111
except RequestException as e:
112112
self._log.error(
113113
f"Request to dbt Cloud API failed for url {url} with method {method} : {e}"
@@ -143,7 +143,7 @@ def create_job(
143143
"""
144144
if not description:
145145
description = "A job that runs dbt models, sources, and tests."
146-
return self._make_request(
146+
response = self._make_request(
147147
method="post",
148148
endpoint="jobs",
149149
base_url=self.api_v2_url,
@@ -155,7 +155,8 @@ def create_job(
155155
"description": description,
156156
"job_type": "other",
157157
},
158-
)["data"]
158+
)
159+
return response.json()["data"]
159160

160161
def list_jobs(
161162
self,
@@ -185,7 +186,7 @@ def list_jobs(
185186
"limit": DAGSTER_DBT_CLOUD_LIST_JOBS_INDIVIDUAL_REQUEST_LIMIT,
186187
"offset": len(results),
187188
},
188-
)["data"]:
189+
).json()["data"]:
189190
results.extend(jobs)
190191
if len(jobs) < DAGSTER_DBT_CLOUD_LIST_JOBS_INDIVIDUAL_REQUEST_LIMIT:
191192
break
@@ -201,7 +202,7 @@ def get_job_details(self, job_id: int) -> Mapping[str, Any]:
201202
method="get",
202203
endpoint=f"jobs/{job_id}",
203204
base_url=self.api_v2_url,
204-
)["data"]
205+
).json()["data"]
205206

206207
def destroy_job(self, job_id: int) -> Mapping[str, Any]:
207208
"""Destroys a given dbt Cloud job.
@@ -213,7 +214,7 @@ def destroy_job(self, job_id: int) -> Mapping[str, Any]:
213214
method="delete",
214215
endpoint=f"jobs/{job_id}",
215216
base_url=self.api_v2_url,
216-
)["data"]
217+
).json()["data"]
217218

218219
def trigger_job_run(
219220
self, job_id: int, steps_override: Optional[Sequence[str]] = None
@@ -237,7 +238,7 @@ def trigger_job_run(
237238
data={"steps_override": steps_override, "cause": DAGSTER_ADHOC_TRIGGER_CAUSE}
238239
if steps_override
239240
else {"cause": DAGSTER_ADHOC_TRIGGER_CAUSE},
240-
)["data"]
241+
).json()["data"]
241242

242243
def get_runs_batch(
243244
self,
@@ -278,26 +279,35 @@ def get_runs_batch(
278279
"finished_at__range": f"""["{finished_at_lower_bound.isoformat()}", "{finished_at_upper_bound.isoformat()}"]""",
279280
"order_by": "finished_at",
280281
},
281-
)
282+
).json()
282283
data = cast("Sequence[Mapping[str, Any]]", resp["data"])
283284
total_count = resp["extra"]["pagination"]["total_count"]
284285
return data, total_count
285286

286-
def get_run_details(self, run_id: int) -> Mapping[str, Any]:
287+
def get_run_details(
288+
self, run_id: int, include_related: Optional[Sequence[str]] = None
289+
) -> Mapping[str, Any]:
287290
"""Retrieves the details of a given dbt Cloud Run.
288291
289292
Args:
290-
run_id (str): The dbt Cloud Run ID. You can retrieve this value from the
293+
run_id (int): The dbt Cloud Run ID. You can retrieve this value from the
291294
URL of the given run in the dbt Cloud UI.
295+
include_related (Optional[Sequence[str]]): List of related fields to pull with the run.
296+
Valid values are "trigger", "job", "debug_logs", and "run_steps".
292297
293298
Returns:
294299
Dict[str, Any]: Parsed json data representing the API response.
295300
"""
301+
params = {}
302+
if include_related:
303+
params["include_related"] = ",".join(include_related)
304+
296305
return self._make_request(
297306
method="get",
298307
endpoint=f"runs/{run_id}",
299308
base_url=self.api_v2_url,
300-
)["data"]
309+
params=params,
310+
).json()["data"]
301311

302312
def poll_run(
303313
self,
@@ -352,21 +362,25 @@ def list_run_artifacts(
352362
endpoint=f"runs/{run_id}/artifacts",
353363
base_url=self.api_v2_url,
354364
session_attr="_get_artifact_session",
355-
)["data"],
365+
).json()["data"],
356366
)
357367

358368
def get_run_artifact(self, run_id: int, path: str) -> Mapping[str, Any]:
359369
"""Retrieves an artifact at the given path for a given dbt Cloud Run.
360370
371+
Args:
372+
run_id (int): The dbt Cloud Run ID.
373+
path (str): The path to the artifact (e.g., "run_results.json", "manifest.json").
374+
361375
Returns:
362-
Dict[str, Any]: Parsed json data representing the API response.
376+
Dict[str, Any]: Parsed json data representing the artifact.
363377
"""
364378
return self._make_request(
365379
method="get",
366380
endpoint=f"runs/{run_id}/artifacts/{path}",
367381
base_url=self.api_v2_url,
368382
session_attr="_get_artifact_session",
369-
)
383+
).json()
370384

371385
def get_run_results_json(self, run_id: int) -> Mapping[str, Any]:
372386
"""Retrieves the run_results.json artifact of a given dbt Cloud Run.
@@ -384,6 +398,65 @@ def get_run_manifest_json(self, run_id: int) -> Mapping[str, Any]:
384398
"""
385399
return self.get_run_artifact(run_id=run_id, path="manifest.json")
386400

401+
def get_run_logs(self, run_id: int, max_retries: int = 3, retry_delay: float = 2.0) -> str:
402+
"""Retrieves the stdout/stderr logs from a given dbt Cloud Run.
403+
404+
This method fetches logs from the run_steps field by calling get_run_details
405+
with include_related=["run_steps"]. Each step contains a logs field with
406+
the stdout/stderr output for that step.
407+
408+
Note: There can be a slight delay between when a run completes and when the logs
409+
are fully populated in the API. This method will retry a few times if it detects
410+
completed steps with empty logs.
411+
412+
Args:
413+
run_id (int): The dbt Cloud Run ID.
414+
max_retries (int): Maximum number of times to retry fetching logs if empty. Defaults to 3.
415+
retry_delay (float): Time in seconds to wait between retries. Defaults to 2.0.
416+
417+
Returns:
418+
str: The concatenated log text content from all run steps.
419+
"""
420+
for attempt in range(max_retries):
421+
run_details = self.get_run_details(run_id=run_id, include_related=["run_steps"])
422+
423+
logs_parts = []
424+
run_steps = run_details.get("run_steps", [])
425+
completed_steps_with_empty_logs = 0
426+
427+
for step in run_steps:
428+
step_name = step.get("name", "Unknown Step")
429+
step_logs = step.get("logs", "")
430+
step_status = step.get("status_humanized", "unknown")
431+
432+
# Track completed steps with empty logs
433+
if step_status == "Success" and not step_logs:
434+
completed_steps_with_empty_logs += 1
435+
436+
if step_logs:
437+
logs_parts.append(f"=== Step: {step_name} ===")
438+
logs_parts.append(step_logs)
439+
logs_parts.append("") # Empty line between steps
440+
441+
# If we have completed steps with empty logs and retries left, wait and try again
442+
if completed_steps_with_empty_logs > 0 and attempt < max_retries - 1:
443+
self._log.warning(
444+
f"Found {completed_steps_with_empty_logs} completed steps with empty logs for run {run_id}. "
445+
f"Retrying in {retry_delay} seconds..."
446+
)
447+
time.sleep(retry_delay)
448+
continue
449+
450+
# Either we got all logs or we're out of retries
451+
if completed_steps_with_empty_logs > 0:
452+
self._log.warning(
453+
f"Still missing logs for {completed_steps_with_empty_logs} completed steps after {max_retries} attempts"
454+
)
455+
456+
return "\n".join(logs_parts) if logs_parts else ""
457+
458+
return ""
459+
387460
def get_project_details(self, project_id: int) -> Mapping[str, Any]:
388461
"""Retrieves the details of a given dbt Cloud Project.
389462
@@ -398,7 +471,7 @@ def get_project_details(self, project_id: int) -> Mapping[str, Any]:
398471
method="get",
399472
endpoint=f"projects/{project_id}",
400473
base_url=self.api_v2_url,
401-
)["data"]
474+
).json()["data"]
402475

403476
def get_environment_details(self, environment_id: int) -> Mapping[str, Any]:
404477
"""Retrieves the details of a given dbt Cloud Environment.
@@ -414,7 +487,7 @@ def get_environment_details(self, environment_id: int) -> Mapping[str, Any]:
414487
method="get",
415488
endpoint=f"environments/{environment_id}",
416489
base_url=self.api_v2_url,
417-
)["data"]
490+
).json()["data"]
418491

419492
def get_account_details(self) -> Mapping[str, Any]:
420493
"""Retrieves the details of the account associated to the dbt Cloud workspace.
@@ -426,7 +499,7 @@ def get_account_details(self) -> Mapping[str, Any]:
426499
method="get",
427500
endpoint=None,
428501
base_url=self.api_v2_url,
429-
)["data"]
502+
).json()["data"]
430503

431504
def verify_connection(self) -> None:
432505
"""Verifies the connection to the dbt Cloud REST API."""

python_modules/libraries/dagster-dbt/dagster_dbt/cloud_v2/run_handler.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from dagster._record import record
1515
from dateutil import parser
16+
from requests.exceptions import RequestException
1617

1718
from dagster_dbt.asset_utils import build_dbt_specs, get_asset_check_key_for_test
1819
from dagster_dbt.cloud_v2.client import DbtCloudWorkspaceClient
@@ -61,6 +62,22 @@ def get_manifest(self) -> Mapping[str, Any]:
6162
def list_run_artifacts(self) -> Sequence[str]:
6263
return self.client.list_run_artifacts(run_id=self.run_id)
6364

65+
def get_run_logs(self) -> Optional[str]:
66+
"""Retrieves the stdout/stderr logs from the completed dbt Cloud run.
67+
68+
This method fetches logs from the run_steps by calling get_run_details
69+
with include_related=["run_steps"].
70+
71+
Returns:
72+
Optional[str]: The concatenated log text content from all run steps,
73+
or None if logs are not available.
74+
"""
75+
try:
76+
return self.client.get_run_logs(run_id=self.run_id)
77+
except RequestException as e:
78+
logger.warning(f"Failed to retrieve logs for run {self.run_id}: {e}")
79+
return None
80+
6481

6582
def get_completed_at_timestamp(result: Mapping[str, Any]) -> float:
6683
# result["timing"] is a list of events in run_results.json

python_modules/libraries/dagster-dbt/dagster_dbt_tests/cloud_v2/conftest.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,45 @@ def get_sample_run_response(run_status: int, job_id: int = TEST_ADHOC_JOB_ID) ->
654654
)
655655

656656

657+
def get_sample_run_response_with_logs(
658+
run_status: int, job_id: int = TEST_ADHOC_JOB_ID
659+
) -> Mapping[str, Any]:
660+
"""Returns a sample run response with realistic run_steps logs."""
661+
return {
662+
"data": {
663+
**get_sample_run_data(run_status=run_status, job_id=job_id),
664+
"run_steps": [
665+
{
666+
"id": 1,
667+
"run_id": TEST_RUN_ID,
668+
"account_id": TEST_ACCOUNT_ID,
669+
"index": 1,
670+
"status": 10,
671+
"name": "Clone git repository",
672+
"logs": "Cloning into '/tmp/jobs/12345/target'...\nSuccessfully cloned repository.\nChecked out to abc123.",
673+
"debug_logs": "",
674+
},
675+
{
676+
"id": 2,
677+
"run_id": TEST_RUN_ID,
678+
"account_id": TEST_ACCOUNT_ID,
679+
"index": 2,
680+
"status": 10,
681+
"name": "Invoke dbt with `dbt build`",
682+
"logs": "15:49:32 Running dbt...\n15:49:34 Found 5 models, 13 data tests\n15:49:35 Completed successfully",
683+
"debug_logs": "",
684+
},
685+
],
686+
},
687+
"status": {
688+
"code": 200,
689+
"is_success": True,
690+
"user_message": "Success!",
691+
"developer_message": "",
692+
},
693+
}
694+
695+
657696
# Taken from dbt Cloud REST API documentation
658697
# https://docs.getdbt.com/dbt-cloud/api-v2#/operations/List%20Runs
659698
def get_sample_list_runs_sample(data: Sequence[Mapping[str, Any]], count: int, total_count: int):

0 commit comments

Comments
 (0)