From f50d4dfbc7d851d5369d2322cfac6ba35ef1c07d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 10:30:44 +1100 Subject: [PATCH 01/12] feature: (WIP) add new telemetry field --- .vscode/launch.json | 28 +++++-------------- .../azure/cli/core/commands/__init__.py | 11 ++++++-- .../azure/cli/core/telemetry.py | 5 +++- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index c2a47d74891..39a4679d7c7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,7 +3,7 @@ "configurations": [ { "name": "Azure CLI Debug (Integrated Console)", - "type": "python", + "type": "debugpy", "request": "launch", "python": "${command:python.interpreterPath}", "program": "${workspaceRoot}/src/azure-cli/azure/cli/__main__.py", @@ -12,33 +12,24 @@ "--help" ], "console": "integratedTerminal", - "debugOptions": [ - "WaitOnAbnormalExit", - "WaitOnNormalExit", - "RedirectOutput" - ], "justMyCode": false }, { "name": "Azure CLI Debug (External Console)", - "type": "python", + "type": "debugpy", "request": "launch", "stopOnEntry": true, "python": "${command:python.interpreterPath}", "program": "${workspaceRoot}/src/azure-cli/azure/cli/__main__.py", "cwd": "${workspaceRoot}", "args": [ - "--help" + "VERSION" ], - "console": "externalTerminal", - "debugOptions": [ - "WaitOnAbnormalExit", - "WaitOnNormalExit" - ] + "console": "externalTerminal" }, { "name": "Azdev Scripts", - "type": "python", + "type": "debugpy", "request": "launch", "python": "${command:python.interpreterPath}", "program": "${workspaceRoot}/tools/automation/__main__.py", @@ -46,12 +37,7 @@ "args": [ "--help" ], - "console": "integratedTerminal", - "debugOptions": [ - "WaitOnAbnormalExit", - "WaitOnNormalExit", - "RedirectOutput" - ] + "console": "integratedTerminal" } ] -} +} \ No newline at end of file diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index fb2a9a3dece..223f5883543 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -514,7 +514,9 @@ def execute(self, args): EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS) # TODO: Can't simply be invoked as an event because args are transformed + # what are args before this call? args = _pre_command_table_create(self.cli_ctx, args) + raw_args = args.copy() self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) @@ -578,8 +580,10 @@ def execute(self, args): self.help.show_welcome(subparser) # TODO: No event in base with which to target - telemetry.set_command_details('az') + telemetry.set_command_details('az', raw_args=raw_args) telemetry.set_success(summary='welcome') + # @TODO: can add custom telemetry calls here? + # @ie: set commandAsTyped. trigger table? (where does that come from) return CommandResultItem(None, exit_code=0) if args[0].lower() == 'help': @@ -631,9 +635,12 @@ def execute(self, args): extension_version = get_extension(command_source.extension_name).version except Exception: # pylint: disable=broad-except pass + + # Raw args added to telemetry payload for command rebuild analysis telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'], self.cli_ctx.data['safe_params'], - extension_name=extension_name, extension_version=extension_version) + extension_name=extension_name, extension_version=extension_version, + raw_args=raw_args) if extension_name: self.data['command_extension_name'] = extension_name self.cli_ctx.logging.log_cmd_metadata_extension_info(extension_name, extension_version) diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 2388002f532..721690fc9c0 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -51,6 +51,7 @@ def __init__(self, correlation_id=None, application=None): self.feedback = None self.extension_management_detail = None self.raw_command = None + self.raw_args = None self.show_survey_message = False self.region_input = None self.region_identified = None @@ -207,6 +208,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed)) set_custom_properties(result, 'OutputType', self.output_type) set_custom_properties(result, 'RawCommand', self.raw_command) + set_custom_properties(result, 'RawArgs', ','.join(self.raw_args or [])) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) @@ -437,12 +439,13 @@ def set_extension_management_detail(ext_name, ext_version): @decorators.suppress_all_exceptions() -def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None): +def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None, raw_args=None): _session.command = command _session.output_type = output_type _session.parameters = parameters _session.extension_name = extension_name _session.extension_version = extension_version + _session.raw_args = raw_args @decorators.suppress_all_exceptions() From 352176e3130595aa83bd293c93a838ab894be74b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 10:39:00 +1100 Subject: [PATCH 02/12] refactor: remove comments and separate raw_args with space --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 4 ---- src/azure-cli-core/azure/cli/core/telemetry.py | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 223f5883543..e67a90af9da 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -514,7 +514,6 @@ def execute(self, args): EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS) # TODO: Can't simply be invoked as an event because args are transformed - # what are args before this call? args = _pre_command_table_create(self.cli_ctx, args) raw_args = args.copy() @@ -582,8 +581,6 @@ def execute(self, args): # TODO: No event in base with which to target telemetry.set_command_details('az', raw_args=raw_args) telemetry.set_success(summary='welcome') - # @TODO: can add custom telemetry calls here? - # @ie: set commandAsTyped. trigger table? (where does that come from) return CommandResultItem(None, exit_code=0) if args[0].lower() == 'help': @@ -636,7 +633,6 @@ def execute(self, args): except Exception: # pylint: disable=broad-except pass - # Raw args added to telemetry payload for command rebuild analysis telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'], self.cli_ctx.data['safe_params'], extension_name=extension_name, extension_version=extension_version, diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 721690fc9c0..38eee0dc221 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -208,7 +208,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed)) set_custom_properties(result, 'OutputType', self.output_type) set_custom_properties(result, 'RawCommand', self.raw_command) - set_custom_properties(result, 'RawArgs', ','.join(self.raw_args or [])) + set_custom_properties(result, 'RawArgs', ' '.join(self.raw_args or [])) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) From 917cb5cd292aa0c4ff78efaa96abd89f42f60c1a Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 14:01:08 +1100 Subject: [PATCH 03/12] refactor: change parsing of positional args and only take command roots --- .vscode/launch.json | 3 ++- .../azure/cli/core/commands/__init__.py | 8 +++++--- src/azure-cli-core/azure/cli/core/telemetry.py | 8 ++++---- src/azure-cli-core/azure/cli/core/util.py | 11 +++++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 39a4679d7c7..c2410822a36 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -23,7 +23,8 @@ "program": "${workspaceRoot}/src/azure-cli/azure/cli/__main__.py", "cwd": "${workspaceRoot}", "args": [ - "VERSION" + "vm", + "show" ], "console": "externalTerminal" }, diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e67a90af9da..5456a72258e 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -512,10 +512,12 @@ def execute(self, args): EVENT_INVOKER_FILTER_RESULT) from azure.cli.core.commands.events import ( EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS) + from azure.cli.core.util import roughly_parse_command_with_casing # TODO: Can't simply be invoked as an event because args are transformed + command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) - raw_args = args.copy() + # The index may be outdated. Make sure the command appears in the loaded command table self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) @@ -579,7 +581,7 @@ def execute(self, args): self.help.show_welcome(subparser) # TODO: No event in base with which to target - telemetry.set_command_details('az', raw_args=raw_args) + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='welcome') return CommandResultItem(None, exit_code=0) @@ -636,7 +638,7 @@ def execute(self, args): telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'], self.cli_ctx.data['safe_params'], extension_name=extension_name, extension_version=extension_version, - raw_args=raw_args) + command_preserve_casing=command_preserve_casing) if extension_name: self.data['command_extension_name'] = extension_name self.cli_ctx.logging.log_cmd_metadata_extension_info(extension_name, extension_version) diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 38eee0dc221..eccb42cb1f3 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -51,7 +51,7 @@ def __init__(self, correlation_id=None, application=None): self.feedback = None self.extension_management_detail = None self.raw_command = None - self.raw_args = None + self.command_preserve_casing = None self.show_survey_message = False self.region_input = None self.region_identified = None @@ -208,7 +208,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed)) set_custom_properties(result, 'OutputType', self.output_type) set_custom_properties(result, 'RawCommand', self.raw_command) - set_custom_properties(result, 'RawArgs', ' '.join(self.raw_args or [])) + set_custom_properties(result, 'CommandPreserveCasing', self.command_preserve_casing or '') set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) @@ -439,13 +439,13 @@ def set_extension_management_detail(ext_name, ext_version): @decorators.suppress_all_exceptions() -def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None, raw_args=None): +def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None, command_preserve_casing=None): _session.command = command _session.output_type = output_type _session.parameters = parameters _session.extension_name = extension_name _session.extension_version = extension_version - _session.raw_args = raw_args + _session.command_preserve_casing = command_preserve_casing @decorators.suppress_all_exceptions() diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index 05f96a575c3..f6fdb6bad10 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1280,6 +1280,17 @@ def roughly_parse_command(args): break return ' '.join(nouns).lower() +def roughly_parse_command_with_casing(args): + # Roughly parse the command part: --name vm1 + # Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing + # and we don't need to bother with positional args + nouns = [] + for arg in args: + if arg and arg[0] != '-': + nouns.append(arg) + else: + break + return ' '.join(nouns) def is_guid(guid): import uuid From c4c4f211ab6ec2f2505eff00bdcafee346dca638 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 15:58:22 +1100 Subject: [PATCH 04/12] feature: add properties flag for commandIndex rebuild --- src/azure-cli-core/azure/cli/core/__init__.py | 2 ++ src/azure-cli-core/azure/cli/core/telemetry.py | 5 +++++ src/azure-cli-core/azure/cli/core/util.py | 1 + 3 files changed, 8 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index e44a72fc5f1..0c6196c1818 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -239,6 +239,8 @@ def _update_command_table_from_modules(args, command_modules=None): command_modules.extend(ALWAYS_LOADED_MODULES) else: # Perform module discovery + from azure.cli.core import telemetry + telemetry.set_command_index_rebuild_triggered(True) command_modules = [] try: mods_ns_pkg = import_module('azure.cli.command_modules') diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index eccb42cb1f3..2896c90a5e7 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -52,6 +52,7 @@ def __init__(self, correlation_id=None, application=None): self.extension_management_detail = None self.raw_command = None self.command_preserve_casing = None + self.cmd_idx_rebuild_triggered = False self.show_survey_message = False self.region_input = None self.region_identified = None @@ -209,6 +210,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'OutputType', self.output_type) set_custom_properties(result, 'RawCommand', self.raw_command) set_custom_properties(result, 'CommandPreserveCasing', self.command_preserve_casing or '') + set_custom_properties(result, 'CmdIdxRebuildTriggered', self.cmd_idx_rebuild_triggered) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) @@ -437,6 +439,9 @@ def set_extension_management_detail(ext_name, ext_version): content = '{}@{}'.format(ext_name, ext_version) _session.extension_management_detail = content[:512] +@decorators.suppress_all_exceptions() +def set_command_index_rebuild_triggered(cmd_idx_rebuild_triggered=False): + _session.cmd_idx_rebuild_triggered = cmd_idx_rebuild_triggered @decorators.suppress_all_exceptions() def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None, command_preserve_casing=None): diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index f6fdb6bad10..c7162ee3694 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1280,6 +1280,7 @@ def roughly_parse_command(args): break return ' '.join(nouns).lower() +# @TODO: add unit test def roughly_parse_command_with_casing(args): # Roughly parse the command part: --name vm1 # Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing From ca021f7f6ed9290ade8ce539c1ff27882e068839 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 16:12:05 +1100 Subject: [PATCH 05/12] feature: add tests for util casing methods --- .../azure/cli/core/tests/test_util.py | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_util.py b/src/azure-cli-core/azure/cli/core/tests/test_util.py index 54b7ce7e490..28843f255b8 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_util.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_util.py @@ -17,7 +17,7 @@ (get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string, open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request, should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent, - _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd) + _get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, roughly_parse_command_with_casing) from azure.cli.core.mock import DummyCli @@ -612,6 +612,83 @@ def _get_mock_HttpOperationError(response_text): return mock_http_error + def test_roughly_parse_command(self): + """Test roughly_parse_command function that extracts command parts and converts to lowercase""" + # Basic command parsing + self.assertEqual(roughly_parse_command(['az', 'vm', 'create']), 'az vm create') + self.assertEqual(roughly_parse_command(['account', 'show']), 'account show') + self.assertEqual(roughly_parse_command(['network', 'vnet', 'list']), 'network vnet list') + + # Test case conversion - should convert to lowercase + self.assertEqual(roughly_parse_command(['az', 'VM', 'CREATE']), 'az vm create') + self.assertEqual(roughly_parse_command(['Account', 'Show']), 'account show') + + # Test with flags - should stop at first flag and not include flag values + self.assertEqual(roughly_parse_command(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create') + self.assertEqual(roughly_parse_command(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create') + self.assertEqual(roughly_parse_command(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create') + + # Test with short flags + self.assertEqual(roughly_parse_command(['az', 'vm', 'list', '-g', 'myResourceGroup']), 'az vm list') + self.assertEqual(roughly_parse_command(['az', 'group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az group create') + + # Edge cases + self.assertEqual(roughly_parse_command([]), '') + self.assertEqual(roughly_parse_command(['az']), 'az') + self.assertEqual(roughly_parse_command(['--help']), '') # Starts with flag + self.assertEqual(roughly_parse_command(['-h']), '') # Starts with short flag + + def test_roughly_parse_command_with_casing(self): + """Test roughly_parse_command_with_casing function that preserves original casing""" + # Basic command parsing with case preservation + self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create']), 'az vm create') + self.assertEqual(roughly_parse_command_with_casing(['account', 'show']), 'account show') + self.assertEqual(roughly_parse_command_with_casing(['network', 'vnet', 'list']), 'network vnet list') + + # Test case preservation - should keep original casing + self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'CREATE']), 'az VM CREATE') + self.assertEqual(roughly_parse_command_with_casing(['Account', 'Show']), 'Account Show') + self.assertEqual(roughly_parse_command_with_casing(['Az', 'Network', 'Vnet', 'List']), 'Az Network Vnet List') + + # Test with flags - should stop at first flag and not include sensitive flag values + self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create') + self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'Create', '--name', 'superSecretVM']), 'az VM Create') + self.assertEqual(roughly_parse_command_with_casing(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create') + self.assertEqual(roughly_parse_command_with_casing(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create') + + # Test with short flags + self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'list', '-g', 'myResourceGroup']), 'az VM list') + self.assertEqual(roughly_parse_command_with_casing(['az', 'Group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az Group create') + + # Test mixed case scenarios that might reveal user typing patterns + self.assertEqual(roughly_parse_command_with_casing(['Az', 'Vm', 'Create']), 'Az Vm Create') + self.assertEqual(roughly_parse_command_with_casing(['AZ', 'STORAGE', 'BLOB', 'LIST']), 'AZ STORAGE BLOB LIST') + + # Edge cases + self.assertEqual(roughly_parse_command_with_casing([]), '') + self.assertEqual(roughly_parse_command_with_casing(['az']), 'az') + self.assertEqual(roughly_parse_command_with_casing(['Az']), 'Az') + self.assertEqual(roughly_parse_command_with_casing(['--help']), '') # Starts with flag + self.assertEqual(roughly_parse_command_with_casing(['-h']), '') # Starts with short flag + + # Security test - ensure no sensitive information leaks after flags + test_cases_with_secrets = [ + (['az', 'vm', 'create', '--admin-password', 'SuperSecret123!'], 'az vm create'), + (['az', 'sql', 'server', 'create', '--admin-user', 'admin', '--admin-password', 'VerySecret!'], 'az sql server create'), + (['az', 'storage', 'account', 'create', '--name', 'storageacct', '--access-tier', 'Hot'], 'az storage account create'), + (['Az', 'KeyVault', 'Secret', 'Set', '--vault-name', 'myVault', '--name', 'secretName', '--value', 'topSecret'], 'Az KeyVault Secret Set') + ] + + for args, expected in test_cases_with_secrets: + with self.subTest(args=args): + result = roughly_parse_command_with_casing(args) + self.assertEqual(result, expected) + # Ensure no sensitive values made it through + self.assertNotIn('SuperSecret123!', result) + self.assertNotIn('VerySecret!', result) + self.assertNotIn('topSecret', result) + self.assertNotIn('storageacct', result) # Even non-secret values after flags should not appear + if __name__ == '__main__': unittest.main() From 35086c5fe27b27cfd9a156c212b1e4f73295c2b5 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 17:17:02 +1100 Subject: [PATCH 06/12] fix: adjust linting errors --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 3 +-- src/azure-cli-core/azure/cli/core/telemetry.py | 8 ++++++-- src/azure-cli-core/azure/cli/core/util.py | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 5456a72258e..07fe87eafa2 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -517,7 +517,7 @@ def execute(self, args): # TODO: Can't simply be invoked as an event because args are transformed command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) - # The index may be outdated. Make sure the command appears in the loaded command table + # The index may be outdated. Make sure the command appears in the loaded command table self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) @@ -634,7 +634,6 @@ def execute(self, args): extension_version = get_extension(command_source.extension_name).version except Exception: # pylint: disable=broad-except pass - telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'], self.cli_ctx.data['safe_params'], extension_name=extension_name, extension_version=extension_version, diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 2896c90a5e7..f4714e5eed0 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -209,7 +209,8 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed)) set_custom_properties(result, 'OutputType', self.output_type) set_custom_properties(result, 'RawCommand', self.raw_command) - set_custom_properties(result, 'CommandPreserveCasing', self.command_preserve_casing or '') + set_custom_properties(result, 'CommandPreserveCasing', + self.command_preserve_casing or '') set_custom_properties(result, 'CmdIdxRebuildTriggered', self.cmd_idx_rebuild_triggered) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) @@ -439,12 +440,15 @@ def set_extension_management_detail(ext_name, ext_version): content = '{}@{}'.format(ext_name, ext_version) _session.extension_management_detail = content[:512] + @decorators.suppress_all_exceptions() def set_command_index_rebuild_triggered(cmd_idx_rebuild_triggered=False): _session.cmd_idx_rebuild_triggered = cmd_idx_rebuild_triggered + @decorators.suppress_all_exceptions() -def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None, command_preserve_casing=None): +def set_command_details(command, output_type=None, parameters=None, extension_name=None, + extension_version=None, command_preserve_casing=None): _session.command = command _session.output_type = output_type _session.parameters = parameters diff --git a/src/azure-cli-core/azure/cli/core/util.py b/src/azure-cli-core/azure/cli/core/util.py index c7162ee3694..a02d1a9457e 100644 --- a/src/azure-cli-core/azure/cli/core/util.py +++ b/src/azure-cli-core/azure/cli/core/util.py @@ -1280,7 +1280,7 @@ def roughly_parse_command(args): break return ' '.join(nouns).lower() -# @TODO: add unit test + def roughly_parse_command_with_casing(args): # Roughly parse the command part: --name vm1 # Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing @@ -1293,6 +1293,7 @@ def roughly_parse_command_with_casing(args): break return ' '.join(nouns) + def is_guid(guid): import uuid try: From 3a54eef3391feb9ae266abf99fae8b933cee38f5 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 24 Nov 2025 18:02:04 +1100 Subject: [PATCH 07/12] fix: remove comments --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 07fe87eafa2..696b6093f5d 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -517,7 +517,6 @@ def execute(self, args): # TODO: Can't simply be invoked as an event because args are transformed command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) - # The index may be outdated. Make sure the command appears in the loaded command table self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) From 26803df4c6733e52e47aae0aecf459e7d79d4af9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 25 Nov 2025 09:34:56 +1100 Subject: [PATCH 08/12] fix: convert telemetry property to string --- src/azure-cli-core/azure/cli/core/telemetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index f4714e5eed0..474a88923c6 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -211,7 +211,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'RawCommand', self.raw_command) set_custom_properties(result, 'CommandPreserveCasing', self.command_preserve_casing or '') - set_custom_properties(result, 'CmdIdxRebuildTriggered', self.cmd_idx_rebuild_triggered) + set_custom_properties(result, 'CmdIdxRebuildTriggered', str(self.cmd_idx_rebuild_triggered)) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) From b6609cdb5fd351714aae864d04bafce1be2b12aa Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 1 Dec 2025 13:22:43 +1100 Subject: [PATCH 09/12] test: add test for preserve casing telemetry --- .../azure/cli/core/tests/test_telemetry.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_telemetry.py b/src/azure-cli-core/azure/cli/core/tests/test_telemetry.py index 7222acba99b..0077f2eeb28 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_telemetry.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_telemetry.py @@ -79,3 +79,42 @@ def test_show_version_sets_telemetry_params(self, mock_get_version): self.assertEqual(session.command, "") self.assertEqual(session.parameters, ["--version"]) self.assertIsNone(session.raw_command) + + @mock.patch('azure.cli.core.util.get_az_version_string') + def test_command_preserve_casing_telemetry(self, mock_get_version): + """Test telemetry captures command preserve casing during actual command invocation.""" + from azure.cli.core import telemetry + from azure.cli.core.mock import DummyCli + from knack.completion import ARGCOMPLETE_ENV_NAME + + mock_get_version.return_value = ("azure-cli 2.80.0", ["core", "extension1"]) + + test_cases = [ + (["version"], "version"), + (["VERSION"], "VERSION"), + (["vm", "list"], "vm list"), + (["Vm", "List"], "Vm List"), + ] + + for command_args, expected_casing in test_cases: + with self.subTest(command_args=command_args): + cli = DummyCli() + telemetry.set_application(cli, ARGCOMPLETE_ENV_NAME) + telemetry.start() + + try: + cli.invoke(command_args) + except SystemExit: + pass + except Exception: + pass + + # Verify the telemetry session preserves casing + session = telemetry._session + self.assertEqual(session.command_preserve_casing, expected_casing) + + azure_cli_props = session._get_azure_cli_properties() + + self.assertIn('Context.Default.AzureCLI.CommandPreserveCasing', azure_cli_props) + self.assertEqual(azure_cli_props['Context.Default.AzureCLI.CommandPreserveCasing'], + expected_casing) From 184676f830606a231b46f037703773ebd97a822f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 2 Dec 2025 14:35:58 +1100 Subject: [PATCH 10/12] refactor: set command_preserve_casing in extension telemetry --- .../azure/cli/core/extension/dynamic_install.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py b/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py index fc35e5d197c..75ba8c6aff9 100644 --- a/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py +++ b/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py @@ -194,6 +194,7 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis # extension is already installed and return if yes as the error is not caused by extension not installed. from azure.cli.core.extension import get_extension, ExtensionNotInstalledException from azure.cli.core.extension._resolve import resolve_from_index, NoExtensionCandidatesError + from azure.cli.core.util import roughly_parse_command_with_casing extension_allow_preview = _get_extension_allow_preview_install_config(cli_ctx) try: ext = get_extension(ext_name) @@ -208,7 +209,8 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis telemetry.set_command_details(command_str, parameters=AzCliCommandInvoker._extract_parameter_names(args), # pylint: disable=protected-access - extension_name=ext_name) + extension_name=ext_name, + command_preserve_casing = roughly_parse_command_with_casing(args)) run_after_extension_installed = _get_extension_run_after_dynamic_install_config(cli_ctx) prompt_info = "" if no_prompt: From 12cd58bf2458044bbe47768f0fe2f57f80937cfe Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 2 Dec 2025 15:19:41 +1100 Subject: [PATCH 11/12] fix: fix linting --- src/azure-cli-core/azure/cli/core/extension/dynamic_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py b/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py index 75ba8c6aff9..1f5c9e41a31 100644 --- a/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py +++ b/src/azure-cli-core/azure/cli/core/extension/dynamic_install.py @@ -210,7 +210,7 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis telemetry.set_command_details(command_str, parameters=AzCliCommandInvoker._extract_parameter_names(args), # pylint: disable=protected-access extension_name=ext_name, - command_preserve_casing = roughly_parse_command_with_casing(args)) + command_preserve_casing=roughly_parse_command_with_casing(args)) run_after_extension_installed = _get_extension_run_after_dynamic_install_config(cli_ctx) prompt_info = "" if no_prompt: From 9fcc8f18a94c9c6c9665da4f8bd8462d0d632003 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 3 Dec 2025 11:45:58 +1100 Subject: [PATCH 12/12] fix: change boolean prop name to match convetion --- src/azure-cli-core/azure/cli/core/telemetry.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/telemetry.py b/src/azure-cli-core/azure/cli/core/telemetry.py index 474a88923c6..714bd751263 100644 --- a/src/azure-cli-core/azure/cli/core/telemetry.py +++ b/src/azure-cli-core/azure/cli/core/telemetry.py @@ -52,7 +52,7 @@ def __init__(self, correlation_id=None, application=None): self.extension_management_detail = None self.raw_command = None self.command_preserve_casing = None - self.cmd_idx_rebuild_triggered = False + self.is_cmd_idx_rebuild_triggered = False self.show_survey_message = False self.region_input = None self.region_identified = None @@ -211,7 +211,7 @@ def _get_azure_cli_properties(self): set_custom_properties(result, 'RawCommand', self.raw_command) set_custom_properties(result, 'CommandPreserveCasing', self.command_preserve_casing or '') - set_custom_properties(result, 'CmdIdxRebuildTriggered', str(self.cmd_idx_rebuild_triggered)) + set_custom_properties(result, 'IsCmdIdxRebuildTriggered', str(self.is_cmd_idx_rebuild_triggered)) set_custom_properties(result, 'Params', ','.join(self.parameters or [])) set_custom_properties(result, 'PythonVersion', platform.python_version()) set_custom_properties(result, 'ModuleCorrelation', self.module_correlation) @@ -442,8 +442,8 @@ def set_extension_management_detail(ext_name, ext_version): @decorators.suppress_all_exceptions() -def set_command_index_rebuild_triggered(cmd_idx_rebuild_triggered=False): - _session.cmd_idx_rebuild_triggered = cmd_idx_rebuild_triggered +def set_command_index_rebuild_triggered(is_cmd_idx_rebuild_triggered=False): + _session.is_cmd_idx_rebuild_triggered = is_cmd_idx_rebuild_triggered @decorators.suppress_all_exceptions()