diff --git a/packages/deepctl-cmd-completion/tests/unit/test_completion_command.py b/packages/deepctl-cmd-completion/tests/unit/test_completion_command.py index 756f6e0..f5517f1 100644 --- a/packages/deepctl-cmd-completion/tests/unit/test_completion_command.py +++ b/packages/deepctl-cmd-completion/tests/unit/test_completion_command.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from deepctl_cmd_completion.command import CompletionCommand diff --git a/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py b/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py index 9e66d98..8599e4d 100644 --- a/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py +++ b/packages/deepctl-cmd-debug-audio/tests/unit/test_audio_command.py @@ -1,17 +1,17 @@ """Tests for audio debug command.""" -import pytest -from unittest.mock import Mock, patch, MagicMock import json +from unittest.mock import MagicMock, Mock, patch +import pytest from deepctl_cmd_debug_audio.command import AudioCommand from deepctl_cmd_debug_audio.models import ( AudioDebugResult, - AudioInfo, AudioFormat, + AudioInfo, AudioStream, ) -from deepctl_core import Config, AuthManager, DeepgramClient +from deepctl_core import AuthManager, Config, DeepgramClient class TestAudioCommand: diff --git a/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py b/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py index f210696..e02969a 100644 --- a/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py +++ b/packages/deepctl-cmd-debug-browser/tests/unit/test_browser_command.py @@ -1,7 +1,8 @@ """Unit tests for browser debug command.""" +from unittest.mock import AsyncMock, MagicMock, Mock, patch + import pytest -from unittest.mock import Mock, patch, AsyncMock, MagicMock from aiohttp import WSMsgType from deepctl_cmd_debug_browser import BrowserCommand, BrowserDebugResult diff --git a/packages/deepctl-cmd-debug-network/tests/unit/test_models.py b/packages/deepctl-cmd-debug-network/tests/unit/test_models.py index 693e397..a54d3cc 100644 --- a/packages/deepctl-cmd-debug-network/tests/unit/test_models.py +++ b/packages/deepctl-cmd-debug-network/tests/unit/test_models.py @@ -1,17 +1,18 @@ """Tests for network debug command models.""" -import pytest from datetime import datetime + +import pytest from deepctl_cmd_debug_network.models import ( - EndpointTestResult, - DNSResult, CertificateInfo, - RevocationEndpointTest, - TLSTestResult, - PythonRequestsTest, CommandExecutionResult, - NetworkDebugResult, DeepgramEndpoint, + DNSResult, + EndpointTestResult, + NetworkDebugResult, + PythonRequestsTest, + RevocationEndpointTest, + TLSTestResult, ) diff --git a/packages/deepctl-cmd-debug-network/tests/unit/test_network_command.py b/packages/deepctl-cmd-debug-network/tests/unit/test_network_command.py index 99526c8..be4dae1 100644 --- a/packages/deepctl-cmd-debug-network/tests/unit/test_network_command.py +++ b/packages/deepctl-cmd-debug-network/tests/unit/test_network_command.py @@ -1,25 +1,25 @@ """Tests for the network debug command.""" -import pytest -from unittest.mock import Mock, MagicMock, patch, call -import subprocess -import socket -import requests import json +import socket +import subprocess from datetime import datetime +from unittest.mock import MagicMock, Mock, call, patch +import pytest +import requests from deepctl_cmd_debug_network import NetworkCommand from deepctl_cmd_debug_network.models import ( - NetworkDebugResult, - DNSResult, - TLSTestResult, CertificateInfo, - RevocationEndpointTest, - PythonRequestsTest, CommandExecutionResult, + DNSResult, EndpointTestResult, + NetworkDebugResult, + PythonRequestsTest, + RevocationEndpointTest, + TLSTestResult, ) -from deepctl_core import Config, AuthManager, DeepgramClient +from deepctl_core import AuthManager, Config, DeepgramClient @pytest.fixture diff --git a/packages/deepctl-cmd-listen/tests/unit/test_captions.py b/packages/deepctl-cmd-listen/tests/unit/test_captions.py index b467858..e1977c3 100644 --- a/packages/deepctl-cmd-listen/tests/unit/test_captions.py +++ b/packages/deepctl-cmd-listen/tests/unit/test_captions.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from deepctl_cmd_listen.captions import ( StreamingCaptionWriter, _fmt_srt, diff --git a/packages/deepctl-cmd-login/tests/unit/test_login_command.py b/packages/deepctl-cmd-login/tests/unit/test_login_command.py index 37d3a4f..e0a1c4f 100644 --- a/packages/deepctl-cmd-login/tests/unit/test_login_command.py +++ b/packages/deepctl-cmd-login/tests/unit/test_login_command.py @@ -1,8 +1,8 @@ """Tests for the login command.""" -import pytest -from unittest.mock import Mock, patch, MagicMock, call +from unittest.mock import MagicMock, Mock, call, patch +import pytest from deepctl_cmd_login.command import ( LoginCommand, LogoutCommand, diff --git a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py index 6192c9b..486040e 100644 --- a/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py +++ b/packages/deepctl-cmd-plugin/tests/unit/test_plugin_command.py @@ -79,12 +79,11 @@ def test_get_plugin_state_existing(self) -> None: """Test getting plugin state from existing file.""" test_state = {"plugins": {"test-plugin": {"version": "1.0.0"}}} - with patch.object(Path, "exists", return_value=True): - with patch.object( - Path, "read_text", return_value=json.dumps(test_state) - ): - state = self.command._get_plugin_state() - assert state == test_state + with patch.object(Path, "exists", return_value=True), patch.object( + Path, "read_text", return_value=json.dumps(test_state) + ): + state = self.command._get_plugin_state() + assert state == test_state def test_save_plugin_state(self) -> None: """Test saving plugin state.""" @@ -140,34 +139,32 @@ def test_install_plugin_system_environment( # Mock state and version operations with patch.object( self.command, "_get_plugin_state" - ) as mock_get_state: - with patch.object( - self.command, "_save_plugin_state" - ) as mock_save_state: - with patch.object( - self.command, - "_get_package_version", - return_value="1.0.0", - ): - mock_get_state.return_value = {"plugins": {}} - - options = PluginInstallOptions( - package="test-plugin" - ) - result = self.command.install_plugin( - self.config, - self.auth_manager, - self.client, - options, - ) - - assert result.success is True - assert "Successfully installed" in result.message - # Strategy should use the plugin env python - cmd = mock_strategy_run.call_args[0][0] - assert cmd[0] == "/path/to/plugin/python" - # Should save plugin state - mock_save_state.assert_called_once() + ) as mock_get_state, patch.object( + self.command, "_save_plugin_state" + ) as mock_save_state, patch.object( + self.command, + "_get_package_version", + return_value="1.0.0", + ): + mock_get_state.return_value = {"plugins": {}} + + options = PluginInstallOptions( + package="test-plugin" + ) + result = self.command.install_plugin( + self.config, + self.auth_manager, + self.client, + options, + ) + + assert result.success is True + assert "Successfully installed" in result.message + # Strategy should use the plugin env python + cmd = mock_strategy_run.call_args[0][0] + assert cmd[0] == "/path/to/plugin/python" + # Should save plugin state + mock_save_state.assert_called_once() def test_install_plugin_git_url(self) -> None: """Test handling git URL in install options.""" @@ -317,32 +314,29 @@ def test_list_plugins_verbose(self) -> None: with patch.object( self.command, "_discover_plugins", return_value=test_plugins - ): - with patch( - "deepctl_cmd_plugin.command.console.print" - ) as mock_print: - with patch( - "deepctl_cmd_plugin.command.print_info" - ) as mock_print_info: - with patch.object( - self.command.detector, "detect" - ) as mock_detect: - mock_detect.return_value.method = InstallMethod.SYSTEM - - self.command.list_plugins( - self.config, - self.auth_manager, - self.client, - verbose=True, - ) - - # Should print a table - mock_print.assert_called_once() - # Should show system installation info via print_info - assert any( - "system" in str(call).lower() - for call in mock_print_info.call_args_list - ) + ), patch( + "deepctl_cmd_plugin.command.console.print" + ) as mock_print, patch( + "deepctl_cmd_plugin.command.print_info" + ) as mock_print_info, patch.object( + self.command.detector, "detect" + ) as mock_detect: + mock_detect.return_value.method = InstallMethod.SYSTEM + + self.command.list_plugins( + self.config, + self.auth_manager, + self.client, + verbose=True, + ) + + # Should print a table + mock_print.assert_called_once() + # Should show system installation info via print_info + assert any( + "system" in str(call).lower() + for call in mock_print_info.call_args_list + ) def test_setup_commands(self) -> None: """Test that all subcommands are properly set up.""" @@ -396,21 +390,20 @@ def test_handle_search(self) -> None: # Test search all with patch( "deepctl_cmd_plugin.command.console.print" - ) as mock_print: - with patch( - "deepctl_cmd_plugin.command.print_info" - ) as mock_info: - self.command._handle_search( - self.config, self.auth_manager, self.client - ) - - # Should print a table - mock_print.assert_called_once() - # Should show install hint - assert any( - "install" in str(call) - for call in mock_info.call_args_list - ) + ) as mock_print, patch( + "deepctl_cmd_plugin.command.print_info" + ) as mock_info: + self.command._handle_search( + self.config, self.auth_manager, self.client + ) + + # Should print a table + mock_print.assert_called_once() + # Should show install hint + assert any( + "install" in str(call) + for call in mock_info.call_args_list + ) # Test search with query with patch( diff --git a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py index 2f7d968..1216f5c 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_plugin_update_check.py @@ -19,7 +19,6 @@ print_pending_plugin_notifications, ) - # --------------------------------------------------------------------------- # _is_excluded # --------------------------------------------------------------------------- diff --git a/packages/deepctl-cmd-update/tests/unit/test_version_check.py b/packages/deepctl-cmd-update/tests/unit/test_version_check.py index 5fc42fe..f96f4fc 100644 --- a/packages/deepctl-cmd-update/tests/unit/test_version_check.py +++ b/packages/deepctl-cmd-update/tests/unit/test_version_check.py @@ -7,9 +7,9 @@ import httpx import pytest from deepctl_cmd_update.version_check import ( + _FREQUENCY_DURATIONS, VersionChecker, VersionInfo, - _FREQUENCY_DURATIONS, format_version_message, ) diff --git a/packages/deepctl-core/src/deepctl_core/base_command.py b/packages/deepctl-core/src/deepctl_core/base_command.py index ad0334c..e94fc84 100644 --- a/packages/deepctl-core/src/deepctl_core/base_command.py +++ b/packages/deepctl-core/src/deepctl_core/base_command.py @@ -104,26 +104,81 @@ def execute(self, ctx: click.Context, **kwargs: Any) -> None: ) raise click.ClickException("Project ID required") + self._tag_telemetry_start(ctx) + # Execute the command try: with TimingContext(f"command_{self.name}_handler"): result = self.handle(config, auth_manager, client, **kwargs) + status = "ok" + if result is not None and hasattr(result, "status"): + status = str(result.status) + self._tag_telemetry_status(status) + # Handle command result if result is not None: with TimingContext("output_processing"): self.output_result(result, config) except KeyboardInterrupt: + self._tag_telemetry_status("cancelled") stderr_console.print("\n[yellow]Command cancelled by user[/yellow]") raise click.Abort() except Exception as e: + self._tag_telemetry_status("error") print_error(f"Command failed: {e}") if config.get("output.verbose", False): stderr_console.print_exception() raise click.ClickException(str(e)) + def _tag_telemetry_start(self, ctx: click.Context) -> None: + """Annotate the active Sentry transaction with command-level usage signal. + + Sets the transaction name to the full Click command path + (e.g. 'deepctl debug audio' instead of just 'debug') and tags it with + the user-provided flag NAMES (never values), the requested output + format, and the auth method. All values are bounded enums or + already-public flag identifiers — no PII risk. + + Wrapped in a bare except so a Sentry hiccup, missing scope, or unknown + Click parameter-source enum value can never crash the user's command. + """ + try: + import sentry_sdk + + scope = sentry_sdk.get_current_scope() + transaction = getattr(scope, "transaction", None) + if transaction is not None and ctx.command_path: + transaction.name = ctx.command_path + + used: list[str] = [] + for param in ctx.command.params: + if not param.name: + continue + src = ctx.get_parameter_source(param.name) + if src is not None and src.name == "COMMANDLINE": + used.append(param.name) + scope.set_tag("cmd.flags", ",".join(sorted(used)) or "(none)") + scope.set_tag("cmd.output_format", ctx.params.get("output") or "default") + except Exception: + pass + + def _tag_telemetry_status(self, status: str) -> None: + """Tag the active Sentry transaction with the command outcome. + + Status is a bounded enum: 'ok', 'cancelled', 'error', or whatever the + command's BaseResult.status returned (also bounded by the result + model). Bare except so telemetry can't crash the command teardown. + """ + try: + import sentry_sdk + + sentry_sdk.get_current_scope().set_tag("cmd.status", status) + except Exception: + pass + @abstractmethod def handle( self, diff --git a/packages/deepctl-core/tests/unit/test_auth.py b/packages/deepctl-core/tests/unit/test_auth.py index 649393f..faf4a9f 100644 --- a/packages/deepctl-core/tests/unit/test_auth.py +++ b/packages/deepctl-core/tests/unit/test_auth.py @@ -5,8 +5,7 @@ import httpx import pytest - -from deepctl_core.auth import AuthManager, AuthenticationError +from deepctl_core.auth import AuthenticationError, AuthManager from deepctl_core.config import Config @@ -186,29 +185,28 @@ def test_verify_credentials_uses_stored_credentials( # Mock the get_api_key and get_project_id methods to return stored values with patch.object( auth_manager, "get_api_key", return_value="sk-stored-key" + ), patch.object( + auth_manager, "get_project_id", return_value="stored-project" ): - with patch.object( - auth_manager, "get_project_id", return_value="stored-project" - ): - # Mock successful response - mock_response = Mock() - mock_response.status_code = 200 - auth_manager.client.get.return_value = mock_response + # Mock successful response + mock_response = Mock() + mock_response.status_code = 200 + auth_manager.client.get.return_value = mock_response - success, message, error_type = ( - auth_manager.verify_credentials() - ) + success, message, error_type = ( + auth_manager.verify_credentials() + ) - assert success is True + assert success is True - # Verify it used stored credentials - auth_manager.client.get.assert_called_once_with( - "https://api.deepgram.com/v1/projects/stored-project", - headers={ - "Authorization": "Token sk-stored-key", - "Content-Type": "application/json", - }, - ) + # Verify it used stored credentials + auth_manager.client.get.assert_called_once_with( + "https://api.deepgram.com/v1/projects/stored-project", + headers={ + "Authorization": "Token sk-stored-key", + "Content-Type": "application/json", + }, + ) @patch.dict( "os.environ", @@ -287,17 +285,15 @@ def test_guard_does_not_require_project_id(self, auth_manager): """Test that guard() only checks API key, not project_id.""" with patch.object( auth_manager, "get_api_key", return_value="sk-test-key" + ), patch.object( + auth_manager, + "verify_api_key", + return_value=(True, "API key verified", None), + ), patch.object( + auth_manager, "get_project_id", return_value=None ): - with patch.object( - auth_manager, - "verify_api_key", - return_value=(True, "API key verified", None), - ): - with patch.object( - auth_manager, "get_project_id", return_value=None - ): - # Should NOT raise even without project_id - auth_manager.guard() + # Should NOT raise even without project_id + auth_manager.guard() def test_login_with_api_key_success(self, auth_manager, mock_config): """Test successful login with API key.""" @@ -326,13 +322,12 @@ def test_login_with_api_key_verification_fails(self, auth_manager): auth_manager, "verify_credentials", return_value=(False, "Invalid API key", "auth"), + ), pytest.raises( + AuthenticationError, match="Invalid API key" ): - with pytest.raises( - AuthenticationError, match="Invalid API key" - ): - auth_manager.login_with_api_key( - "sk-invalid-key", "test-project" - ) + auth_manager.login_with_api_key( + "sk-invalid-key", "test-project" + ) @patch.dict( "os.environ", diff --git a/packages/deepctl-core/tests/unit/test_base.py b/packages/deepctl-core/tests/unit/test_base.py index 13fb215..a20a19d 100644 --- a/packages/deepctl-core/tests/unit/test_base.py +++ b/packages/deepctl-core/tests/unit/test_base.py @@ -1,13 +1,12 @@ """Unit tests for BaseCommand class.""" from typing import Any, Dict, List -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock, Mock, patch import click import pytest from click.testing import CliRunner - -from deepctl_core import BaseCommand, AuthManager, DeepgramClient, Config +from deepctl_core import AuthManager, BaseCommand, Config, DeepgramClient class TestBaseCommand: @@ -112,9 +111,7 @@ def test_execute_successful( command.execute(mock_context, test_arg="value") # Verify AuthManager and DeepgramClient were created - mock_auth_class.assert_called_once_with( - mock_context.obj["config"], None, None - ) + mock_auth_class.assert_called_once_with(mock_context.obj["config"], None, None) mock_client_class.assert_called_once_with( mock_context.obj["config"], mock_auth_instance ) @@ -163,9 +160,7 @@ def test_execute_without_config_in_context( mock_config_class.assert_called_once() # Verify AuthManager and DeepgramClient were created with new config - mock_auth_class.assert_called_once_with( - mock_config_instance, None, None - ) + mock_auth_class.assert_called_once_with(mock_config_instance, None, None) mock_client_class.assert_called_once_with( mock_config_instance, mock_auth_instance ) @@ -220,9 +215,7 @@ def test_execute_with_auth_required_failure( """Test command execution with authentication required and failed auth.""" # Setup mocks mock_auth_instance = Mock() - mock_auth_instance.guard.side_effect = Exception( - "Authentication failed" - ) + mock_auth_instance.guard.side_effect = Exception("Authentication failed") mock_auth_class.return_value = mock_auth_instance # Create command that requires auth @@ -411,9 +404,7 @@ def handle( command.execute(mock_context) # Verify error was printed to stderr via print_error - mock_print_error.assert_called_once_with( - "Command failed: Something went wrong" - ) + mock_print_error.assert_called_once_with("Command failed: Something went wrong") @pytest.mark.unit @patch("deepctl_core.base_command.AuthManager") @@ -461,9 +452,7 @@ def handle( command.execute(ctx) # Verify error and traceback went to stderr - mock_print_error.assert_called_once_with( - "Command failed: Something went wrong" - ) + mock_print_error.assert_called_once_with("Command failed: Something went wrong") mock_stderr_console.print_exception.assert_called_once() @pytest.mark.unit @@ -491,9 +480,7 @@ def test_output_result_json_dict(self, mock_console, mock_command_class): # Verify print_json was called with JSON string import json - mock_console.print_json.assert_called_once_with( - json.dumps(result, indent=2) - ) + mock_console.print_json.assert_called_once_with(json.dumps(result, indent=2)) @pytest.mark.unit @patch("deepctl_core.base_command.console") @@ -508,9 +495,7 @@ def test_output_result_json_list(self, mock_console, mock_command_class): import json - mock_console.print_json.assert_called_once_with( - json.dumps(result, indent=2) - ) + mock_console.print_json.assert_called_once_with(json.dumps(result, indent=2)) @pytest.mark.unit @patch("deepctl_core.base_command.console") @@ -550,9 +535,7 @@ class TestModel(BaseModel): command.output_result(result, config) # Verify model was converted to dict - mock_output_json.assert_called_once_with( - {"name": "test", "value": 123} - ) + mock_output_json.assert_called_once_with({"name": "test", "value": 123}) @pytest.mark.unit def test_output_result_list_of_pydantic_models(self, mock_command_class): @@ -582,7 +565,10 @@ class TestModel(BaseModel): @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "yaml", "quiet": False, "verbose": False, "color": True}) + @patch( + "deepctl_core.output._output_config", + {"format": "yaml", "quiet": False, "verbose": False, "color": True}, + ) def test_output_result_yaml(self, mock_console, mock_command_class): """Test output_result with YAML format.""" command = mock_command_class() @@ -599,10 +585,11 @@ def test_output_result_yaml(self, mock_console, mock_command_class): @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "table", "quiet": False, "verbose": False, "color": True}) - def test_output_result_table_list_of_dicts( - self, mock_console, mock_command_class - ): + @patch( + "deepctl_core.output._output_config", + {"format": "table", "quiet": False, "verbose": False, "color": True}, + ) + def test_output_result_table_list_of_dicts(self, mock_console, mock_command_class): """Test output_result with table format and list of dicts.""" command = mock_command_class() config = Mock(spec=Config) @@ -613,13 +600,14 @@ def test_output_result_table_list_of_dicts( # Verify table was created and printed mock_console.print.assert_called_once() # Check that Table was created - assert any( - "Table" in str(call) for call in mock_console.print.call_args_list - ) + assert any("Table" in str(call) for call in mock_console.print.call_args_list) @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "table", "quiet": False, "verbose": False, "color": True}) + @patch( + "deepctl_core.output._output_config", + {"format": "table", "quiet": False, "verbose": False, "color": True}, + ) def test_output_result_table_dict(self, mock_console, mock_command_class): """Test output_result with table format and dict.""" command = mock_command_class() @@ -633,10 +621,11 @@ def test_output_result_table_dict(self, mock_console, mock_command_class): @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "csv", "quiet": False, "verbose": False, "color": True}) - def test_output_result_csv_list_of_dicts( - self, mock_console, mock_command_class - ): + @patch( + "deepctl_core.output._output_config", + {"format": "csv", "quiet": False, "verbose": False, "color": True}, + ) + def test_output_result_csv_list_of_dicts(self, mock_console, mock_command_class): """Test output_result with CSV format and list of dicts.""" command = mock_command_class() config = Mock(spec=Config) @@ -653,7 +642,10 @@ def test_output_result_csv_list_of_dicts( @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "csv", "quiet": False, "verbose": False, "color": True}) + @patch( + "deepctl_core.output._output_config", + {"format": "csv", "quiet": False, "verbose": False, "color": True}, + ) def test_output_result_csv_dict(self, mock_console, mock_command_class): """Test output_result with CSV format and dict.""" command = mock_command_class() @@ -671,10 +663,11 @@ def test_output_result_csv_dict(self, mock_console, mock_command_class): @pytest.mark.unit @patch("deepctl_core.base_command.console") - @patch("deepctl_core.output._output_config", {"format": "unknown", "quiet": False, "verbose": False, "color": True}) - def test_output_result_unknown_format( - self, mock_console, mock_command_class - ): + @patch( + "deepctl_core.output._output_config", + {"format": "unknown", "quiet": False, "verbose": False, "color": True}, + ) + def test_output_result_unknown_format(self, mock_console, mock_command_class): """Test output_result with unknown format falls back to JSON.""" command = mock_command_class() config = Mock(spec=Config) @@ -820,9 +813,7 @@ def handle( @pytest.mark.unit @patch("deepctl_core.base_command._agentic", False) @patch("click.prompt") - def test_prompt_interactive_mode( - self, mock_click_prompt, mock_command_class - ): + def test_prompt_interactive_mode(self, mock_click_prompt, mock_command_class): """Test prompt in interactive mode.""" command = mock_command_class() mock_click_prompt.return_value = "user_input" @@ -836,9 +827,7 @@ def test_prompt_interactive_mode( @pytest.mark.unit @patch("click.prompt") - def test_prompt_with_hidden_input( - self, mock_click_prompt, mock_command_class - ): + def test_prompt_with_hidden_input(self, mock_click_prompt, mock_command_class): """Test prompt with hidden input (password mode).""" command = mock_command_class() mock_click_prompt.return_value = "secret_password" @@ -852,9 +841,7 @@ def test_prompt_with_hidden_input( @pytest.mark.unit @patch("click.prompt") - def test_prompt_abort_raises_exception( - self, mock_click_prompt, mock_command_class - ): + def test_prompt_abort_raises_exception(self, mock_click_prompt, mock_command_class): """Test prompt raises ClickException when user aborts.""" command = mock_command_class() mock_click_prompt.side_effect = click.Abort() @@ -957,7 +944,7 @@ def handle( ) -> Any: pass - def get_arguments(self) -> List[Dict[str, Any]]: + def get_arguments(self) -> list[dict[str, Any]]: return [ {"name": "--option1", "type": str, "help": "First option"}, { @@ -976,3 +963,101 @@ def get_arguments(self) -> List[Dict[str, Any]]: assert args[0]["type"] == str assert args[1]["name"] == "--option2" assert args[1]["default"] == 42 + + +class TestTelemetryTagging: + """Verify _tag_telemetry_start and _tag_telemetry_status emit usage signal.""" + + @pytest.fixture + def command(self): + class MockCommand(BaseCommand): + name = "test" + help = "Test command" + + def handle(self, *args, **kwargs): + return None + + return MockCommand() + + @staticmethod + def _ctx_with_flags( + used_flags, defaulted_flags, output="json", path="deepctl listen" + ): + ctx = Mock(spec=click.Context) + ctx.command_path = path + ctx.params = {"output": output} + ctx.command = Mock() + ctx.command.params = [ + *(_param(n) for n in used_flags), + *(_param(n) for n in defaulted_flags), + ] + used = set(used_flags) + + def get_source(name): + src = Mock() + src.name = "COMMANDLINE" if name in used else "DEFAULT" + return src + + ctx.get_parameter_source = get_source + return ctx + + @pytest.mark.unit + def test_start_renames_transaction_to_command_path(self, command): + ctx = self._ctx_with_flags(["diarize"], ["model"]) + scope = Mock() + scope.transaction = Mock() + with patch("sentry_sdk.get_current_scope", return_value=scope): + command._tag_telemetry_start(ctx) + assert scope.transaction.name == "deepctl listen" + + @pytest.mark.unit + def test_start_records_only_user_provided_flags(self, command): + ctx = self._ctx_with_flags(["diarize", "summarize"], ["model", "language"]) + scope = Mock() + scope.transaction = Mock() + with patch("sentry_sdk.get_current_scope", return_value=scope): + command._tag_telemetry_start(ctx) + scope.set_tag.assert_any_call("cmd.flags", "diarize,summarize") + scope.set_tag.assert_any_call("cmd.output_format", "json") + + @pytest.mark.unit + def test_start_no_flags_records_none_sentinel(self, command): + ctx = self._ctx_with_flags([], [], output=None, path="deepctl whoami") + scope = Mock() + scope.transaction = Mock() + with patch("sentry_sdk.get_current_scope", return_value=scope): + command._tag_telemetry_start(ctx) + scope.set_tag.assert_any_call("cmd.flags", "(none)") + scope.set_tag.assert_any_call("cmd.output_format", "default") + + @pytest.mark.unit + def test_start_swallows_exceptions(self, command): + ctx = self._ctx_with_flags(["diarize"], []) + with patch("sentry_sdk.get_current_scope", side_effect=RuntimeError("boom")): + command._tag_telemetry_start(ctx) + + @pytest.mark.unit + def test_status_sets_cmd_status(self, command): + scope = Mock() + with patch("sentry_sdk.get_current_scope", return_value=scope): + command._tag_telemetry_status("ok") + scope.set_tag.assert_called_with("cmd.status", "ok") + + @pytest.mark.unit + @pytest.mark.parametrize("status", ["ok", "error", "cancelled", "partial"]) + def test_status_passes_through_status_string(self, command, status): + scope = Mock() + with patch("sentry_sdk.get_current_scope", return_value=scope): + command._tag_telemetry_status(status) + scope.set_tag.assert_called_with("cmd.status", status) + + @pytest.mark.unit + def test_status_swallows_exceptions(self, command): + with patch("sentry_sdk.get_current_scope", side_effect=RuntimeError("boom")): + command._tag_telemetry_status("error") + + +def _param(name): + p = Mock() + p.name = name + return p diff --git a/packages/deepctl-core/tests/unit/test_base_group.py b/packages/deepctl-core/tests/unit/test_base_group.py index 5ff7dbc..9352972 100644 --- a/packages/deepctl-core/tests/unit/test_base_group.py +++ b/packages/deepctl-core/tests/unit/test_base_group.py @@ -1,18 +1,17 @@ """Unit tests for BaseGroupCommand class.""" from typing import Any, Dict, List -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock, Mock, patch import click import pytest from click.testing import CliRunner - from deepctl_core import ( + AuthManager, BaseCommand, BaseGroupCommand, - AuthManager, - DeepgramClient, Config, + DeepgramClient, ) @@ -214,7 +213,7 @@ class GroupWithArgs(BaseGroupCommand): name = "testgroup" help = "Test group" - def get_arguments(self) -> List[Dict[str, Any]]: + def get_arguments(self) -> list[dict[str, Any]]: return [ { "names": ["--verbose", "-v"], @@ -358,7 +357,7 @@ class ComplexGroup(BaseGroupCommand): name = "complex" help = "Complex group" - def get_arguments(self) -> List[Dict[str, Any]]: + def get_arguments(self) -> list[dict[str, Any]]: return [ { "name": "arg1", diff --git a/packages/deepctl-core/tests/unit/test_client.py b/packages/deepctl-core/tests/unit/test_client.py index 0bf8039..d5772ee 100644 --- a/packages/deepctl-core/tests/unit/test_client.py +++ b/packages/deepctl-core/tests/unit/test_client.py @@ -1,13 +1,13 @@ """Tests for the DeepgramClient class.""" -import pytest -from unittest.mock import Mock, patch, MagicMock, mock_open from pathlib import Path +from unittest.mock import MagicMock, Mock, mock_open, patch -from deepgram.core.api_error import ApiError -from deepctl_core.client import DeepgramClient +import pytest from deepctl_core.auth import AuthManager +from deepctl_core.client import DeepgramClient from deepctl_core.config import Config +from deepgram.core.api_error import ApiError def _mock_sdk_response(data: dict) -> Mock: diff --git a/packages/deepctl-core/tests/unit/test_config.py b/packages/deepctl-core/tests/unit/test_config.py index bb9e785..b08b7e8 100644 --- a/packages/deepctl-core/tests/unit/test_config.py +++ b/packages/deepctl-core/tests/unit/test_config.py @@ -1,11 +1,11 @@ """Tests for the Config class.""" -import pytest -from unittest.mock import Mock, patch, MagicMock, mock_open -from pathlib import Path -import yaml import os +from pathlib import Path +from unittest.mock import MagicMock, Mock, mock_open, patch +import pytest +import yaml from deepctl_core.config import Config diff --git a/packages/deepctl-core/tests/unit/test_models.py b/packages/deepctl-core/tests/unit/test_models.py index 11dce76..c6a9019 100644 --- a/packages/deepctl-core/tests/unit/test_models.py +++ b/packages/deepctl-core/tests/unit/test_models.py @@ -1,8 +1,6 @@ """Tests for the core models.""" import pytest -from pydantic import ValidationError - from deepctl_core.models import ( BaseResult, ErrorResult, @@ -10,6 +8,7 @@ ProfileInfo, ProfilesResult, ) +from pydantic import ValidationError class TestBaseResult: diff --git a/packages/deepctl-core/tests/unit/test_output.py b/packages/deepctl-core/tests/unit/test_output.py index 2f01ed0..ef9d45d 100644 --- a/packages/deepctl-core/tests/unit/test_output.py +++ b/packages/deepctl-core/tests/unit/test_output.py @@ -1,20 +1,20 @@ """Tests for the output utilities.""" -import pytest -from unittest.mock import Mock, patch, MagicMock import json -import yaml +from unittest.mock import MagicMock, Mock, patch +import pytest +import yaml from deepctl_core.output import ( OutputFormatter, - setup_output, + get_console, + is_agentic, + print_error, + print_info, print_output, print_success, - print_error, print_warning, - print_info, - get_console, - is_agentic, + setup_output, ) diff --git a/packages/deepctl-core/tests/unit/test_plugin_env.py b/packages/deepctl-core/tests/unit/test_plugin_env.py index d4862a2..01088af 100644 --- a/packages/deepctl-core/tests/unit/test_plugin_env.py +++ b/packages/deepctl-core/tests/unit/test_plugin_env.py @@ -30,7 +30,7 @@ class TestConstants: @pytest.mark.unit def test_plugin_dir_is_under_home(self): - assert PLUGIN_DIR == Path.home() / ".deepctl" / "plugins" + assert Path.home() / ".deepctl" / "plugins" == PLUGIN_DIR @pytest.mark.unit def test_plugin_venv_is_under_plugin_dir(self): @@ -120,10 +120,9 @@ def test_handles_timeout(self): with patch( "deepctl_core.plugin_env.subprocess.run", side_effect=sp.TimeoutExpired("python3", 5), - ): - with patch.object(Path, "exists", return_value=False): - result = find_system_python() - assert result is None + ), patch.object(Path, "exists", return_value=False): + result = find_system_python() + assert result is None class TestGetVenvPython: @@ -272,12 +271,11 @@ def test_reads_existing_state(self): test_state = { "plugins": {"test-plugin": {"version": "1.0.0"}} } - with patch.object(Path, "exists", return_value=True): - with patch.object( - Path, "read_text", return_value=json.dumps(test_state) - ): - state = get_plugin_state() - assert state == test_state + with patch.object(Path, "exists", return_value=True), patch.object( + Path, "read_text", return_value=json.dumps(test_state) + ): + state = get_plugin_state() + assert state == test_state @pytest.mark.unit def test_returns_empty_on_parse_error(self): diff --git a/packages/deepctl-core/tests/unit/test_plugin_manager.py b/packages/deepctl-core/tests/unit/test_plugin_manager.py index ce0ee9e..ad5d16d 100644 --- a/packages/deepctl-core/tests/unit/test_plugin_manager.py +++ b/packages/deepctl-core/tests/unit/test_plugin_manager.py @@ -1,19 +1,18 @@ """Unit tests for PluginManager class.""" -from typing import Any, Dict, List -from unittest.mock import Mock, patch, MagicMock, create_autospec from importlib.metadata import EntryPoint +from typing import Any, Dict, List +from unittest.mock import MagicMock, Mock, create_autospec, patch import click import pytest - from deepctl_core import ( + AuthManager, BaseCommand, BaseGroupCommand, - PluginManager, Config, - AuthManager, DeepgramClient, + PluginManager, ) @@ -405,13 +404,12 @@ def test_plugin_venv_skips_when_no_site_packages( with patch( "deepctl_core.plugin_manager.get_plugin_state", return_value={"plugins": {"some-plugin": {}}}, + ), patch( + "deepctl_core.plugin_manager.get_venv_site_packages", + return_value=None, ): - with patch( - "deepctl_core.plugin_manager.get_venv_site_packages", - return_value=None, - ): - plugin_manager._load_plugin_venv_entries(mock_cli_group) - mock_cli_group.add_command.assert_not_called() + plugin_manager._load_plugin_venv_entries(mock_cli_group) + mock_cli_group.add_command.assert_not_called() @pytest.mark.unit def test_plugin_venv_deduplicates_already_loaded( @@ -443,26 +441,23 @@ def test_plugin_venv_deduplicates_already_loaded( with patch( "deepctl_core.plugin_manager.get_plugin_state", return_value={"plugins": {"my-plugin": {}}}, - ): - with patch( - "deepctl_core.plugin_manager.get_venv_site_packages", - return_value=fake_sp, - ): - with patch( - "deepctl_core.plugin_manager.metadata.distributions", - return_value=[mock_dist], - ): - with patch( - "deepctl_core.plugin_manager.sys" - ) as mock_sys: - mock_sys.path = [] - - plugin_manager._load_plugin_venv_entries( - mock_cli_group - ) - - # Should NOT add the command (it's a duplicate) - mock_cli_group.add_command.assert_not_called() + ), patch( + "deepctl_core.plugin_manager.get_venv_site_packages", + return_value=fake_sp, + ), patch( + "deepctl_core.plugin_manager.metadata.distributions", + return_value=[mock_dist], + ), patch( + "deepctl_core.plugin_manager.sys" + ) as mock_sys: + mock_sys.path = [] + + plugin_manager._load_plugin_venv_entries( + mock_cli_group + ) + + # Should NOT add the command (it's a duplicate) + mock_cli_group.add_command.assert_not_called() @pytest.mark.unit def test_plugin_venv_loads_new_plugins( @@ -489,33 +484,30 @@ def test_plugin_venv_loads_new_plugins( with patch( "deepctl_core.plugin_manager.get_plugin_state", return_value={"plugins": {"venv-plugin": {}}}, - ): - with patch( - "deepctl_core.plugin_manager.get_venv_site_packages", - return_value=fake_sp, - ): - with patch( - "deepctl_core.plugin_manager.metadata.distributions", - return_value=[mock_dist], - ): - with patch( - "deepctl_core.plugin_manager.sys" - ) as mock_sys: - mock_sys.path = [] - - plugin_manager._load_plugin_venv_entries( - mock_cli_group - ) - - # Should add the command - mock_cli_group.add_command.assert_called_once() - # Should track in dedup set - assert ( - "venv_plugin.command:VenvPlugin" - in plugin_manager._loaded_entry_point_values - ) - # Should be in loaded_plugins - assert "venv-plugin" in plugin_manager.loaded_plugins + ), patch( + "deepctl_core.plugin_manager.get_venv_site_packages", + return_value=fake_sp, + ), patch( + "deepctl_core.plugin_manager.metadata.distributions", + return_value=[mock_dist], + ), patch( + "deepctl_core.plugin_manager.sys" + ) as mock_sys: + mock_sys.path = [] + + plugin_manager._load_plugin_venv_entries( + mock_cli_group + ) + + # Should add the command + mock_cli_group.add_command.assert_called_once() + # Should track in dedup set + assert ( + "venv_plugin.command:VenvPlugin" + in plugin_manager._loaded_entry_point_values + ) + # Should be in loaded_plugins + assert "venv-plugin" in plugin_manager.loaded_plugins @pytest.mark.unit def test_warn_if_plugin_venv_python_mismatch_silent_when_unknown( @@ -525,10 +517,9 @@ def test_warn_if_plugin_venv_python_mismatch_silent_when_unknown( with patch( "deepctl_core.plugin_manager.get_venv_python_version", return_value=None, - ): - with patch("deepctl_core.plugin_manager.print_warning") as mock_warn: - plugin_manager._warn_if_plugin_venv_python_mismatch() - mock_warn.assert_not_called() + ), patch("deepctl_core.plugin_manager.print_warning") as mock_warn: + plugin_manager._warn_if_plugin_venv_python_mismatch() + mock_warn.assert_not_called() @pytest.mark.unit def test_warn_if_plugin_venv_python_mismatch_silent_when_match( @@ -538,16 +529,14 @@ def test_warn_if_plugin_venv_python_mismatch_silent_when_match( with patch( "deepctl_core.plugin_manager.get_venv_python_version", return_value=(3, 13), - ): - with patch( - "deepctl_core.plugin_manager.sys.version_info", - Mock(major=3, minor=13), - ): - with patch( - "deepctl_core.plugin_manager.print_warning" - ) as mock_warn: - plugin_manager._warn_if_plugin_venv_python_mismatch() - mock_warn.assert_not_called() + ), patch( + "deepctl_core.plugin_manager.sys.version_info", + Mock(major=3, minor=13), + ), patch( + "deepctl_core.plugin_manager.print_warning" + ) as mock_warn: + plugin_manager._warn_if_plugin_venv_python_mismatch() + mock_warn.assert_not_called() @pytest.mark.unit def test_warn_if_plugin_venv_python_mismatch_warns_on_minor_diff( @@ -557,20 +546,18 @@ def test_warn_if_plugin_venv_python_mismatch_warns_on_minor_diff( with patch( "deepctl_core.plugin_manager.get_venv_python_version", return_value=(3, 12), - ): - with patch( - "deepctl_core.plugin_manager.sys.version_info", - Mock(major=3, minor=13), - ): - with patch( - "deepctl_core.plugin_manager.print_warning" - ) as mock_warn: - plugin_manager._warn_if_plugin_venv_python_mismatch() - mock_warn.assert_called_once() - msg = mock_warn.call_args[0][0] - assert "3.12" in msg - assert "3.13" in msg - assert "rm -rf" in msg + ), patch( + "deepctl_core.plugin_manager.sys.version_info", + Mock(major=3, minor=13), + ), patch( + "deepctl_core.plugin_manager.print_warning" + ) as mock_warn: + plugin_manager._warn_if_plugin_venv_python_mismatch() + mock_warn.assert_called_once() + msg = mock_warn.call_args[0][0] + assert "3.12" in msg + assert "3.13" in msg + assert "rm -rf" in msg @pytest.mark.unit def test_warn_if_plugin_venv_python_mismatch_warns_on_major_diff( @@ -580,13 +567,11 @@ def test_warn_if_plugin_venv_python_mismatch_warns_on_major_diff( with patch( "deepctl_core.plugin_manager.get_venv_python_version", return_value=(3, 13), - ): - with patch( - "deepctl_core.plugin_manager.sys.version_info", - Mock(major=4, minor=0), - ): - with patch( - "deepctl_core.plugin_manager.print_warning" - ) as mock_warn: - plugin_manager._warn_if_plugin_venv_python_mismatch() - mock_warn.assert_called_once() + ), patch( + "deepctl_core.plugin_manager.sys.version_info", + Mock(major=4, minor=0), + ), patch( + "deepctl_core.plugin_manager.print_warning" + ) as mock_warn: + plugin_manager._warn_if_plugin_venv_python_mismatch() + mock_warn.assert_called_once() diff --git a/packages/deepctl-core/tests/unit/test_skill_generator.py b/packages/deepctl-core/tests/unit/test_skill_generator.py index 6ddc08c..90f7f95 100644 --- a/packages/deepctl-core/tests/unit/test_skill_generator.py +++ b/packages/deepctl-core/tests/unit/test_skill_generator.py @@ -5,15 +5,14 @@ from unittest.mock import MagicMock, patch import pytest - from deepctl_core.skill_generator import ( + AmazonQGenerator, ClaudeCodeGenerator, + ClineGenerator, CodexGenerator, CommandMetadata, - GeminiGenerator, - AmazonQGenerator, CursorGenerator, - ClineGenerator, + GeminiGenerator, _commands_hash, collect_command_metadata, detect_ai_clis, diff --git a/packages/deepctl-plugin-example/tests/unit/test_example_command.py b/packages/deepctl-plugin-example/tests/unit/test_example_command.py index e342135..0d30348 100644 --- a/packages/deepctl-plugin-example/tests/unit/test_example_command.py +++ b/packages/deepctl-plugin-example/tests/unit/test_example_command.py @@ -1,11 +1,11 @@ """Tests for the example plugin command.""" +from unittest.mock import MagicMock, Mock + import pytest -from unittest.mock import Mock, MagicMock from click.testing import CliRunner - +from deepctl_core import AuthManager, Config, DeepgramClient from deepctl_plugin_example import ExampleCommand -from deepctl_core import Config, AuthManager, DeepgramClient @pytest.fixture diff --git a/packages/deepctl-shared-utils/tests/unit/test_ffprobe.py b/packages/deepctl-shared-utils/tests/unit/test_ffprobe.py index 5e54030..cda0cdf 100644 --- a/packages/deepctl-shared-utils/tests/unit/test_ffprobe.py +++ b/packages/deepctl-shared-utils/tests/unit/test_ffprobe.py @@ -17,7 +17,6 @@ AudioStreamInfo, ) - SAMPLE_FFPROBE_OUTPUT = { "format": { "format_name": "wav", diff --git a/packages/deepctl-shared-utils/tests/unit/test_file_info.py b/packages/deepctl-shared-utils/tests/unit/test_file_info.py index 0024d16..1f1b26c 100644 --- a/packages/deepctl-shared-utils/tests/unit/test_file_info.py +++ b/packages/deepctl-shared-utils/tests/unit/test_file_info.py @@ -14,16 +14,15 @@ from typing import Any, Dict import pytest -from pydantic import ValidationError - from deepctl_shared_utils import FileInfo +from pydantic import ValidationError class TestFileInfo: """Test suite for FileInfo model.""" @pytest.fixture - def valid_file_data(self) -> Dict[str, Any]: + def valid_file_data(self) -> dict[str, Any]: """Valid FileInfo data fixture.""" return { "path": "/home/user/audio/sample.mp3", @@ -40,13 +39,13 @@ def valid_file_data(self) -> Dict[str, Any]: } @pytest.fixture - def minimal_file_data(self) -> Dict[str, Any]: + def minimal_file_data(self) -> dict[str, Any]: """Minimal required FileInfo data.""" return {"path": "/tmp/test.txt", "exists": False} @pytest.mark.unit def test_file_info_creation_with_valid_data( - self, valid_file_data: Dict[str, Any] + self, valid_file_data: dict[str, Any] ): """Test FileInfo model creation with all fields.""" # Create the model @@ -66,7 +65,7 @@ def test_file_info_creation_with_valid_data( @pytest.mark.unit def test_file_info_minimal_creation( - self, minimal_file_data: Dict[str, Any] + self, minimal_file_data: dict[str, Any] ): """Test FileInfo model with minimal required fields.""" file_info = FileInfo(**minimal_file_data) @@ -154,7 +153,7 @@ def test_file_info_size_calculations( @pytest.mark.unit def test_file_info_model_serialization( - self, valid_file_data: Dict[str, Any] + self, valid_file_data: dict[str, Any] ): """Test model serialization to dict and JSON.""" file_info = FileInfo(**valid_file_data) @@ -230,7 +229,7 @@ def test_file_info_special_paths(self): @pytest.mark.unit @pytest.mark.benchmark def test_file_info_creation_performance( - self, valid_file_data: Dict[str, Any] + self, valid_file_data: dict[str, Any] ): """Benchmark FileInfo model creation performance.""" import time @@ -250,7 +249,7 @@ def test_file_info_creation_performance( assert avg_time < 0.001 # Less than 1ms per creation @pytest.mark.unit - def test_file_info_copy_and_update(self, valid_file_data: Dict[str, Any]): + def test_file_info_copy_and_update(self, valid_file_data: dict[str, Any]): """Test model copy with update functionality.""" original = FileInfo(**valid_file_data) diff --git a/packages/deepctl-telemetry/src/deepctl_telemetry/client.py b/packages/deepctl-telemetry/src/deepctl_telemetry/client.py index ecfb8d5..a9eb2b0 100644 --- a/packages/deepctl-telemetry/src/deepctl_telemetry/client.py +++ b/packages/deepctl-telemetry/src/deepctl_telemetry/client.py @@ -64,9 +64,11 @@ def init_telemetry(config: Config) -> bool: environment="production", send_default_pii=False, auto_session_tracking=True, - traces_sample_rate=0.0, - profiles_sample_rate=0.0, - max_breadcrumbs=20, + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + enable_logs=True, + attach_stacktrace=True, + max_breadcrumbs=100, before_send=_scrub_event, ) diff --git a/packages/deepctl-telemetry/tests/unit/test_telemetry.py b/packages/deepctl-telemetry/tests/unit/test_telemetry.py index 01c2915..def061e 100644 --- a/packages/deepctl-telemetry/tests/unit/test_telemetry.py +++ b/packages/deepctl-telemetry/tests/unit/test_telemetry.py @@ -5,7 +5,6 @@ from unittest.mock import Mock, patch import pytest - from deepctl_telemetry import init_telemetry, is_enabled, render_notice from deepctl_telemetry.client import DISABLE_ENV_VAR @@ -50,7 +49,7 @@ def reset_initialized(self) -> None: yield client._initialized = False - def test_init_enables_auto_session_tracking(self) -> None: + def test_init_enables_full_observability_stack(self) -> None: with patch("deepctl_telemetry.client.atexit.register"), patch( "sentry_sdk.init" ) as mock_init, patch("sentry_sdk.set_tag"), patch( @@ -61,8 +60,12 @@ def test_init_enables_auto_session_tracking(self) -> None: assert mock_init.called kwargs = mock_init.call_args.kwargs assert kwargs["auto_session_tracking"] is True - assert kwargs["traces_sample_rate"] == 0.0 - assert kwargs["profiles_sample_rate"] == 0.0 + assert kwargs["traces_sample_rate"] == 1.0 + assert kwargs["profiles_sample_rate"] == 1.0 + assert kwargs["enable_logs"] is True + assert kwargs["attach_stacktrace"] is True + assert kwargs["max_breadcrumbs"] == 100 + assert kwargs["send_default_pii"] is False assert "session_mode" not in kwargs def test_init_registers_atexit_flush(self) -> None: diff --git a/src/deepctl/main.py b/src/deepctl/main.py index 959f988..56c8bb9 100644 --- a/src/deepctl/main.py +++ b/src/deepctl/main.py @@ -1,9 +1,16 @@ """Main entry point for deepctl.""" +from __future__ import annotations + import importlib.metadata import sys +from contextlib import contextmanager +from typing import TYPE_CHECKING import click + +if TYPE_CHECKING: + from collections.abc import Iterator from deepctl_core import ( Config, TimingContext, @@ -228,6 +235,27 @@ def load_commands() -> None: load_commands() +@contextmanager +def _telemetry_transaction() -> Iterator[None]: + """Wrap CLI dispatch in a Sentry transaction (no-op when telemetry is off). + + The transaction is named generically ('cli') here. BaseCommand.execute + renames it to the full Click command path (e.g. 'deepctl debug audio') + once Click has dispatched — which is the single source of truth for + the name. Trying to extract a command name from raw sys.argv is unsafe + because flag values can look like command names ('--output json' makes + 'json' look like a command). + """ + try: + import sentry_sdk + except ImportError: + yield + return + + with sentry_sdk.start_transaction(op="cli.command", name="cli"): + yield + + def main() -> None: """Main entry point for the CLI.""" try: @@ -294,8 +322,7 @@ def main() -> None: # Preprocess arguments to handle hyphenated commands processed_args = preprocess_hyphenated_commands(args) - with TimingContext("cli_execution"): - # Call CLI with processed arguments + with TimingContext("cli_execution"), _telemetry_transaction(): try: cli(args=processed_args, standalone_mode=False) except SystemExit: