Repository: meltano/meltano

Issue:
feature: Add Meltano command information to Hub API request
### Feature scope

CLI (options, error messages, logging, etc.)

### Description

When Meltano sends requests to the Hub API, currently sets the `X-Project-ID` and `User-Agent` headers: https://github.com/meltano/meltano/blob/193a3ae4c7adfa29e102a65d31b7e865d9eea349/src/meltano/core/hub/client.py#L113-L125

However, it'd be good to include the CLI command where the request is coming from.


Discussion:
I'd like to see this done relatively soon as this was part of the original scope in https://github.com/meltano/internal-general/issues/444 
@tayloramurphy I overlooked the file yesterday, the Meltano version should already be available in the user-agent, e.g. `User-Agent: Meltano/2.13.0`.

I've updated the issue title and description.


@edgarrmondragon I was about to cross post https://github.com/meltano/internal-data/issues/68#issuecomment-1387343286, I noticed thee same thing. Version does come through. As long as we can confirm that its a reliable way to get it.
> @edgarrmondragon I was about to cross post [meltano/internal-data#68 (comment)](https://github.com/meltano/internal-data/issues/68#issuecomment-1387343286), I noticed thee same thing. Version does come through. As long as we can confirm that its a reliable way to get it.

@pnadolny13 yeah, it should be reliable since it's coming from the Python package metadata for Meltano.

Resolving patch:
diff --git a/src/meltano/core/hub/client.py b/src/meltano/core/hub/client.py
--- a/src/meltano/core/hub/client.py
+++ b/src/meltano/core/hub/client.py
@@ -5,6 +5,7 @@
 from http import HTTPStatus
 from typing import Any
 
+import click
 import requests
 from requests.adapters import HTTPAdapter
 from structlog.stdlib import get_logger
@@ -198,6 +199,41 @@ def plugin_endpoint(
 
         return url
 
+    def _build_request(self, method: str, url: str) -> requests.PreparedRequest:
+        """Build a request to the Hub API.
+
+        Args:
+            method: The HTTP method.
+            url: The URL to request.
+
+        Returns:
+            The prepared request.
+        """
+        request = requests.Request(method, url)
+        click_context = click.get_current_context(silent=True)
+
+        if click_context:
+            request.headers["X-Meltano-Command"] = click_context.command_path
+
+        return self.session.prepare_request(request)
+
+    def _get(self, url: str) -> requests.Response:
+        """Make a GET request to the Hub API.
+
+        Args:
+            url: The URL to request.
+
+        Returns:
+            The response.
+
+        Raises:
+            HubConnectionError: If the Hub API could not be reached.
+        """
+        try:
+            return self.session.send(self._build_request("GET", url))
+        except requests.exceptions.ConnectionError as connection_err:
+            raise HubConnectionError("Could not reach Meltano Hub.") from connection_err
+
     def find_definition(
         self,
         plugin_type: PluginType,
@@ -241,7 +277,7 @@ def find_definition(
                 plugin_type, plugin, variant_name
             ) from variant_key_err
 
-        response = self.session.get(url)
+        response = self._get(url)
 
         try:
             response.raise_for_status()
@@ -299,7 +335,7 @@ def get_plugins_of_type(  # noqa: WPS210
             return {}
 
         url = self.plugin_type_endpoint(plugin_type)
-        response = self.session.get(url)
+        response = self._get(url)
 
         try:
             response.raise_for_status()


Test patch:
diff --git a/tests/meltano/cli/test_add.py b/tests/meltano/cli/test_add.py
--- a/tests/meltano/cli/test_add.py
+++ b/tests/meltano/cli/test_add.py
@@ -66,6 +66,7 @@ def test_add(
         project,
         cli_runner,
         project_plugins_service,
+        meltano_hub_service,
     ):
         # ensure the plugin is not present
         with pytest.raises(PluginNotFoundError):
diff --git a/tests/meltano/core/hub/test_meltano_hub_service.py b/tests/meltano/core/hub/test_meltano_hub_service.py
--- a/tests/meltano/core/hub/test_meltano_hub_service.py
+++ b/tests/meltano/core/hub/test_meltano_hub_service.py
@@ -2,9 +2,13 @@
 
 from collections import Counter
 
+import click
+import mock
 import pytest
 from requests import HTTPError, Response
 
+from meltano.cli import cli
+from meltano.cli.discovery import discover
 from meltano.core.hub import MeltanoHubService
 from meltano.core.hub.client import HubConnectionError, HubPluginVariantNotFoundError
 from meltano.core.plugin.base import PluginType, Variant
@@ -117,3 +121,18 @@ def test_server_error(self, subject: MeltanoHubService):
             "https://hub.meltano.com/meltano/api/v1/plugins/extractors"
             "/this-returns-500--original"
         )
+
+    def test_request_headers(self, subject: MeltanoHubService):
+        with mock.patch("click.get_current_context") as get_context:
+            get_context.return_value = click.Context(
+                discover,
+                info_name="discover",
+                parent=click.Context(cli, info_name="meltano"),
+            )
+            request = subject._build_request("GET", "https://example.com")
+            assert request.headers["X-Meltano-Command"] == "meltano discover"
+
+        with mock.patch("click.get_current_context") as get_context:
+            get_context.return_value = None
+            request = subject._build_request("GET", "https://example.com")
+            assert "X-Meltano-Command" not in request.headers