Repository: meltano/meltano

Issue:
Add telemetry for `meltano config` command
Update `meltano.cli.config` to support new style snowplow telemetry. 

Spun out from https://github.com/orgs/meltano/discussions/392


Discussion:

Resolving patch:
diff --git a/src/meltano/cli/config.py b/src/meltano/cli/config.py
--- a/src/meltano/cli/config.py
+++ b/src/meltano/cli/config.py
@@ -1,4 +1,5 @@
 """Config management CLI."""
+from __future__ import annotations
 
 import json
 import tempfile
@@ -18,6 +19,7 @@
 from meltano.core.project_settings_service import ProjectSettingsService
 from meltano.core.settings_service import SettingValueStore
 from meltano.core.settings_store import StoreNotSupportedError
+from meltano.core.tracking import CliContext, CliEvent, PluginsTrackingContext, Tracker
 from meltano.core.utils import run_async
 
 from . import cli
@@ -54,7 +56,23 @@ def config(  # noqa: WPS231
 
     \b\nRead more at https://docs.meltano.com/reference/command-line-interface#config
     """
-    plugin_type = PluginType.from_cli_argument(plugin_type) if plugin_type else None
+    tracker = Tracker(project)
+    tracker.add_contexts(
+        CliContext.from_command_and_kwargs(
+            "config",
+            ctx.invoked_subcommand or None,
+            plugin_type=plugin_type,
+            format=format,
+            extras=extras,
+        )
+    )
+
+    try:
+        plugin_type = PluginType.from_cli_argument(plugin_type) if plugin_type else None
+    except ValueError:
+        tracker.track_command_event(CliEvent.started)
+        tracker.track_command_event(CliEvent.aborted)
+        raise
 
     plugins_service = ProjectPluginsService(project)
 
@@ -66,8 +84,14 @@ def config(  # noqa: WPS231
         if plugin_name == "meltano":
             plugin = None
         else:
+            tracker.track_command_event(CliEvent.started)
+            tracker.track_command_event(CliEvent.aborted)
             raise
 
+    if plugin:
+        tracker.add_contexts(PluginsTrackingContext([(plugin, None)]))
+    tracker.track_command_event(CliEvent.started)
+
     _, Session = project_engine(project)  # noqa: N806
     session = Session()
     try:
@@ -107,8 +131,12 @@ def config(  # noqa: WPS231
                     dotenv_content = Path(temp_dotenv.name).read_text()
 
                 click.echo(dotenv_content)
+    except Exception:
+        tracker.track_command_event(CliEvent.failed)
+        raise
     finally:
         session.close()
+    ctx.obj["tracker"] = tracker
 
 
 @config.command(
@@ -119,10 +147,11 @@ def config(  # noqa: WPS231
 )
 @click.option("--extras", is_flag=True)
 @click.pass_context
-def list_settings(ctx, extras):
+def list_settings(ctx, extras: bool):
     """List all settings for the specified plugin with their names, environment variables, and current values."""
     settings = ctx.obj["settings"]
     session = ctx.obj["session"]
+    tracker = ctx.obj["tracker"]
 
     printed_custom_heading = False
     printed_extra_heading = extras
@@ -196,6 +225,7 @@ def list_settings(ctx, extras):
         click.echo(
             f"To learn more about {settings.label} and its settings, visit {docs_url}"
         )
+    tracker.track_command_event(CliEvent.completed)
 
 
 @config.command()
@@ -211,10 +241,12 @@ def reset(ctx, store):
 
     settings = ctx.obj["settings"]
     session = ctx.obj["session"]
+    tracker = ctx.obj["tracker"]
 
     try:
         metadata = settings.reset(store=store, session=session)
     except StoreNotSupportedError as err:
+        tracker.track_command_event(CliEvent.aborted)
         raise CliError(
             f"{settings.label.capitalize()} settings in {store.label} could not be reset: {err}"
         ) from err
@@ -224,6 +256,7 @@ def reset(ctx, store):
         f"{settings.label.capitalize()} settings in {store.label} were reset",
         fg="green",
     )
+    tracker.track_command_event(CliEvent.completed)
 
 
 @config.command("set")
@@ -238,7 +271,7 @@ def reset(ctx, store):
 def set_(ctx, setting_name, value, store):
     """Set the configurations' setting `<name>` to `<value>`."""
     store = SettingValueStore(store)
-
+    tracker = ctx.obj["tracker"]
     try:
         value = json.loads(value)
     except json.JSONDecodeError:
@@ -253,6 +286,7 @@ def set_(ctx, setting_name, value, store):
             path, value, store=store, session=session
         )
     except StoreNotSupportedError as err:
+        tracker.track_command_event(CliEvent.aborted)
         raise CliError(
             f"{settings.label.capitalize()} setting '{path}' could not be set in {store.label}: {err}"
         ) from err
@@ -270,6 +304,7 @@ def set_(ctx, setting_name, value, store):
             f"Current value is still: {current_value!r} (from {source.label})",
             fg="yellow",
         )
+    tracker.track_command_event(CliEvent.completed)
 
 
 @config.command("test")
@@ -277,7 +312,9 @@ def set_(ctx, setting_name, value, store):
 def test(ctx):
     """Test the configuration of a plugin."""
     invoker = ctx.obj["invoker"]
+    tracker = ctx.obj["tracker"]
     if not invoker:
+        tracker.track_command_event(CliEvent.aborted)
         raise CliError("Testing of the Meltano project configuration is not supported")
 
     session = ctx.obj["session"]
@@ -287,12 +324,18 @@ async def _validate():  # noqa: WPS430
             plugin_test_service = PluginTestServiceFactory(invoker).get_test_service()
             return await plugin_test_service.validate()
 
-    is_valid, detail = run_async(_validate())
+    try:
+        is_valid, detail = run_async(_validate())
+    except Exception:
+        tracker.track_command_event(CliEvent.failed)
+        raise
 
     if not is_valid:
+        tracker.track_command_event(CliEvent.failed)
         raise CliError("\n".join(("Plugin configuration is invalid", detail)))
 
     click.secho("Plugin configuration is valid", fg="green")
+    tracker.track_command_event(CliEvent.completed)
 
 
 @config.command()
@@ -309,11 +352,13 @@ def unset(ctx, setting_name, store):
 
     settings = ctx.obj["settings"]
     session = ctx.obj["session"]
+    tracker = ctx.obj["tracker"]
 
     path = list(setting_name)
     try:
         metadata = settings.unset(path, store=store, session=session)
     except StoreNotSupportedError as err:
+        tracker.track_command_event(CliEvent.aborted)
         raise CliError(
             f"{settings.label.capitalize()} setting '{path}' in {store.label} could not be unset: {err}"
         ) from err
@@ -331,3 +376,4 @@ def unset(ctx, setting_name, store):
             f"Current value is now: {current_value!r} (from {source.label})",
             fg="yellow",
         )
+    tracker.track_command_event(CliEvent.completed)
diff --git a/src/meltano/core/tracking/contexts/plugins.py b/src/meltano/core/tracking/contexts/plugins.py
--- a/src/meltano/core/tracking/contexts/plugins.py
+++ b/src/meltano/core/tracking/contexts/plugins.py
@@ -17,11 +17,13 @@
 
 
 def _from_plugin(plugin: ProjectPlugin, cmd: str) -> dict:
-    if not safe_hasattr(plugin, "info"):
+    if not plugin or not safe_hasattr(plugin, "info"):
+        # don't try to snag any info for this plugin, we're somehow badly malformed (unittest?), or where passed None.
+        # this event will be routed to the "bad" bucket on the snowplow side. That makes it detectable on our end,
+        # unlike if we had just filtered it out completely.
         logger.debug(
             "Plugin tracker context some how encountered plugin without info attr."
         )
-        # don't try to snag any info for this plugin, we're somehow badly malformed (unittest?)
         return {}
 
     return {


Test patch:
diff --git a/tests/meltano/core/tracking/test_plugins.py b/tests/meltano/core/tracking/test_plugins.py
--- a/tests/meltano/core/tracking/test_plugins.py
+++ b/tests/meltano/core/tracking/test_plugins.py
@@ -50,3 +50,7 @@ def test_plugins_tracking_context(self, tap: ProjectPlugin, dbt: ProjectPlugin):
                 assert plugin.get("pip_url_hash") == hash_sha256(dbt.formatted_pip_url)
                 assert plugin.get("parent_name_hash") == hash_sha256(dbt.parent.name)
                 assert plugin.get("command") == "test"
+
+        # verify that passing a None object results in an empty plugin context.
+        plugin_ctx = PluginsTrackingContext([(None, None)])
+        assert plugin_ctx.data.get("plugins") == [{}]