Repository: dbt-labs/dbt-core

Issue:
[CT-2049] Fire an event at the end of every command
On the `feature/click-cli` branch, each command explicitly returns a tuple of `success: bool, results: Any`. (The structure of `results` differs based on the command.)

We should additionally fire an event that can be used programmatically to determine:
- when the command completed
- if the command succeeded

Ideally, this event would be fired _always_, even in cases where the command was interrupted (`^C`) or cancelled itself (e.g. `--fail-fast`).


Discussion:
Super agree with this. I suggest that we might want to have a CommandCompleted event that contains the data common to all commands, such as timing and success, and refer to an additional event that would contain more of the specific Result dump. We could do a "oneof" of all of the various Result objects, but the way they work in python protobufs is kind of kludgey and slow. We could even include the name of the Result event in the CommandCompleted event, if that would help.
Discussed during BLG: This event shouldn't include the full result that would be returned from the command if invoked programmatically, à la:
```python
results, success = dbt.invoke(['<command>'])
```
**Rationale:**
- those `results` can be different types (depending on the command), as Gerda mentioned above
- node-level results are already fired as events as soon as the node is finished
- we should already have events that "summarize" a graph-running command (how many nodes, how many succeeded, etc)

This event's `data` only needs to include:
- `which` command
- whether it succeeded (boolean)
- timestamp when it finished
- how long it took

(Plus standard metadata of course - `invocation_id` etc - which should already be present in the event's `info` dict, no specific plumbing needed)
Will need to connect with the Execution Team for where we should fire this event.  Possibly [here](https://github.com/dbt-labs/dbt-core/blob/main/core/dbt/tracking.py#L445-L461)?
Per BLG -- 2/13/23 we'd probably want to do this in the preflight decorator as a generator.  If the language team picks this ticket up feel free to ask for a pair from the Execution team, we'd be happy to show how/where that lives.

Resolving patch:
diff --git a/core/dbt/cli/requires.py b/core/dbt/cli/requires.py
--- a/core/dbt/cli/requires.py
+++ b/core/dbt/cli/requires.py
@@ -5,7 +5,12 @@
 from dbt.config import RuntimeConfig
 from dbt.config.runtime import load_project, load_profile, UnsetProfile
 from dbt.events.functions import setup_event_logger, fire_event, LOG_VERSION
-from dbt.events.types import MainReportVersion, MainReportArgs, MainTrackingUserState
+from dbt.events.types import (
+    CommandCompleted,
+    MainReportVersion,
+    MainReportArgs,
+    MainTrackingUserState,
+)
 from dbt.exceptions import DbtProjectError
 from dbt.parser.manifest import ManifestLoader, write_manifest
 from dbt.profiler import profiler
@@ -13,7 +18,9 @@
 from dbt.utils import cast_dict_to_dict_of_strings
 
 from click import Context
+import datetime
 from functools import update_wrapper
+import time
 
 
 def preflight(func):
@@ -53,7 +60,33 @@ def wrapper(*args, **kwargs):
         # Adapter management
         ctx.with_resource(adapter_management())
 
-        return func(*args, **kwargs)
+        start_func = time.perf_counter()
+
+        try:
+            (results, success) = func(*args, **kwargs)
+
+            fire_event(
+                CommandCompleted(
+                    command=ctx.command_path,
+                    success=success,
+                    completed_at=datetime.datetime.utcnow(),
+                    elapsed=time.perf_counter() - start_func,
+                )
+            )
+        # Bare except because we really do want to catch ALL exceptions,
+        # i.e. we want to fire this event in ALL cases.
+        except:  # noqa
+            fire_event(
+                CommandCompleted(
+                    command=ctx.command_path,
+                    success=False,
+                    completed_at=datetime.datetime.utcnow(),
+                    elapsed=time.perf_counter() - start_func,
+                )
+            )
+            raise
+
+        return (results, success)
 
     return update_wrapper(wrapper, func)
 
diff --git a/core/dbt/events/proto_types.py b/core/dbt/events/proto_types.py
--- a/core/dbt/events/proto_types.py
+++ b/core/dbt/events/proto_types.py
@@ -2184,6 +2184,22 @@ class NoNodesSelectedMsg(betterproto.Message):
     data: "NoNodesSelected" = betterproto.message_field(2)
 
 
+@dataclass
+class CommandCompleted(betterproto.Message):
+    """Q039"""
+
+    command: str = betterproto.string_field(1)
+    success: bool = betterproto.bool_field(2)
+    completed_at: datetime = betterproto.message_field(3)
+    elapsed: float = betterproto.float_field(4)
+
+
+@dataclass
+class CommandCompletedMsg(betterproto.Message):
+    info: "EventInfo" = betterproto.message_field(1)
+    data: "CommandCompleted" = betterproto.message_field(2)
+
+
 @dataclass
 class CatchableExceptionOnRun(betterproto.Message):
     """W002"""
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
@@ -1827,6 +1827,16 @@ def message(self) -> str:
         return "No nodes selected!"
 
 
+@dataclass
+class CommandCompleted(DebugLevel, pt.CommandCompleted):
+    def code(self):
+        return "Q039"
+
+    def message(self) -> str:
+        status = "succeeded" if self.success else "failed"
+        return f"Command `{self.command}` {status} at {self.completed_at} after {self.elapsed:0.2f} seconds"
+
+
 # =======================================================
 # W - Node testing
 # =======================================================


Test patch:
diff --git a/tests/unit/test_events.py b/tests/unit/test_events.py
--- a/tests/unit/test_events.py
+++ b/tests/unit/test_events.py
@@ -1,6 +1,7 @@
 import re
 from typing import TypeVar
 
+from datetime import datetime
 from dbt.contracts.results import TimingInfo
 from dbt.events import AdapterLogger, test_types, types
 from dbt.events.base_types import (
@@ -326,6 +327,7 @@ def test_event_codes(self):
     types.NoNodesSelected(),
     types.DepsUnpinned(revision="", git=""),
     types.NoNodesForSelectionCriteria(spec_raw=""),
+    types.CommandCompleted(command="", success=True, elapsed=0.1, completed_at=datetime.utcnow()),
     # W - Node testing ======================
     types.CatchableExceptionOnRun(exc=""),
     types.InternalErrorOnRun(build_path="", exc=""),