diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 8f2958473..ac0da64da 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -57,7 +57,7 @@ jobs: rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile" envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile" env: - CONNECTION_STRING: ${{ secrets.APPINS_CONNECTION_STRING }} + CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }} - name: Build run: uv build diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 9326cb40d..d2fce5b27 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -40,7 +40,7 @@ jobs: rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile" envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile" env: - CONNECTION_STRING: ${{ secrets.APPINS_CONNECTION_STRING }} + CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }} - name: Set development version shell: pwsh diff --git a/pyproject.toml b/pyproject.toml index ff52e089a..f4393c0c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.6.22" +version = "2.6.23" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/_cli/_telemetry.py b/src/uipath/_cli/_telemetry.py new file mode 100644 index 000000000..02001e8eb --- /dev/null +++ b/src/uipath/_cli/_telemetry.py @@ -0,0 +1,207 @@ +import logging +import os +import time +import uuid +from functools import wraps +from typing import Any, Callable, Dict, Optional + +from uipath.telemetry._track import ( + is_telemetry_enabled, + track_cli_event, +) + +logger = logging.getLogger(__name__) + +# Telemetry event name templates for Application Insights +CLI_COMMAND_STARTED = "Cli.{command}.Start.URT" +CLI_COMMAND_COMPLETED = "Cli.{command}.End.URT" +CLI_COMMAND_FAILED = "Cli.{command}.Failed.URT" + + +class CliTelemetryTracker: + """Tracks CLI command execution and sends telemetry to Application Insights. + + This class handles tracking of CLI command lifecycle events: + - Command start events + - Command completion events (success) + - Command failure events (with error details) + """ + + def __init__(self) -> None: + self._start_times: Dict[str, float] = {} + self._event_ids: Dict[str, str] = {} + + @staticmethod + def _get_event_name(command: str, status: str) -> str: + return f"Cli.{command.capitalize()}.{status}.URT" + + def _enrich_properties(self, properties: Dict[str, Any]) -> None: + """Enrich properties with common context information. + + Args: + properties: The properties dictionary to enrich. + """ + # Add CI environment detection + properties["IsCI"] = bool(os.getenv("GITHUB_ACTIONS")) + + # Add UiPath context + project_id = os.getenv("UIPATH_PROJECT_ID") + if project_id: + properties["ProjectId"] = project_id + + org_id = os.getenv("UIPATH_CLOUD_ORGANIZATION_ID") + if org_id: + properties["CloudOrganizationId"] = org_id + + user_id = os.getenv("UIPATH_CLOUD_USER_ID") + if user_id: + properties["CloudUserId"] = user_id + + tenant_id = os.getenv("UIPATH_TENANT_ID") + if tenant_id: + properties["TenantId"] = tenant_id + + # Add source identifier + properties["Source"] = "uipath-python-cli" + properties["ApplicationName"] = "UiPath.Cli" + + def track_command_start(self, command: str) -> None: + try: + self._start_times[command] = time.time() + self._event_ids[command] = str(uuid.uuid4()) + + properties: Dict[str, Any] = { + "Command": command, + "EventId": self._event_ids[command], + } + self._enrich_properties(properties) + + track_cli_event(self._get_event_name(command, "Start"), properties) + logger.debug(f"Tracked CLI command started: {command}") + + except Exception as e: + logger.debug(f"Error tracking CLI command start: {e}") + + def track_command_end( + self, + command: str, + duration_ms: Optional[int] = None, + ) -> None: + try: + if duration_ms is None: + start_time = self._start_times.pop(command, None) + if start_time: + duration_ms = int((time.time() - start_time) * 1000) + + event_id = self._event_ids.pop(command, None) + + properties: Dict[str, Any] = { + "Command": command, + "Success": True, + } + + if event_id: + properties["EventId"] = event_id + + if duration_ms is not None: + properties["DurationMs"] = duration_ms + + self._enrich_properties(properties) + + track_cli_event(self._get_event_name(command, "End"), properties) + logger.debug(f"Tracked CLI command completed: {command}") + + except Exception as e: + logger.debug(f"Error tracking CLI command end: {e}") + + def track_command_failed( + self, + command: str, + duration_ms: Optional[int] = None, + exception: Optional[Exception] = None, + ) -> None: + try: + if duration_ms is None: + start_time = self._start_times.pop(command, None) + if start_time: + duration_ms = int((time.time() - start_time) * 1000) + + event_id = self._event_ids.pop(command, None) + + properties: Dict[str, Any] = { + "Command": command, + "Success": False, + } + + if event_id: + properties["EventId"] = event_id + + if duration_ms is not None: + properties["DurationMs"] = duration_ms + + if exception is not None: + properties["ErrorType"] = type(exception).__name__ + properties["ErrorMessage"] = str(exception)[:500] + + self._enrich_properties(properties) + + track_cli_event(self._get_event_name(command, "Failed"), properties) + logger.debug(f"Tracked CLI command failed: {command}") + + except Exception as e: + logger.debug(f"Error tracking CLI command failed: {e}") + + +def track_command(command: str) -> Callable[..., Any]: + """Decorator to track CLI command execution. + + Tracks the following events to Application Insights: + - Cli..Start.URT - when command begins + - Cli..End.URT - on successful completion + - Cli..Failed.URT - on exception + + Properties tracked include: + - Command: The command name + - Success: Whether the command succeeded + - DurationMs: Execution time in milliseconds + - ErrorType: Exception type name (on failure) + - ErrorMessage: Exception message (on failure, truncated to 500 chars) + - ProjectId, CloudOrganizationId, etc. (if available) + + Telemetry failures are silently ignored to ensure CLI execution + is never blocked by telemetry issues. + + Args: + command: The CLI command name (e.g., "pack", "publish", "run"). + + Returns: + A decorator function that wraps the CLI command. + + Example: + @click.command() + @track_command("pack") + def pack(root, nolock): + ... + """ + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if not is_telemetry_enabled(): + return func(*args, **kwargs) + + tracker = CliTelemetryTracker() + tracker.track_command_start(command) + + try: + result = func(*args, **kwargs) + tracker.track_command_end(command) + return result + + except Exception as e: + tracker.track_command_failed(command, exception=e) + raise + + return wrapper + + return decorator diff --git a/src/uipath/_cli/cli_add.py b/src/uipath/_cli/cli_add.py index b8bb92990..d04c2625e 100644 --- a/src/uipath/_cli/cli_add.py +++ b/src/uipath/_cli/cli_add.py @@ -7,6 +7,7 @@ import click from .._utils.constants import EVALS_FOLDER +from ._telemetry import track_command from ._utils._console import ConsoleLogger from ._utils._resources import Resources @@ -84,6 +85,7 @@ def create_evaluator(evaluator_name): @click.command() @click.argument("resource", required=True) @click.argument("args", nargs=-1) +@track_command("add") def add(resource: str, args: tuple[str]) -> None: """Create a local resource. diff --git a/src/uipath/_cli/cli_auth.py b/src/uipath/_cli/cli_auth.py index a014f0d2e..574bb177b 100644 --- a/src/uipath/_cli/cli_auth.py +++ b/src/uipath/_cli/cli_auth.py @@ -1,6 +1,7 @@ import click from ._auth._auth_service import AuthService +from ._telemetry import track_command from ._utils._common import environment_options from ._utils._console import ConsoleLogger @@ -43,6 +44,7 @@ default="OR.Execution", help="Space-separated list of OAuth scopes to request (e.g., 'OR.Execution OR.Queues'). Defaults to 'OR.Execution'", ) +@track_command("auth") def auth( environment: str, force: bool = False, diff --git a/src/uipath/_cli/cli_deploy.py b/src/uipath/_cli/cli_deploy.py index a80d713c8..087fb890c 100644 --- a/src/uipath/_cli/cli_deploy.py +++ b/src/uipath/_cli/cli_deploy.py @@ -1,5 +1,6 @@ import click +from ._telemetry import track_command from .cli_pack import pack from .cli_publish import publish @@ -27,6 +28,7 @@ help="Folder name to publish to (skips interactive selection)", ) @click.argument("root", type=str, default="./") +@track_command("deploy") def deploy(root, feed, folder): """Pack and publish the project.""" ctx = click.get_current_context() diff --git a/src/uipath/_cli/cli_dev.py b/src/uipath/_cli/cli_dev.py index 0b659dafb..ed3073b70 100644 --- a/src/uipath/_cli/cli_dev.py +++ b/src/uipath/_cli/cli_dev.py @@ -4,6 +4,7 @@ from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry +from uipath._cli._telemetry import track_command from uipath._cli._utils._console import ConsoleLogger from uipath._cli._utils._debug import setup_debugging from uipath._cli.middlewares import Middlewares @@ -39,6 +40,7 @@ def _check_dev_dependency() -> None: default=5678, help="Port for the debug server (default: 5678)", ) +@track_command("dev") def dev(interface: str | None, debug: bool, debug_port: int) -> None: """Launch UiPath Developer Console.""" try: diff --git a/src/uipath/_cli/cli_init.py b/src/uipath/_cli/cli_init.py index dadb2987a..58a66ea99 100644 --- a/src/uipath/_cli/cli_init.py +++ b/src/uipath/_cli/cli_init.py @@ -27,6 +27,7 @@ from .._utils.constants import ENV_TELEMETRY_ENABLED from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE +from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares from .models.runtime_schema import Bindings @@ -252,6 +253,7 @@ def _add_graph_to_chart(chart: Chart | Subgraph, graph: UiPathRuntimeGraph) -> N default=False, help="Won't override existing .agent files and AGENTS.md file.", ) +@track_command("init") def init(no_agents_md_override: bool) -> None: """Initialize the project.""" with console.spinner("Initializing UiPath project ..."): diff --git a/src/uipath/_cli/cli_invoke.py b/src/uipath/_cli/cli_invoke.py index 3e5381650..726b623d1 100644 --- a/src/uipath/_cli/cli_invoke.py +++ b/src/uipath/_cli/cli_invoke.py @@ -7,6 +7,7 @@ import httpx from .._utils._ssl_context import get_httpx_client_kwargs +from ._telemetry import track_command from ._utils._common import get_env_vars from ._utils._console import ConsoleLogger from ._utils._folders import get_personal_workspace_info_async @@ -43,6 +44,7 @@ def _read_project_details() -> tuple[str, str]: type=click.Path(exists=True), help="File path for the .json input", ) +@track_command("invoke") def invoke(entrypoint: str | None, input: str | None, file: str | None) -> None: """Invoke an agent published in my workspace.""" if file: diff --git a/src/uipath/_cli/cli_new.py b/src/uipath/_cli/cli_new.py index d585d0dd1..390c581c5 100644 --- a/src/uipath/_cli/cli_new.py +++ b/src/uipath/_cli/cli_new.py @@ -4,6 +4,7 @@ import click +from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -46,6 +47,7 @@ def generate_uipath_json(target_directory): @click.command() @click.argument("name", type=str, default="") +@track_command("new") def new(name: str): """Generate a quick-start project.""" directory = os.getcwd() diff --git a/src/uipath/_cli/cli_pack.py b/src/uipath/_cli/cli_pack.py index fddef5c0c..86d22868d 100644 --- a/src/uipath/_cli/cli_pack.py +++ b/src/uipath/_cli/cli_pack.py @@ -14,6 +14,7 @@ from uipath.platform.common import UiPathConfig from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE +from ._telemetry import track_command from ._utils._console import ConsoleLogger from ._utils._project_files import ( ensure_config_file, @@ -336,6 +337,7 @@ def display_project_info(config): is_flag=True, help="Skip running uv lock and exclude uv.lock from the package", ) +@track_command("pack") def pack(root, nolock): """Pack the project.""" version = get_project_version(root) diff --git a/src/uipath/_cli/cli_publish.py b/src/uipath/_cli/cli_publish.py index 9e7c64afc..246977af1 100644 --- a/src/uipath/_cli/cli_publish.py +++ b/src/uipath/_cli/cli_publish.py @@ -6,6 +6,7 @@ import httpx from .._utils._ssl_context import get_httpx_client_kwargs +from ._telemetry import track_command from ._utils._common import get_env_vars from ._utils._console import ConsoleLogger from ._utils._folders import get_personal_workspace_info_async @@ -118,6 +119,7 @@ def find_feed_by_folder_name( type=str, help="Folder name to publish to (skips interactive selection)", ) +@track_command("publish") def publish(feed, folder): """Publish the package.""" [base_url, token] = get_env_vars() diff --git a/src/uipath/_cli/cli_pull.py b/src/uipath/_cli/cli_pull.py index a135d9e59..c056569e1 100644 --- a/src/uipath/_cli/cli_pull.py +++ b/src/uipath/_cli/cli_pull.py @@ -7,6 +7,7 @@ from uipath.platform.common import UiPathConfig +from ._telemetry import track_command from ._utils._common import ensure_coded_agent_project, may_override_files from ._utils._console import ConsoleLogger from ._utils._project_files import ( @@ -30,6 +31,7 @@ is_flag=True, help="Automatically overwrite local files without prompts", ) +@track_command("pull") def pull(root: Path, overwrite: bool) -> None: """Pull remote project files from Studio Web. diff --git a/src/uipath/_cli/cli_push.py b/src/uipath/_cli/cli_push.py index 72db76ed3..61e46cbc6 100644 --- a/src/uipath/_cli/cli_push.py +++ b/src/uipath/_cli/cli_push.py @@ -9,6 +9,7 @@ from ..platform.resource_catalog import ResourceType from ._push.sw_file_handler import SwFileHandler +from ._telemetry import track_command from ._utils._common import ensure_coded_agent_project, may_override_files from ._utils._console import ConsoleLogger from ._utils._project_files import ( @@ -230,6 +231,7 @@ async def upload_source_files_to_project( is_flag=True, help="Automatically overwrite remote files without prompts", ) +@track_command("push") def push(root: str, ignore_resources: bool, nolock: bool, overwrite: bool) -> None: """Push local project files to Studio Web. diff --git a/src/uipath/_cli/cli_register.py b/src/uipath/_cli/cli_register.py index f6b2313e7..38c056aae 100644 --- a/src/uipath/_cli/cli_register.py +++ b/src/uipath/_cli/cli_register.py @@ -5,6 +5,7 @@ from ._evals._helpers import ( # type: ignore[attr-defined] # Remove after gnarly fix register_evaluator, ) +from ._telemetry import track_command from ._utils._console import ConsoleLogger from ._utils._resources import Resources @@ -15,6 +16,7 @@ @click.command() @click.argument("resource", required=True) @click.argument("args", nargs=-1) +@track_command("register") def register(resource: str, args: tuple[str]) -> None: """Register a local resource. diff --git a/src/uipath/_cli/cli_run.py b/src/uipath/_cli/cli_run.py index c447ad8ef..5c13bf0e9 100644 --- a/src/uipath/_cli/cli_run.py +++ b/src/uipath/_cli/cli_run.py @@ -27,6 +27,7 @@ LlmOpsHttpExporter, ) +from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -84,6 +85,7 @@ is_flag=True, help="Keep the temporary state file even when not resuming and no job id is provided", ) +@track_command("run") def run( entrypoint: str | None, input: str | None, diff --git a/src/uipath/telemetry/_track.py b/src/uipath/telemetry/_track.py index 926fb7dc5..aea314b9f 100644 --- a/src/uipath/telemetry/_track.py +++ b/src/uipath/telemetry/_track.py @@ -1,5 +1,6 @@ import json import os +import threading from functools import wraps from importlib.metadata import version from logging import INFO, WARNING, LogRecord, getLogger @@ -24,6 +25,7 @@ _CODE_FILEPATH, _CODE_FUNCTION, _CODE_LINENO, + _CONNECTION_STRING, _OTEL_RESOURCE_ATTRIBUTES, _PROJECT_KEY, _SDK_VERSION, @@ -325,6 +327,48 @@ def flush_events() -> None: _AppInsightsEventClient.flush() +def _inject_cli_connection_string() -> None: + """Inject _CONNECTION_STRING into TELEMETRY_CONNECTION_STRING env var for CLI. + + This allows CLI commands to use the build-time injected connection string. + """ + if os.getenv("TELEMETRY_CONNECTION_STRING"): + return + if _CONNECTION_STRING and _CONNECTION_STRING != "$CONNECTION_STRING": + os.environ["TELEMETRY_CONNECTION_STRING"] = _CONNECTION_STRING + + +def _send_cli_event_sync(name: str, properties: Optional[Dict[str, Any]]) -> None: + """Send CLI event synchronously.""" + try: + _inject_cli_connection_string() + _AppInsightsEventClient.track_event(name, properties) + _AppInsightsEventClient.flush() + except Exception: + pass + + +def track_cli_event( + name: str, + properties: Optional[Dict[str, Any]] = None, +) -> None: + """Track a CLI event. + + This function runs telemetry in a background daemon thread so it doesn't block the CLI. + """ + if not _TelemetryClient._is_enabled(): + return + try: + thread = threading.Thread( + target=_send_cli_event_sync, + args=(name, properties), + daemon=True, + ) + thread.start() + except Exception: + pass + + def track( name_or_func: Optional[Union[str, Callable[..., Any]]] = None, *, diff --git a/tests/cli/test_cli_telemetry.py b/tests/cli/test_cli_telemetry.py new file mode 100644 index 000000000..4fd960874 --- /dev/null +++ b/tests/cli/test_cli_telemetry.py @@ -0,0 +1,433 @@ +"""Tests for CLI telemetry functionality.""" + +import os +from typing import Any +from unittest.mock import patch + +import pytest + +from uipath._cli._telemetry import ( + CLI_COMMAND_COMPLETED, + CLI_COMMAND_FAILED, + CLI_COMMAND_STARTED, + CliTelemetryTracker, + track_command, +) + + +class TestEventNameConstants: + """Test telemetry event name constants.""" + + def test_cli_command_event_name_templates(self): + """Test CLI command event name templates.""" + assert CLI_COMMAND_STARTED == "Cli.{command}.Start.URT" + assert CLI_COMMAND_COMPLETED == "Cli.{command}.End.URT" + assert CLI_COMMAND_FAILED == "Cli.{command}.Failed.URT" + + +class TestCliTelemetryTrackerInit: + """Test CliTelemetryTracker initialization.""" + + def test_init_creates_empty_tracking_dicts(self): + """Test that initialization creates empty tracking dictionaries.""" + tracker = CliTelemetryTracker() + + assert tracker._start_times == {} + assert tracker._event_ids == {} + + +class TestCliTelemetryTrackerGetEventName: + """Test event name generation.""" + + def test_get_event_name_start(self): + """Test event name for start status.""" + assert ( + CliTelemetryTracker._get_event_name("pack", "Start") == "Cli.Pack.Start.URT" + ) + + def test_get_event_name_end(self): + """Test event name for end status.""" + assert ( + CliTelemetryTracker._get_event_name("publish", "End") + == "Cli.Publish.End.URT" + ) + + def test_get_event_name_failed(self): + """Test event name for failed status.""" + assert ( + CliTelemetryTracker._get_event_name("run", "Failed") == "Cli.Run.Failed.URT" + ) + + def test_get_event_name_lowercase_command(self): + """Test that command is capitalized.""" + assert ( + CliTelemetryTracker._get_event_name("init", "Start") == "Cli.Init.Start.URT" + ) + + +class TestCliTelemetryTrackerEnrichProperties: + """Test property enrichment with context information.""" + + def test_enrich_properties_adds_source(self): + """Test that source and application name are always added.""" + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + tracker._enrich_properties(properties) + + assert properties["Source"] == "uipath-python-cli" + assert properties["ApplicationName"] == "UiPath.Cli" + + def test_enrich_properties_adds_env_vars(self): + """Test that environment variables are added when present.""" + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + with patch.dict( + os.environ, + { + "UIPATH_PROJECT_ID": "project-123", + "UIPATH_CLOUD_ORGANIZATION_ID": "org-456", + "UIPATH_CLOUD_USER_ID": "user-789", + "UIPATH_TENANT_ID": "tenant-abc", + }, + ): + tracker._enrich_properties(properties) + + assert properties["ProjectId"] == "project-123" + assert properties["CloudOrganizationId"] == "org-456" + assert properties["CloudUserId"] == "user-789" + assert properties["TenantId"] == "tenant-abc" + + def test_enrich_properties_skips_missing_env_vars(self): + """Test that missing environment variables are not added.""" + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + with patch.dict(os.environ, {}, clear=True): + for key in [ + "UIPATH_PROJECT_ID", + "UIPATH_CLOUD_ORGANIZATION_ID", + "UIPATH_CLOUD_USER_ID", + "UIPATH_TENANT_ID", + ]: + os.environ.pop(key, None) + + tracker._enrich_properties(properties) + + assert "ProjectId" not in properties + assert "CloudOrganizationId" not in properties + assert "CloudUserId" not in properties + assert "TenantId" not in properties + + +class TestCliTelemetryTrackerTrackCommandStart: + """Test command start tracking.""" + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_start_tracks_event(self, mock_track_event): + """Test that command start event is tracked.""" + tracker = CliTelemetryTracker() + + tracker.track_command_start("pack") + + mock_track_event.assert_called_once() + call_args = mock_track_event.call_args + assert call_args[0][0] == "Cli.Pack.Start.URT" + properties = call_args[0][1] + assert properties["Command"] == "pack" + assert properties["Source"] == "uipath-python-cli" + assert "EventId" in properties + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_start_stores_start_time_and_event_id(self, mock_track_event): + """Test that command start time and event ID are stored.""" + tracker = CliTelemetryTracker() + + tracker.track_command_start("publish") + + assert "publish" in tracker._start_times + assert "publish" in tracker._event_ids + # Event ID should be a valid UUID format + import uuid + + uuid.UUID(tracker._event_ids["publish"]) # Raises if invalid + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_start_handles_exception(self, mock_track_event): + """Test that exceptions in tracking are caught.""" + mock_track_event.side_effect = Exception("Track failed") + tracker = CliTelemetryTracker() + + # Should not raise exception + tracker.track_command_start("pack") + + +class TestCliTelemetryTrackerTrackCommandEnd: + """Test command end tracking.""" + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_end_tracks_event(self, mock_track_event): + """Test that command end event is tracked.""" + tracker = CliTelemetryTracker() + tracker._start_times["pack"] = 1000.0 + tracker._event_ids["pack"] = "test-event-id-123" + + with patch("time.time", return_value=1002.0): + tracker.track_command_end("pack") + + mock_track_event.assert_called_once() + call_args = mock_track_event.call_args + assert call_args[0][0] == "Cli.Pack.End.URT" + properties = call_args[0][1] + assert properties["Command"] == "pack" + assert properties["Success"] is True + assert properties["DurationMs"] == 2000 + assert properties["EventId"] == "test-event-id-123" + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_end_with_explicit_duration(self, mock_track_event): + """Test that explicit duration is used when provided.""" + tracker = CliTelemetryTracker() + + tracker.track_command_end("publish", duration_ms=1500) + + properties = mock_track_event.call_args[0][1] + assert properties["DurationMs"] == 1500 + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_end_handles_exception(self, mock_track_event): + """Test that exceptions in tracking are caught.""" + mock_track_event.side_effect = Exception("Track failed") + tracker = CliTelemetryTracker() + + # Should not raise exception + tracker.track_command_end("pack") + + +class TestCliTelemetryTrackerTrackCommandFailed: + """Test command failed tracking.""" + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_failed_tracks_event(self, mock_track_event): + """Test that command failed event is tracked.""" + tracker = CliTelemetryTracker() + tracker._start_times["run"] = 1000.0 + tracker._event_ids["run"] = "test-event-id-456" + exception = ValueError("Test error message") + + with patch("time.time", return_value=1003.0): + tracker.track_command_failed("run", exception=exception) + + mock_track_event.assert_called_once() + call_args = mock_track_event.call_args + assert call_args[0][0] == "Cli.Run.Failed.URT" + properties = call_args[0][1] + assert properties["Command"] == "run" + assert properties["Success"] is False + assert properties["DurationMs"] == 3000 + assert properties["ErrorType"] == "ValueError" + assert "Test error message" in properties["ErrorMessage"] + assert properties["EventId"] == "test-event-id-456" + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_failed_truncates_long_error_messages(self, mock_track_event): + """Test that error messages are truncated to 500 characters.""" + tracker = CliTelemetryTracker() + long_message = "x" * 1000 + exception = ValueError(long_message) + + tracker.track_command_failed("run", exception=exception) + + properties = mock_track_event.call_args[0][1] + assert len(properties["ErrorMessage"]) == 500 + + @patch("uipath._cli._telemetry.track_cli_event") + def test_track_command_failed_handles_exception(self, mock_track_event): + """Test that exceptions in tracking are caught.""" + mock_track_event.side_effect = Exception("Track failed") + tracker = CliTelemetryTracker() + + # Should not raise exception + tracker.track_command_failed("run", exception=ValueError("Error")) + + +class TestTrackCliCommandDecorator: + """Test the track_command decorator.""" + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_tracks_start_event(self, mock_enabled, mock_track_event): + """Test that decorator tracks start event.""" + + @track_command("pack") + def my_command(): + return "result" + + my_command() + + # Should have at least 2 calls: Start and End + assert mock_track_event.call_count >= 2 + + # First call should be Start + first_call = mock_track_event.call_args_list[0] + assert first_call[0][0] == "Cli.Pack.Start.URT" + assert first_call[0][1]["Command"] == "pack" + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_tracks_end_event_on_success( + self, mock_enabled, mock_track_event + ): + """Test that decorator tracks end event on successful completion.""" + + @track_command("publish") + def my_command(): + return "result" + + result = my_command() + + assert result == "result" + + # Second call should be End + second_call = mock_track_event.call_args_list[1] + assert second_call[0][0] == "Cli.Publish.End.URT" + properties = second_call[0][1] + assert properties["Command"] == "publish" + assert properties["Success"] is True + assert "DurationMs" in properties + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_tracks_failed_event_on_exception( + self, mock_enabled, mock_track_event + ): + """Test that decorator tracks failed event when exception is raised.""" + + @track_command("run") + def my_command(): + raise ValueError("Test error message") + + with pytest.raises(ValueError, match="Test error message"): + my_command() + + # Second call should be Failed + second_call = mock_track_event.call_args_list[1] + assert second_call[0][0] == "Cli.Run.Failed.URT" + properties = second_call[0][1] + assert properties["Command"] == "run" + assert properties["Success"] is False + assert properties["ErrorType"] == "ValueError" + assert "Test error message" in properties["ErrorMessage"] + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=False) + def test_decorator_skips_telemetry_when_disabled( + self, mock_enabled, mock_track_event + ): + """Test that decorator skips telemetry when disabled.""" + + @track_command("pack") + def my_command(): + return "result" + + result = my_command() + + assert result == "result" + mock_track_event.assert_not_called() + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_preserves_function_arguments( + self, mock_enabled, mock_track_event + ): + """Test that decorator preserves function arguments.""" + + @track_command("invoke") + def my_command(arg1, arg2, kwarg1=None): + return f"{arg1}-{arg2}-{kwarg1}" + + result = my_command("a", "b", kwarg1="c") + + assert result == "a-b-c" + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_calculates_duration(self, mock_enabled, mock_track_event): + """Test that decorator calculates duration in milliseconds.""" + import time + + @track_command("pack") + def my_command(): + time.sleep(0.1) # Sleep 100ms + return "result" + + my_command() + + second_call = mock_track_event.call_args_list[1] + properties = second_call[0][1] + # Duration should be at least 100ms + assert properties["DurationMs"] >= 100 + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_decorator_uses_consistent_event_id(self, mock_enabled, mock_track_event): + """Test that decorator uses the same EventId for Start and End events.""" + + @track_command("pack") + def my_command(): + return "result" + + my_command() + + # Get EventId from Start event + start_call = mock_track_event.call_args_list[0] + start_event_id = start_call[0][1]["EventId"] + + # Get EventId from End event + end_call = mock_track_event.call_args_list[1] + end_event_id = end_call[0][1]["EventId"] + + # They should be the same + assert start_event_id == end_event_id + # And should be a valid UUID + import uuid + + uuid.UUID(start_event_id) + + +class TestExceptionHandling: + """Test that telemetry never breaks the main CLI.""" + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_track_event_exception_does_not_break_command( + self, mock_enabled, mock_track_event + ): + """Test that exceptions in track_event don't break the command.""" + mock_track_event.side_effect = Exception("Telemetry failed") + + @track_command("pack") + def my_command(): + return "result" + + # Should not raise exception + result = my_command() + assert result == "result" + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_track_event_exception_still_allows_command_exception_to_propagate( + self, mock_enabled, mock_track_event + ): + """Test that command exceptions propagate even when telemetry fails.""" + # First call succeeds (Start), second call fails (End) + mock_track_event.side_effect = [None, Exception("Telemetry failed")] + + @track_command("run") + def my_command(): + raise ValueError("Command error") + + # Command exception should still propagate + with pytest.raises(ValueError, match="Command error"): + my_command() diff --git a/uv.lock b/uv.lock index b3289b67b..88710ccf2 100644 --- a/uv.lock +++ b/uv.lock @@ -2491,7 +2491,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.6.22" +version = "2.6.23" source = { editable = "." } dependencies = [ { name = "applicationinsights" },