Repository: dbt-labs/dbt-core

Issue:
[CT-649] [Feature] Prettify duration message at the end of execution
### Is this your first time opening an issue?

- [X] I have read the [expectations for open source contributors](https://docs.getdbt.com/docs/contributing/oss-expectations)

### Describe the Feature

Hi colleagues,
Would like to suggest more human-readable logging for end of execution dbt commands
Now we log only time in seconds, but it's not very convenient to convert it in your head every time you need to understand how long it was
For example:
```
Finished running 15 tests, 1 hook in 410.20s.
```
I would suggest to write it in the following way:
```
Finished running 15 tests, 1 hook in 6 min 51s (410.20s).
```
Saving pure seconds for those cases who might be parsing the duration from logs using RegExp

### Describe alternatives you've considered

_No response_

### Who will this benefit?

Those who execute pipelines longer than few minutes

### Are you interested in contributing this feature?

I think my team can do it

### Anything else?

_No response_


Discussion:
@SOVALINUX Makes sense to me! We do something similar for `bytes_processed` on `dbt-bigquery` [here](https://github.com/dbt-labs/dbt-bigquery/blob/8aaa6ab6da182aa8e831f631feb12af5ec76d9ce/dbt/adapters/bigquery/connections.py#L245-L253). The underlying data is still recorded as an integer (number of bytes, number of seconds), but then we convert the value for inclusion in the human-friendly message.

The relevant message is in a event named `HookFinished`:

https://github.com/dbt-labs/dbt-core/blob/0d8e061a3d8d726bb10ff767d8f276439cc472b2/core/dbt/task/run.py#L394-L405

https://github.com/dbt-labs/dbt-core/blob/0d8e061a3d8d726bb10ff767d8f276439cc472b2/core/dbt/events/types.py#L1500-L1507

I'm going to mark this a `good first issue`. It sounds like it's a change your team may be interested in contributing?

---

**Tech debt:** I'll be honest, this event feels far from perfect:
- `HookFinished` doesn't feel like the right name, versus `RunResultsLine` or similar. (I'm not sure if or how we could make this change in a backwards-compatible manner, though)
- I'd much rather pass in a structured dictionary of node counts, and then render the human-friendly `stat_line` for the event's message
- We should also be storing `execution_time` as a float, rather than `execution` as a string

Those are beyond the scope of the specific change you're proposing. For what you're after, I think it would be reasonable to:
- Format `execution`, as a string, with hours/minutes/seconds (`1 hr 6 min 51s`)
- Add `execution_time` (float, representing seconds) as a dataclass attribute that gets passed into `HookFinished`, and included in its structured (JSON) output, even if it isn't templated into the human-friendly `message`
Hi
Will take a look, thanks! It's not the first priority for my obviously, but sooner or later we can make it :)
Thanks for your inputs!

Resolving patch:
diff --git a/core/dbt/events/types.py b/core/dbt/events/types.py
--- a/core/dbt/events/types.py
+++ b/core/dbt/events/types.py
@@ -1501,10 +1501,11 @@ def message(self) -> str:
 class HookFinished(InfoLevel):
     stat_line: str
     execution: str
+    execution_time: float
     code: str = "E040"
 
     def message(self) -> str:
-        return f"Finished running {self.stat_line}{self.execution}."
+        return f"Finished running {self.stat_line}{self.execution} ({self.execution_time:0.2f}s)."
 
 
 @dataclass
@@ -2620,7 +2621,7 @@ def message(self) -> str:
     DatabaseErrorRunning(hook_type="")
     EmptyLine()
     HooksRunning(num_hooks=0, hook_type="")
-    HookFinished(stat_line="", execution="")
+    HookFinished(stat_line="", execution="", execution_time=0)
     WriteCatalogFailure(num_exceptions=0)
     CatalogWritten(path="")
     CannotGenerateDocs()
diff --git a/core/dbt/task/run.py b/core/dbt/task/run.py
--- a/core/dbt/task/run.py
+++ b/core/dbt/task/run.py
@@ -398,11 +398,13 @@ def print_results_line(self, results, execution_time):
         execution = ""
 
         if execution_time is not None:
-            execution = " in {execution_time:0.2f}s".format(execution_time=execution_time)
+            execution = utils.humanize_execution_time(execution_time=execution_time)
 
         with TextOnly():
             fire_event(EmptyLine())
-        fire_event(HookFinished(stat_line=stat_line, execution=execution))
+        fire_event(
+            HookFinished(stat_line=stat_line, execution=execution, execution_time=execution_time)
+        )
 
     def _get_deferred_manifest(self) -> Optional[WritableManifest]:
         if not self.args.defer:
diff --git a/core/dbt/utils.py b/core/dbt/utils.py
--- a/core/dbt/utils.py
+++ b/core/dbt/utils.py
@@ -317,6 +317,13 @@ def timestring() -> str:
     return datetime.datetime.utcnow().isoformat() + "Z"
 
 
+def humanize_execution_time(execution_time: int) -> str:
+    minutes, seconds = divmod(execution_time, 60)
+    hours, minutes = divmod(minutes, 60)
+
+    return f" in {int(hours)} hours {int(minutes)} minutes and {seconds:0.2f} seconds"
+
+
 class JSONEncoder(json.JSONEncoder):
     """A 'custom' json encoder that does normal json encoder things, but also
     handles `Decimal`s and `Undefined`s. Decimals can lose precision because


Test patch:
diff --git a/test/unit/test_events.py b/test/unit/test_events.py
--- a/test/unit/test_events.py
+++ b/test/unit/test_events.py
@@ -313,7 +313,7 @@ def MockNode():
     DatabaseErrorRunning(hook_type=''),
     EmptyLine(),
     HooksRunning(num_hooks=0, hook_type=''),
-    HookFinished(stat_line='', execution=''),
+    HookFinished(stat_line='', execution='', execution_time=0),
     WriteCatalogFailure(num_exceptions=0),
     CatalogWritten(path=''),
     CannotGenerateDocs(),
diff --git a/test/unit/test_utils.py b/test/unit/test_utils.py
--- a/test/unit/test_utils.py
+++ b/test/unit/test_utils.py
@@ -207,3 +207,22 @@ def test_two_members_overlap(self):
         assert md['c'] == 1
         assert md['d'] == 2
         assert md['e'] == 3
+
+class TestHumanizeExecutionTime(unittest.TestCase):
+    def test_humanzing_execution_time_with_integer(self):
+
+        result = dbt.utils.humanize_execution_time(execution_time=9460)
+
+        assert result == " in 2 hours 37 minutes and 40.00 seconds"
+
+    def test_humanzing_execution_time_with_two_decimal_place_float(self):
+
+        result = dbt.utils.humanize_execution_time(execution_time=0.32)
+
+        assert result == " in 0 hours 0 minutes and 0.32 seconds"
+
+    def test_humanzing_execution_time_with_four_decimal_place_float(self):
+
+        result = dbt.utils.humanize_execution_time(execution_time=0.3254)
+
+        assert result == " in 0 hours 0 minutes and 0.33 seconds"
diff --git a/tests/functional/basic/test_jaffle_shop.py b/tests/functional/basic/test_jaffle_shop.py
--- a/tests/functional/basic/test_jaffle_shop.py
+++ b/tests/functional/basic/test_jaffle_shop.py
@@ -1,4 +1,4 @@
-from dbt.tests.util import run_dbt, get_manifest
+from dbt.tests.util import run_dbt, get_manifest, run_dbt_and_capture
 
 
 from tests.fixtures.jaffle_shop import JaffleShopProject
@@ -14,3 +14,11 @@ def test_basic(self, project):
         assert len(results) == 5
         manifest = get_manifest(project.project_root)
         assert "model.jaffle_shop.orders" in manifest.nodes
+
+    def test_execution_time_format_is_humanized(self, project):
+        # Create the data from seeds
+        run_dbt(["seed"])
+        _, log_output = run_dbt_and_capture(["run"])
+
+        assert " in 0 hours 0 minutes and " in log_output
+        assert " seconds" in log_output