Skip to content

Commit 811d5ad

Browse files
{Core} Add CommandPreserveCasing and CommandIndexRebuild telemetry properties for command rebuild analysis (#32451)
1 parent 339d929 commit 811d5ad

File tree

7 files changed

+153
-5
lines changed

7 files changed

+153
-5
lines changed

src/azure-cli-core/azure/cli/core/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ def _update_command_table_from_modules(args, command_modules=None):
242242
command_modules.extend(ALWAYS_LOADED_MODULES)
243243
else:
244244
# Perform module discovery
245+
from azure.cli.core import telemetry
246+
telemetry.set_command_index_rebuild_triggered(True)
245247
command_modules = []
246248
try:
247249
mods_ns_pkg = import_module('azure.cli.command_modules')

src/azure-cli-core/azure/cli/core/commands/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,8 +512,10 @@ def execute(self, args):
512512
EVENT_INVOKER_FILTER_RESULT)
513513
from azure.cli.core.commands.events import (
514514
EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS)
515+
from azure.cli.core.util import roughly_parse_command_with_casing
515516

516517
# TODO: Can't simply be invoked as an event because args are transformed
518+
command_preserve_casing = roughly_parse_command_with_casing(args)
517519
args = _pre_command_table_create(self.cli_ctx, args)
518520

519521
self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args)
@@ -578,7 +580,7 @@ def execute(self, args):
578580
self.help.show_welcome(subparser)
579581

580582
# TODO: No event in base with which to target
581-
telemetry.set_command_details('az')
583+
telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing)
582584
telemetry.set_success(summary='welcome')
583585
return CommandResultItem(None, exit_code=0)
584586

@@ -633,7 +635,8 @@ def execute(self, args):
633635
pass
634636
telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'],
635637
self.cli_ctx.data['safe_params'],
636-
extension_name=extension_name, extension_version=extension_version)
638+
extension_name=extension_name, extension_version=extension_version,
639+
command_preserve_casing=command_preserve_casing)
637640
if extension_name:
638641
self.data['command_extension_name'] = extension_name
639642
self.cli_ctx.logging.log_cmd_metadata_extension_info(extension_name, extension_version)

src/azure-cli-core/azure/cli/core/extension/dynamic_install.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis
194194
# extension is already installed and return if yes as the error is not caused by extension not installed.
195195
from azure.cli.core.extension import get_extension, ExtensionNotInstalledException
196196
from azure.cli.core.extension._resolve import resolve_from_index, NoExtensionCandidatesError
197+
from azure.cli.core.util import roughly_parse_command_with_casing
197198
extension_allow_preview = _get_extension_allow_preview_install_config(cli_ctx)
198199
try:
199200
ext = get_extension(ext_name)
@@ -208,7 +209,8 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis
208209

209210
telemetry.set_command_details(command_str,
210211
parameters=AzCliCommandInvoker._extract_parameter_names(args), # pylint: disable=protected-access
211-
extension_name=ext_name)
212+
extension_name=ext_name,
213+
command_preserve_casing=roughly_parse_command_with_casing(args))
212214
run_after_extension_installed = _get_extension_run_after_dynamic_install_config(cli_ctx)
213215
prompt_info = ""
214216
if no_prompt:

src/azure-cli-core/azure/cli/core/telemetry.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ def __init__(self, correlation_id=None, application=None):
5151
self.feedback = None
5252
self.extension_management_detail = None
5353
self.raw_command = None
54+
self.command_preserve_casing = None
55+
self.is_cmd_idx_rebuild_triggered = False
5456
self.show_survey_message = False
5557
self.region_input = None
5658
self.region_identified = None
@@ -207,6 +209,9 @@ def _get_azure_cli_properties(self):
207209
set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed))
208210
set_custom_properties(result, 'OutputType', self.output_type)
209211
set_custom_properties(result, 'RawCommand', self.raw_command)
212+
set_custom_properties(result, 'CommandPreserveCasing',
213+
self.command_preserve_casing or '')
214+
set_custom_properties(result, 'IsCmdIdxRebuildTriggered', str(self.is_cmd_idx_rebuild_triggered))
210215
set_custom_properties(result, 'Params', ','.join(self.parameters or []))
211216
set_custom_properties(result, 'PythonVersion', platform.python_version())
212217
set_custom_properties(result, 'ModuleCorrelation', self.module_correlation)
@@ -437,12 +442,19 @@ def set_extension_management_detail(ext_name, ext_version):
437442

438443

439444
@decorators.suppress_all_exceptions()
440-
def set_command_details(command, output_type=None, parameters=None, extension_name=None, extension_version=None):
445+
def set_command_index_rebuild_triggered(is_cmd_idx_rebuild_triggered=False):
446+
_session.is_cmd_idx_rebuild_triggered = is_cmd_idx_rebuild_triggered
447+
448+
449+
@decorators.suppress_all_exceptions()
450+
def set_command_details(command, output_type=None, parameters=None, extension_name=None,
451+
extension_version=None, command_preserve_casing=None):
441452
_session.command = command
442453
_session.output_type = output_type
443454
_session.parameters = parameters
444455
_session.extension_name = extension_name
445456
_session.extension_version = extension_version
457+
_session.command_preserve_casing = command_preserve_casing
446458

447459

448460
@decorators.suppress_all_exceptions()

src/azure-cli-core/azure/cli/core/tests/test_telemetry.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,42 @@ def test_show_version_sets_telemetry_params(self, mock_get_version):
7979
self.assertEqual(session.command, "")
8080
self.assertEqual(session.parameters, ["--version"])
8181
self.assertIsNone(session.raw_command)
82+
83+
@mock.patch('azure.cli.core.util.get_az_version_string')
84+
def test_command_preserve_casing_telemetry(self, mock_get_version):
85+
"""Test telemetry captures command preserve casing during actual command invocation."""
86+
from azure.cli.core import telemetry
87+
from azure.cli.core.mock import DummyCli
88+
from knack.completion import ARGCOMPLETE_ENV_NAME
89+
90+
mock_get_version.return_value = ("azure-cli 2.80.0", ["core", "extension1"])
91+
92+
test_cases = [
93+
(["version"], "version"),
94+
(["VERSION"], "VERSION"),
95+
(["vm", "list"], "vm list"),
96+
(["Vm", "List"], "Vm List"),
97+
]
98+
99+
for command_args, expected_casing in test_cases:
100+
with self.subTest(command_args=command_args):
101+
cli = DummyCli()
102+
telemetry.set_application(cli, ARGCOMPLETE_ENV_NAME)
103+
telemetry.start()
104+
105+
try:
106+
cli.invoke(command_args)
107+
except SystemExit:
108+
pass
109+
except Exception:
110+
pass
111+
112+
# Verify the telemetry session preserves casing
113+
session = telemetry._session
114+
self.assertEqual(session.command_preserve_casing, expected_casing)
115+
116+
azure_cli_props = session._get_azure_cli_properties()
117+
118+
self.assertIn('Context.Default.AzureCLI.CommandPreserveCasing', azure_cli_props)
119+
self.assertEqual(azure_cli_props['Context.Default.AzureCLI.CommandPreserveCasing'],
120+
expected_casing)

src/azure-cli-core/azure/cli/core/tests/test_util.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
(get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string,
1818
open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request,
1919
should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent,
20-
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd)
20+
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, roughly_parse_command_with_casing)
2121
from azure.cli.core.mock import DummyCli
2222

2323

@@ -612,6 +612,83 @@ def _get_mock_HttpOperationError(response_text):
612612

613613
return mock_http_error
614614

615+
def test_roughly_parse_command(self):
616+
"""Test roughly_parse_command function that extracts command parts and converts to lowercase"""
617+
# Basic command parsing
618+
self.assertEqual(roughly_parse_command(['az', 'vm', 'create']), 'az vm create')
619+
self.assertEqual(roughly_parse_command(['account', 'show']), 'account show')
620+
self.assertEqual(roughly_parse_command(['network', 'vnet', 'list']), 'network vnet list')
621+
622+
# Test case conversion - should convert to lowercase
623+
self.assertEqual(roughly_parse_command(['az', 'VM', 'CREATE']), 'az vm create')
624+
self.assertEqual(roughly_parse_command(['Account', 'Show']), 'account show')
625+
626+
# Test with flags - should stop at first flag and not include flag values
627+
self.assertEqual(roughly_parse_command(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create')
628+
self.assertEqual(roughly_parse_command(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create')
629+
self.assertEqual(roughly_parse_command(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create')
630+
631+
# Test with short flags
632+
self.assertEqual(roughly_parse_command(['az', 'vm', 'list', '-g', 'myResourceGroup']), 'az vm list')
633+
self.assertEqual(roughly_parse_command(['az', 'group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az group create')
634+
635+
# Edge cases
636+
self.assertEqual(roughly_parse_command([]), '')
637+
self.assertEqual(roughly_parse_command(['az']), 'az')
638+
self.assertEqual(roughly_parse_command(['--help']), '') # Starts with flag
639+
self.assertEqual(roughly_parse_command(['-h']), '') # Starts with short flag
640+
641+
def test_roughly_parse_command_with_casing(self):
642+
"""Test roughly_parse_command_with_casing function that preserves original casing"""
643+
# Basic command parsing with case preservation
644+
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create']), 'az vm create')
645+
self.assertEqual(roughly_parse_command_with_casing(['account', 'show']), 'account show')
646+
self.assertEqual(roughly_parse_command_with_casing(['network', 'vnet', 'list']), 'network vnet list')
647+
648+
# Test case preservation - should keep original casing
649+
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'CREATE']), 'az VM CREATE')
650+
self.assertEqual(roughly_parse_command_with_casing(['Account', 'Show']), 'Account Show')
651+
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Network', 'Vnet', 'List']), 'Az Network Vnet List')
652+
653+
# Test with flags - should stop at first flag and not include sensitive flag values
654+
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create')
655+
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'Create', '--name', 'superSecretVM']), 'az VM Create')
656+
self.assertEqual(roughly_parse_command_with_casing(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create')
657+
self.assertEqual(roughly_parse_command_with_casing(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create')
658+
659+
# Test with short flags
660+
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'list', '-g', 'myResourceGroup']), 'az VM list')
661+
self.assertEqual(roughly_parse_command_with_casing(['az', 'Group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az Group create')
662+
663+
# Test mixed case scenarios that might reveal user typing patterns
664+
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Vm', 'Create']), 'Az Vm Create')
665+
self.assertEqual(roughly_parse_command_with_casing(['AZ', 'STORAGE', 'BLOB', 'LIST']), 'AZ STORAGE BLOB LIST')
666+
667+
# Edge cases
668+
self.assertEqual(roughly_parse_command_with_casing([]), '')
669+
self.assertEqual(roughly_parse_command_with_casing(['az']), 'az')
670+
self.assertEqual(roughly_parse_command_with_casing(['Az']), 'Az')
671+
self.assertEqual(roughly_parse_command_with_casing(['--help']), '') # Starts with flag
672+
self.assertEqual(roughly_parse_command_with_casing(['-h']), '') # Starts with short flag
673+
674+
# Security test - ensure no sensitive information leaks after flags
675+
test_cases_with_secrets = [
676+
(['az', 'vm', 'create', '--admin-password', 'SuperSecret123!'], 'az vm create'),
677+
(['az', 'sql', 'server', 'create', '--admin-user', 'admin', '--admin-password', 'VerySecret!'], 'az sql server create'),
678+
(['az', 'storage', 'account', 'create', '--name', 'storageacct', '--access-tier', 'Hot'], 'az storage account create'),
679+
(['Az', 'KeyVault', 'Secret', 'Set', '--vault-name', 'myVault', '--name', 'secretName', '--value', 'topSecret'], 'Az KeyVault Secret Set')
680+
]
681+
682+
for args, expected in test_cases_with_secrets:
683+
with self.subTest(args=args):
684+
result = roughly_parse_command_with_casing(args)
685+
self.assertEqual(result, expected)
686+
# Ensure no sensitive values made it through
687+
self.assertNotIn('SuperSecret123!', result)
688+
self.assertNotIn('VerySecret!', result)
689+
self.assertNotIn('topSecret', result)
690+
self.assertNotIn('storageacct', result) # Even non-secret values after flags should not appear
691+
615692

616693
if __name__ == '__main__':
617694
unittest.main()

src/azure-cli-core/azure/cli/core/util.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,6 +1281,19 @@ def roughly_parse_command(args):
12811281
return ' '.join(nouns).lower()
12821282

12831283

1284+
def roughly_parse_command_with_casing(args):
1285+
# Roughly parse the command part: <az VM create> --name vm1
1286+
# Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing
1287+
# and we don't need to bother with positional args
1288+
nouns = []
1289+
for arg in args:
1290+
if arg and arg[0] != '-':
1291+
nouns.append(arg)
1292+
else:
1293+
break
1294+
return ' '.join(nouns)
1295+
1296+
12841297
def is_guid(guid):
12851298
import uuid
12861299
try:

0 commit comments

Comments
 (0)