Repository: dbt-labs/dbt-core

Issue:
[CT-846] Detailed `timing` info not collected for models with `error` status
The `timing` array in `run_results.json` makes it possible to see the exact timestamps when a model started/finished compiling and executing. The array appears to be entirely empty if the resource encounters a runtime error.

What users see in `run_results.json`, for an `error` model vs. a `success` model:
```json
"results": [
    {
      "status": "error",
      "timing": [],
      "thread_id": "Thread-1",
      "execution_time": 0.1041419506072998,
      "adapter_response": {},
      "message": "Database Error in model failing_model (models/failing_model.sql)\n  column \"abcdef\" does not exist\n  LINE 4:     select abcdef\n                     ^\n  compiled SQL at target/run/testy/models/failing_model.sql",
      "failures": null,
      "unique_id": "model.testy.failing_model"
    },
    {
      "status": "success",
      "timing": [
        {
          "name": "compile",
          "started_at": "2022-07-15T10:01:00.404848Z",
          "completed_at": "2022-07-15T10:01:00.410078Z"
        },
        {
          "name": "execute",
          "started_at": "2022-07-15T10:01:00.436724Z",
          "completed_at": "2022-07-15T10:01:00.530546Z"
        }
      ],
      "thread_id": "Thread-2",
      "execution_time": 0.12770366668701172,
      "adapter_response": {
        "_message": "CREATE VIEW",
        "code": "CREATE VIEW",
        "rows_affected": -1
      },
      "message": "CREATE VIEW",
      "failures": null,
      "unique_id": "model.testy.success_model"
    }
  ],
```

The relevant code is here, in the `compile_and_execute` method:

https://github.com/dbt-labs/dbt-core/blob/f988f76fccc1878aaf8d8631c05be3e9104b3b9a/core/dbt/task/base.py#L336-L340

The `collect_timing_info` context manager updates the `timing_info` attribute on `self`. However, it never appends `timing_info` above if `self.run` encounters an error (e.g. a database error):
```python
ipdb> result = self.run(ctx.node, manifest)
*** dbt.exceptions.DatabaseException: Database Error
  column "aposhfaosdfj" does not exist
  LINE 4:     select aposhfaosdfj
                     ^
```

The resolution to this bug would find a way to return/append/update the `timing_info` array even when the model encounters an error during its build.


Discussion:
Detailed timing information (including timestamps) would be available from the structured logging interface, which is where we're investing more of our effort around dbt Core metadata going forward
Related: https://github.com/dbt-labs/dbt-snowflake/issues/82#issuecomment-1315392698

Even though the `response` object is available from failing queries, because of the way we `try` materialization macros and bubble up any exceptions, we don't plumb the `adapter_response` back up to the run result object.

Resolving patch:
diff --git a/core/dbt/contracts/results.py b/core/dbt/contracts/results.py
--- a/core/dbt/contracts/results.py
+++ b/core/dbt/contracts/results.py
@@ -21,13 +21,14 @@
 from dataclasses import dataclass, field
 from datetime import datetime
 from typing import (
-    Union,
+    Any,
+    Callable,
     Dict,
     List,
-    Optional,
-    Any,
     NamedTuple,
+    Optional,
     Sequence,
+    Union,
 )
 
 from dbt.clients.system import write_json
@@ -56,15 +57,16 @@ def to_msg_dict(self):
 
 # This is a context manager
 class collect_timing_info:
-    def __init__(self, name: str):
+    def __init__(self, name: str, callback: Callable[[TimingInfo], None]):
         self.timing_info = TimingInfo(name=name)
+        self.callback = callback
 
     def __enter__(self):
         self.timing_info.begin()
-        return self.timing_info
 
     def __exit__(self, exc_type, exc_value, traceback):
         self.timing_info.end()
+        self.callback(self.timing_info)
         # Note: when legacy logger is removed, we can remove the following line
         with TimingProcessor(self.timing_info):
             fire_event(
diff --git a/core/dbt/task/base.py b/core/dbt/task/base.py
--- a/core/dbt/task/base.py
+++ b/core/dbt/task/base.py
@@ -318,12 +318,11 @@ def compile_and_execute(self, manifest, ctx):
                     node_info=ctx.node.node_info,
                 )
             )
-            with collect_timing_info("compile") as timing_info:
+            with collect_timing_info("compile", ctx.timing.append):
                 # if we fail here, we still have a compiled node to return
                 # this has the benefit of showing a build path for the errant
                 # model
                 ctx.node = self.compile(manifest)
-            ctx.timing.append(timing_info)
 
             # for ephemeral nodes, we only want to compile, not run
             if not ctx.node.is_ephemeral_model or self.run_ephemeral_models:
@@ -333,12 +332,10 @@ def compile_and_execute(self, manifest, ctx):
                         node_info=ctx.node.node_info,
                     )
                 )
-                with collect_timing_info("execute") as timing_info:
+                with collect_timing_info("execute", ctx.timing.append):
                     result = self.run(ctx.node, manifest)
                     ctx.node = result.node
 
-                ctx.timing.append(timing_info)
-
         return result
 
     def _handle_catchable_exception(self, e, ctx):
@@ -402,8 +399,7 @@ def safe_run(self, manifest):
                 error = exc_str
 
         if error is not None:
-            # we could include compile time for runtime errors here
-            result = self.error_result(ctx.node, error, started, [])
+            result = self.error_result(ctx.node, error, started, ctx.timing)
         elif result is not None:
             result = self.from_run_result(result, started, ctx.timing)
         else:


Test patch:
diff --git a/tests/functional/artifacts/test_run_results.py b/tests/functional/artifacts/test_run_results.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/artifacts/test_run_results.py
@@ -0,0 +1,32 @@
+import pytest
+from dbt.tests.util import run_dbt
+
+good_model_sql = """
+select 1 as id
+"""
+
+bad_model_sql = """
+something bad
+"""
+
+
+class TestRunResultsTimingSuccess:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {"model.sql": good_model_sql}
+
+    def test_timing_exists(self, project):
+        results = run_dbt(["run"])
+        assert len(results.results) == 1
+        assert len(results.results[0].timing) > 0
+
+
+class TestRunResultsTimingFailure:
+    @pytest.fixture(scope="class")
+    def models(self):
+        return {"model.sql": bad_model_sql}
+
+    def test_timing_exists(self, project):
+        results = run_dbt(["run"], expect_pass=False)
+        assert len(results.results) == 1
+        assert len(results.results[0].timing) > 0