From e25dc7d65dc5e1bf99426c7b7a1cbc7c5aa4802b Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 20 Mar 2026 12:54:36 +0100 Subject: [PATCH 1/5] CM-61376: Track CLI/IDE activation events Co-Authored-By: Claude Sonnet 4.6 --- cycode/cli/app.py | 2 + cycode/cli/apps/activation_manager.py | 35 ++++++++++++ cycode/cli/apps/scan/scan_command.py | 5 ++ .../cli/user_settings/config_file_manager.py | 11 ++++ .../user_settings/configuration_manager.py | 6 ++ cycode/cyclient/cli_activation_client.py | 14 +++++ .../user_settings/test_activation_tracking.py | 57 +++++++++++++++++++ 7 files changed, 130 insertions(+) create mode 100644 cycode/cli/apps/activation_manager.py create mode 100644 cycode/cyclient/cli_activation_client.py create mode 100644 tests/user_settings/test_activation_tracking.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 41391f99..0e9f9c7b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -166,6 +166,8 @@ def app_callback( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + ctx.obj['plugin_app_name'] = user_agent_option.app_name + ctx.obj['plugin_app_version'] = user_agent_option.app_version if not no_update_notifier: ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py new file mode 100644 index 00000000..c05421c7 --- /dev/null +++ b/cycode/cli/apps/activation_manager.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING, Optional + +from cycode import __version__ +from cycode.cli.config import configuration_manager +from cycode.cyclient.cli_activation_client import CliActivationClient +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +logger = get_logger('Activation Manager') + +_CLI_CLIENT_NAME = 'cli' + + +def try_report_activation( + cycode_client: 'CycodeClientBase', + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> None: + """Report CLI/IDE activation to the backend if the (client, version) pair is new. + + Failures are swallowed — activation tracking is non-critical. + """ + try: + client = plugin_app_name or _CLI_CLIENT_NAME + version = plugin_app_version or __version__ + + if configuration_manager.get_last_reported_activation_version(client) == version: + return + + CliActivationClient(cycode_client).report_activation() + configuration_manager.update_last_reported_activation_version(client, version) + except Exception: + logger.debug('Failed to report CLI activation', exc_info=True) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7aab9d27..649456e8 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,7 @@ import click import typer +from cycode.cli.apps.activation_manager import try_report_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -140,6 +141,10 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + try_report_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5b029e39..cfab38d2 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -18,6 +18,7 @@ class ConfigFileManager(BaseFileManager): SCAN_SECTION_NAME: str = 'scan' INSTALLATION_ID_FIELD_NAME: str = 'installation_id' + LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: str = 'last_reported_activation_versions' API_URL_FIELD_NAME: str = 'cycode_api_url' APP_URL_FIELD_NAME: str = 'cycode_app_url' VERBOSE_FIELD_NAME: str = 'verbose' @@ -68,6 +69,16 @@ def update_installation_id(self, installation_id: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) + def get_last_reported_activation_versions(self) -> dict[str, str]: + value = self._get_value_from_environment_section(self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME) + return value if isinstance(value, dict) else {} + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + versions = self.get_last_reported_activation_versions() + versions[client] = version + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: versions}} + self.write_content_to_file(update_data) + def add_exclusion(self, scan_type: str, exclusion_type: str, new_exclusion: str) -> None: exclusions = self._get_exclusions_by_exclusion_type(scan_type, exclusion_type) if new_exclusion in exclusions: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 689ec0d5..f80f9a6e 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -94,6 +94,12 @@ def get_or_create_installation_id(self) -> str: return installation_id + def get_last_reported_activation_version(self, client: str) -> Optional[str]: + return self.global_config_file_manager.get_last_reported_activation_versions().get(client) + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + self.global_config_file_manager.update_last_reported_activation_version(client, version) + def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileManager: if scope == 'local': return self.local_config_file_manager diff --git a/cycode/cyclient/cli_activation_client.py b/cycode/cyclient/cli_activation_client.py new file mode 100644 index 00000000..2932c353 --- /dev/null +++ b/cycode/cyclient/cli_activation_client.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +_CLI_ACTIVATION_PATH = 'scans/api/v4/cli-activation' + + +class CliActivationClient: + def __init__(self, cycode_client: 'CycodeClientBase') -> None: + self._cycode_client = cycode_client + + def report_activation(self) -> None: + self._cycode_client.put(url_path=_CLI_ACTIVATION_PATH) diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py new file mode 100644 index 00000000..bbcfe1a5 --- /dev/null +++ b/tests/user_settings/test_activation_tracking.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from cycode.cli.user_settings.config_file_manager import ConfigFileManager + + +@pytest.fixture() +def config_manager(tmp_path: Path) -> ConfigFileManager: + return ConfigFileManager(tmp_path) + + +def test_get_last_reported_activation_versions_returns_empty_when_not_set( + config_manager: ConfigFileManager, +) -> None: + assert config_manager.get_last_reported_activation_versions() == {} + + +def test_update_and_get_last_reported_activation_version_cli(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.7'} + + +def test_update_and_get_last_reported_activation_version_plugin(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + + assert config_manager.get_last_reported_activation_versions() == {'vscode_extension': '2.0.0'} + + +def test_update_last_reported_activation_version_multiple_clients(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('jetbrains_extension', '1.5.0') + + assert config_manager.get_last_reported_activation_versions() == { + 'cli': '1.10.7', + 'vscode_extension': '2.0.0', + 'jetbrains_extension': '1.5.0', + } + + +def test_update_last_reported_activation_version_overwrites_existing(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.8'} + + +def test_update_last_reported_activation_version_does_not_affect_other_clients( + config_manager: ConfigFileManager, +) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions()['vscode_extension'] == '2.0.0' From 79de768377fbeb6ff2d360ff3a08d934a0534dab Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 20 Mar 2026 13:08:31 +0100 Subject: [PATCH 2/5] CM-61376: Address review feedback Co-Authored-By: Claude Sonnet 4.6 --- cycode/cli/apps/activation_manager.py | 19 ++++++++++++++++--- cycode/cli/apps/auth/auth_command.py | 8 ++++++++ cycode/cli/apps/scan/scan_command.py | 5 ----- cycode/cli/apps/status/get_cli_status.py | 5 +++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py index c05421c7..da8bd79c 100644 --- a/cycode/cli/apps/activation_manager.py +++ b/cycode/cli/apps/activation_manager.py @@ -13,7 +13,21 @@ _CLI_CLIENT_NAME = 'cli' -def try_report_activation( +def _get_client_and_version( + plugin_app_name: Optional[str], plugin_app_version: Optional[str] +) -> tuple[str, str]: + return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ + + +def should_report_cli_activation( + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> bool: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + return configuration_manager.get_last_reported_activation_version(client) != version + + +def report_cli_activation( cycode_client: 'CycodeClientBase', plugin_app_name: Optional[str] = None, plugin_app_version: Optional[str] = None, @@ -23,8 +37,7 @@ def try_report_activation( Failures are swallowed — activation tracking is non-critical. """ try: - client = plugin_app_name or _CLI_CLIENT_NAME - version = plugin_app_version or __version__ + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) if configuration_manager.get_last_reported_activation_version(client) == version: return diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 1184a916..e9d198bf 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,9 +1,11 @@ import typer +from cycode.cli.apps.activation_manager import should_report_cli_activation, report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult +from cycode.cli.utils.get_api_client import get_scan_cycode_client def auth_command(ctx: typer.Context) -> None: @@ -23,6 +25,12 @@ def auth_command(ctx: typer.Context) -> None: auth_manager = AuthManager() auth_manager.authenticate() + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + scan_client = get_scan_cycode_client(ctx) + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + result = CliResult(success=True, message='Successfully logged into cycode') printer.print_result(result) except Exception as err: diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 649456e8..7aab9d27 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,7 +5,6 @@ import click import typer -from cycode.cli.apps.activation_manager import try_report_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -141,10 +140,6 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client - plugin_app_name = ctx.obj.get('plugin_app_name') - plugin_app_version = ctx.obj.get('plugin_app_version') - try_report_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) - # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0cf6e8fd..3f9c91b1 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ +from cycode.cli.apps.activation_manager import should_report_cli_activation, report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME @@ -22,7 +23,11 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') client = get_scan_cycode_client(ctx) + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(client.scan_cycode_client, plugin_app_name, plugin_app_version) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning From 53aa831a7febf7d1faa9736801daed406e1042fc Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 20 Mar 2026 13:14:50 +0100 Subject: [PATCH 3/5] CM-61376: Report activation on scans too Co-Authored-By: Claude Sonnet 4.6 --- cycode/cli/apps/scan/scan_command.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7aab9d27..56dd2a56 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,7 @@ import click import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -140,6 +141,11 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) From 1b7347ae3e40870446425fa5c38a9c069af4d60c Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 20 Mar 2026 13:18:56 +0100 Subject: [PATCH 4/5] CM-61376: Fix ruff lint and format Co-Authored-By: Claude Sonnet 4.6 --- cycode/cli/apps/activation_manager.py | 4 +--- cycode/cli/apps/auth/auth_command.py | 2 +- cycode/cli/apps/status/get_cli_status.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py index da8bd79c..8eed3caa 100644 --- a/cycode/cli/apps/activation_manager.py +++ b/cycode/cli/apps/activation_manager.py @@ -13,9 +13,7 @@ _CLI_CLIENT_NAME = 'cli' -def _get_client_and_version( - plugin_app_name: Optional[str], plugin_app_version: Optional[str] -) -> tuple[str, str]: +def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]: return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index e9d198bf..005e8c3e 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,6 +1,6 @@ import typer -from cycode.cli.apps.activation_manager import should_report_cli_activation, report_cli_activation +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 3f9c91b1..7018fa29 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ -from cycode.cli.apps.activation_manager import should_report_cli_activation, report_cli_activation +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME From 75fc53c0433e6ef1bcc9bdfca2b78d2436a8c941 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 20 Mar 2026 13:22:01 +0100 Subject: [PATCH 5/5] CM-61376: Fix pytest fixture style Co-Authored-By: Claude Sonnet 4.6 --- tests/user_settings/test_activation_tracking.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py index bbcfe1a5..5c8faf02 100644 --- a/tests/user_settings/test_activation_tracking.py +++ b/tests/user_settings/test_activation_tracking.py @@ -5,7 +5,7 @@ from cycode.cli.user_settings.config_file_manager import ConfigFileManager -@pytest.fixture() +@pytest.fixture def config_manager(tmp_path: Path) -> ConfigFileManager: return ConfigFileManager(tmp_path)