Repository: meltano/meltano

Issue:
When possible, emit exit event with exception data
The exception context is automatically added to every event. If an event occurs while an error is being handled (i.e. within some `except` block) then we'll gain info about the exception.

Unfortunately the stack is unwound before the atexit handlers are run, so if an exit occurs within an exception block, we'll get an exit event, but the exception context it comes with will not have details about the exception that originally triggered the exit.

To get that info, we'll probably want to fire an event within [the finally block in `meltano.cli`](https://github.com/meltano/meltano/blob/3b404ac0575e5f6a5e48807b2aa5b6a4eae16bfa/src/meltano/cli/__init__.py#L75). This can be the exit event. We'll add a global flag for whether the exit event has been fired. If we fire the exit even within `meltano.cli`, then it will not be fired from the atexit handler later. This way we can still leverage atexit to ensure the exit event is fired in all possible cases, but also get more info about what caused the exit whenever possible.

_Originally posted by @WillDaSilva in https://github.com/meltano/meltano/issues/6149#issuecomment-1154676553_


Discussion:

Resolving patch:
diff --git a/src/meltano/cli/__init__.py b/src/meltano/cli/__init__.py
--- a/src/meltano/cli/__init__.py
+++ b/src/meltano/cli/__init__.py
@@ -4,9 +4,11 @@
 import logging
 import os
 import sys
+from typing import TYPE_CHECKING
 
 from meltano.core.logging import setup_logging
 from meltano.core.project import ProjectReadonly
+from meltano.core.tracking.contexts.exception import ExceptionContext  # noqa: F401
 
 from .utils import CliError
 
@@ -39,10 +41,18 @@
     job,
 )
 
+if TYPE_CHECKING:
+    from meltano.core.tracking.tracker import Tracker
+
+
 # Holds the exit code for error reporting during process exiting. In particular, a function
 # registered by the `atexit` module uses this value.
 exit_code: None | int = None
 
+atexit_handler_registered = False
+exit_code_reported = False
+exit_event_tracker: Tracker = None
+
 setup_logging()
 
 logger = logging.getLogger(__name__)
@@ -76,8 +86,12 @@ def main():
         global exit_code
         ex = sys.exc_info()[1]
         if ex is None:
-            exit_code = 0
+            exit_code = 0  # noqa: WPS442
         elif isinstance(ex, SystemExit):
-            exit_code = 0 if ex.code is None else ex.code
+            exit_code = 0 if ex.code is None else ex.code  # noqa: WPS442
         else:
-            exit_code = 1
+            exit_code = 1  # noqa: WPS442
+        # Track the exit event now to provide more details via the exception context.
+        # We assume the process will exit practically immediately after `main` returns.
+        if exit_event_tracker is not None:
+            exit_event_tracker.track_exit_event()
diff --git a/src/meltano/core/tracking/tracker.py b/src/meltano/core/tracking/tracker.py
--- a/src/meltano/core/tracking/tracker.py
+++ b/src/meltano/core/tracking/tracker.py
@@ -118,7 +118,7 @@ def __init__(
             self.snowplow_tracker = SnowplowTracker(emitters=emitters)
             self.snowplow_tracker.subject.set_lang(locale.getdefaultlocale()[0])
             self.snowplow_tracker.subject.set_timezone(self.timezone_name)
-            atexit.register(self.track_exit_event)
+            self.setup_exit_event()
         else:
             self.snowplow_tracker = None
 
@@ -441,9 +441,33 @@ def track_block_event(self, block_type: str, event: BlockEvents) -> None:
             )
         )
 
+    def setup_exit_event(self):
+        """If not already done, register the atexit handler to fire the exit event.
+
+        This method also provides this tracker instance to the CLI module for
+        it to fire an exit event immediately before the CLI exits. The atexit
+        handler is used only as a fallback.
+        """
+        from meltano import cli
+
+        if cli.atexit_handler_registered:
+            return
+
+        cli.atexit_handler_registered = True
+
+        # Provide `meltano.cli` with this tracker to track the exit event with more context.
+        cli.exit_event_tracker = self
+
+        # As a fallback, use atexit to help ensure the exit event is sent.
+        atexit.register(self.track_exit_event)
+
     def track_exit_event(self):
         """Fire exit event."""
-        from meltano.cli import exit_code
+        from meltano import cli
+
+        if cli.exit_code_reported:
+            return
+        cli.exit_code_reported = True
 
         start_time = datetime.utcfromtimestamp(Process().create_time())
 
@@ -455,7 +479,7 @@ def track_exit_event(self):
             SelfDescribingJson(
                 ExitEventSchema.url,
                 {
-                    "exit_code": exit_code,
+                    "exit_code": cli.exit_code,
                     "exit_timestamp": f"{now.isoformat()}Z",
                     "process_duration_microseconds": int(
                         (now - start_time).total_seconds() * MICROSECONDS_PER_SECOND
@@ -463,3 +487,4 @@ def track_exit_event(self):
                 },
             )
         )
+        atexit.unregister(self.track_exit_event)


Test patch: