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 29b3ca3b6..41f3b26a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.9.1" +version = "2.9.2" 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..7adc54410 --- /dev/null +++ b/src/uipath/_cli/_telemetry.py @@ -0,0 +1,202 @@ +import logging +import os +import time +from functools import wraps +from importlib.metadata import version +from typing import Any, Callable, Dict, Optional + +from uipath._cli._utils._common import get_claim_from_token +from uipath.platform.common import UiPathConfig +from uipath.telemetry._track import ( + _get_project_key, + is_telemetry_enabled, + track_cli_event, +) + +logger = logging.getLogger(__name__) + +# Telemetry event name template for Application Insights +CLI_COMMAND_EVENT = "Cli.{command}" + + +class CliTelemetryTracker: + """Tracks CLI command execution and sends telemetry to Application Insights. + + Sends a single event per command execution at completion with: + - Status: "Completed" or "Failed" + - Success: Boolean indicating success/failure + - Error details (if failed) + """ + + def __init__(self) -> None: + self._start_times: Dict[str, float] = {} + + @staticmethod + def _get_event_name(command: str) -> str: + return f"Cli.{command.capitalize()}" + + def _enrich_properties(self, properties: Dict[str, Any]) -> None: + """Enrich properties with common context information. + + Args: + properties: The properties dictionary to enrich. + """ + # Add UiPath context + project_key = _get_project_key() + if project_key: + properties["AgentId"] = project_key + + # Get organization ID + if UiPathConfig.organization_id: + properties["CloudOrganizationId"] = UiPathConfig.organization_id + + # Get tenant ID + if UiPathConfig.tenant_id: + properties["CloudTenantId"] = UiPathConfig.tenant_id + + # Get CloudUserId from JWT token + try: + cloud_user_id = get_claim_from_token("sub") + if cloud_user_id: + properties["CloudUserId"] = cloud_user_id + except Exception: + pass + + properties["SessionId"] = "nosession" # Placeholder for session ID + + try: + properties["SDKVersion"] = version("uipath") + except Exception: + pass + + properties["IsGithubCI"] = bool(os.getenv("GITHUB_ACTIONS")) + + # Add source identifier + properties["Source"] = "uipath-python-cli" + properties["ApplicationName"] = "UiPath.AgentCli" + + def track_command_start(self, command: str) -> None: + """Record the start time for duration calculation.""" + try: + self._start_times[command] = time.time() + logger.debug(f"Started tracking CLI command: {command}") + + except Exception as e: + logger.debug(f"Error recording CLI command start time: {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) + + properties: Dict[str, Any] = { + "Command": command, + "Status": "Completed", + "Success": True, + } + + if duration_ms is not None: + properties["DurationMs"] = duration_ms + + self._enrich_properties(properties) + + track_cli_event(self._get_event_name(command), 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) + + properties: Dict[str, Any] = { + "Command": command, + "Status": "Failed", + "Success": False, + } + + 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), 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. + + Sends an event (Cli.) to Application Insights at command + completion with the execution outcome. + + Properties tracked include: + - Command: The command name + - Status: Execution outcome ("Completed" or "Failed") + - Success: Whether the command succeeded (true/false) + - DurationMs: Execution time in milliseconds + - ErrorType: Exception type name (on failure) + - ErrorMessage: Exception message (on failure, truncated to 500 chars) + - AgentId: Project key from .uipath/.telemetry.json (GUID) + - Version: Package version (uipath package) + - 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() or UiPathConfig.job_key: + 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 b083005cf..3ddfa21d7 100644 --- a/src/uipath/_cli/cli_add.py +++ b/src/uipath/_cli/cli_add.py @@ -8,6 +8,7 @@ from uipath.eval.constants import EVALS_FOLDER +from ._telemetry import track_command from ._utils._console import ConsoleLogger from ._utils._resources import Resources @@ -85,6 +86,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_debug.py b/src/uipath/_cli/cli_debug.py index 7da1453c7..379806627 100644 --- a/src/uipath/_cli/cli_debug.py +++ b/src/uipath/_cli/cli_debug.py @@ -21,6 +21,7 @@ from uipath.runtime.debug import UiPathDebugProtocol, UiPathDebugRuntime from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter +from ._telemetry import track_command from ._utils._console import ConsoleLogger from .middlewares import Middlewares @@ -62,6 +63,7 @@ default=5678, help="Port for the debug server (default: 5678)", ) +@track_command("debug") def debug( entrypoint: str | None, input: str | None, 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 1ecafd091..62740dc4b 100644 --- a/src/uipath/_cli/cli_dev.py +++ b/src/uipath/_cli/cli_dev.py @@ -9,6 +9,8 @@ from uipath.core.tracing import UiPathTraceManager from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry +from ._telemetry import track_command + console = ConsoleLogger() @@ -44,6 +46,7 @@ def _check_dev_dependency(interface: str) -> None: default=5678, help="Port for the debug server (default: 5678)", ) +@track_command("dev") def dev(interface: str, debug: bool, debug_port: int) -> None: """Launch UiPath Developer Console. diff --git a/src/uipath/_cli/cli_init.py b/src/uipath/_cli/cli_init.py index 6549f8a5f..04fe6288d 100644 --- a/src/uipath/_cli/cli_init.py +++ b/src/uipath/_cli/cli_init.py @@ -31,6 +31,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 @@ -326,6 +327,7 @@ def _display_entrypoint_graphs(entry_point_schemas: list[UiPathRuntimeSchema]) - default=False, help="Won't override existing .agent files and AGENTS.md file.", ) +@track_command("initialize") 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 541ca31d0..974948902 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 78235682f..49dc0e573 100644 --- a/src/uipath/_cli/cli_register.py +++ b/src/uipath/_cli/cli_register.py @@ -7,6 +7,7 @@ register_evaluator, ) +from ._telemetry import track_command from ._utils._console import ConsoleLogger from ._utils._resources import Resources @@ -17,6 +18,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 f700fa61e..6b5152ed4 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/_cli/cli_server.py b/src/uipath/_cli/cli_server.py index 16643a7a3..444b23773 100644 --- a/src/uipath/_cli/cli_server.py +++ b/src/uipath/_cli/cli_server.py @@ -13,6 +13,7 @@ import click from aiohttp import ClientSession, UnixConnector, web +from ._telemetry import track_command from ._utils._console import ConsoleLogger from .cli_debug import debug from .cli_eval import eval @@ -319,6 +320,7 @@ async def start_tcp_server(host: str, port: int) -> None: is_flag=True, help="Force TCP mode even on Unix systems", ) +@track_command("server") def server( client_socket: str | None, server_socket: str | None, diff --git a/src/uipath/telemetry/_track.py b/src/uipath/telemetry/_track.py index f2ecf2cca..d7709f6f7 100644 --- a/src/uipath/telemetry/_track.py +++ b/src/uipath/telemetry/_track.py @@ -1,3 +1,4 @@ +import atexit import json import os from functools import wraps @@ -24,6 +25,7 @@ _CODE_FILEPATH, _CODE_FUNCTION, _CODE_LINENO, + _CONNECTION_STRING, _OTEL_RESOURCE_ATTRIBUTES, _PROJECT_KEY, _SDK_VERSION, @@ -70,6 +72,20 @@ def _parse_connection_string(connection_string: str) -> Optional[str]: _logger.propagate = False +def _get_connection_string() -> str | None: + """Get the Application Insights connection string. + + Checks the TELEMETRY_CONNECTION_STRING env var first, then falls back + to the _CONNECTION_STRING constant. + """ + env_value = os.getenv("TELEMETRY_CONNECTION_STRING") + if env_value: + return env_value + if _CONNECTION_STRING and _CONNECTION_STRING != "$CONNECTION_STRING": + return _CONNECTION_STRING + return None + + def _get_project_key() -> str: """Get project key from telemetry file if present. @@ -129,6 +145,7 @@ class _AppInsightsEventClient: _initialized = False _client: Optional[Any] = None + _atexit_registered = False @staticmethod def _initialize() -> None: @@ -146,7 +163,7 @@ def _initialize() -> None: if not _HAS_APPINSIGHTS: return - connection_string = os.getenv("TELEMETRY_CONNECTION_STRING") + connection_string = _get_connection_string() if not connection_string: return @@ -210,6 +227,13 @@ def flush() -> None: _logger.warning(f"Failed to flush telemetry events: {e}") _logger.debug("Telemetry flush error", exc_info=True) + @staticmethod + def register_atexit_flush() -> None: + """Register an atexit handler to flush events on process exit.""" + if not _AppInsightsEventClient._atexit_registered: + atexit.register(_AppInsightsEventClient.flush) + _AppInsightsEventClient._atexit_registered = True + class _TelemetryClient: """A class to handle telemetry using OpenTelemetry for method tracking.""" @@ -332,6 +356,23 @@ def flush_events() -> None: _AppInsightsEventClient.flush() +def track_cli_event( + name: str, + properties: Optional[Dict[str, Any]] = None, +) -> None: + """Track a CLI event. + + Buffers the event and registers an atexit handler to flush pending events on process exit. + """ + if not _TelemetryClient._is_enabled(): + return + try: + _AppInsightsEventClient.track_event(name, properties) + _AppInsightsEventClient.register_atexit_flush() + 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..19a54d54d --- /dev/null +++ b/tests/cli/test_cli_telemetry.py @@ -0,0 +1,185 @@ +"""Tests for CLI telemetry functionality.""" + +import os +from typing import Any +from unittest.mock import patch + +import pytest + +from uipath._cli._telemetry import ( + CliTelemetryTracker, + track_command, +) + + +class TestGetEventName: + """Test event name generation.""" + + @pytest.mark.parametrize( + "command, expected", + [ + ("pack", "Cli.Pack"), + ("publish", "Cli.Publish"), + ("run", "Cli.Run"), + ("initialize", "Cli.Initialize"), + ], + ) + def test_get_event_name(self, command, expected): + assert CliTelemetryTracker._get_event_name(command) == expected + + +class TestEnrichProperties: + """Test property enrichment with context information.""" + + @patch("uipath._cli._telemetry.version", return_value="1.0.0") + @patch("uipath._cli._telemetry.get_claim_from_token", return_value=None) + @patch("uipath._cli._telemetry._get_project_key", return_value=None) + def test_always_present_fields(self, mock_project_key, mock_claim, mock_version): + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + with patch.dict(os.environ, {}, clear=True): + tracker._enrich_properties(properties) + + assert properties["Source"] == "uipath-python-cli" + assert properties["ApplicationName"] == "UiPath.AgentCli" + assert properties["SessionId"] == "nosession" + assert properties["SDKVersion"] == "1.0.0" + assert properties["IsGithubCI"] is False + + @patch("uipath._cli._telemetry.version", return_value="1.2.3") + @patch("uipath._cli._telemetry.get_claim_from_token", return_value="user-789") + @patch("uipath._cli._telemetry._get_project_key", return_value="project-key-123") + def test_adds_context_when_available( + self, mock_project_key, mock_claim, mock_version + ): + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + with patch.dict( + os.environ, + { + "UIPATH_TENANT_ID": "tenant-abc", + "UIPATH_ORGANIZATION_ID": "org-456", + }, + ): + tracker._enrich_properties(properties) + + assert properties["AgentId"] == "project-key-123" + assert properties["CloudOrganizationId"] == "org-456" + assert properties["CloudTenantId"] == "tenant-abc" + assert properties["CloudUserId"] == "user-789" + + @patch("uipath._cli._telemetry.version", side_effect=Exception("not found")) + @patch("uipath._cli._telemetry.get_claim_from_token", return_value=None) + @patch("uipath._cli._telemetry._get_project_key", return_value=None) + def test_skips_missing_context(self, mock_project_key, mock_claim, mock_version): + tracker = CliTelemetryTracker() + properties: dict[str, Any] = {} + + with patch.dict(os.environ, {}, clear=True): + tracker._enrich_properties(properties) + + assert "AgentId" not in properties + assert "CloudOrganizationId" not in properties + assert "CloudTenantId" not in properties + assert "CloudUserId" not in properties + assert "SDKVersion" not in properties + assert properties["SessionId"] == "nosession" + assert properties["IsGithubCI"] is False + + +class TestTrackCommandDecorator: + """Test the track_command decorator end-to-end.""" + + @patch.object(CliTelemetryTracker, "_enrich_properties") + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_tracks_success(self, mock_enabled, mock_track_event, mock_enrich): + @track_command("publish") + def my_command(): + return "result" + + assert my_command() == "result" + + mock_track_event.assert_called_once() + properties = mock_track_event.call_args[0][1] + assert mock_track_event.call_args[0][0] == "Cli.Publish" + assert properties["Command"] == "publish" + assert properties["Status"] == "Completed" + assert properties["Success"] is True + assert "DurationMs" in properties + + @patch.object(CliTelemetryTracker, "_enrich_properties") + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_tracks_failure(self, mock_enabled, mock_track_event, mock_enrich): + @track_command("run") + def my_command(): + raise ValueError("Test error message") + + with pytest.raises(ValueError, match="Test error message"): + my_command() + + mock_track_event.assert_called_once() + properties = mock_track_event.call_args[0][1] + assert properties["Command"] == "run" + assert properties["Status"] == "Failed" + assert properties["Success"] is False + assert properties["ErrorType"] == "ValueError" + assert "Test error message" in properties["ErrorMessage"] + + @patch.object(CliTelemetryTracker, "_enrich_properties") + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_truncates_long_error_messages( + self, mock_enabled, mock_track_event, mock_enrich + ): + @track_command("run") + def my_command(): + raise ValueError("x" * 1000) + + with pytest.raises(ValueError): + my_command() + + properties = mock_track_event.call_args[0][1] + assert len(properties["ErrorMessage"]) == 500 + + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=False) + def test_skips_when_disabled(self, mock_enabled, mock_track_event): + @track_command("pack") + def my_command(): + return "result" + + assert my_command() == "result" + mock_track_event.assert_not_called() + + @patch.object(CliTelemetryTracker, "_enrich_properties") + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_telemetry_error_does_not_break_command( + self, mock_enabled, mock_track_event, mock_enrich + ): + mock_track_event.side_effect = Exception("Telemetry failed") + + @track_command("pack") + def my_command(): + return "result" + + assert my_command() == "result" + + @patch.object(CliTelemetryTracker, "_enrich_properties") + @patch("uipath._cli._telemetry.track_cli_event") + @patch("uipath._cli._telemetry.is_telemetry_enabled", return_value=True) + def test_command_exception_propagates_when_telemetry_fails( + self, mock_enabled, mock_track_event, mock_enrich + ): + mock_track_event.side_effect = Exception("Telemetry failed") + + @track_command("run") + def my_command(): + raise ValueError("Command error") + + with pytest.raises(ValueError, match="Command error"): + my_command() diff --git a/tests/telemetry/test_track.py b/tests/telemetry/test_track.py index dd7e94093..efe234daa 100644 --- a/tests/telemetry/test_track.py +++ b/tests/telemetry/test_track.py @@ -84,6 +84,7 @@ def teardown_method(self): _AppInsightsEventClient._initialized = False _AppInsightsEventClient._client = None + @patch("uipath.telemetry._track._CONNECTION_STRING", "$CONNECTION_STRING") def test_initialize_no_connection_string(self): """Test initialization when no connection string is provided.""" with patch.dict(os.environ, {}, clear=True): @@ -95,6 +96,28 @@ def test_initialize_no_connection_string(self): assert _AppInsightsEventClient._initialized is True assert _AppInsightsEventClient._client is None + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) + @patch("uipath.telemetry._track.AppInsightsTelemetryClient") + @patch( + "uipath.telemetry._track._CONNECTION_STRING", + "InstrumentationKey=builtin-key;IngestionEndpoint=https://example.com/", + ) + def test_initialize_falls_back_to_builtin_connection_string( + self, mock_client_class + ): + """Test initialization uses _CONNECTION_STRING when env var is not set.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("TELEMETRY_CONNECTION_STRING", None) + + _AppInsightsEventClient._initialize() + + assert _AppInsightsEventClient._initialized is True + assert _AppInsightsEventClient._client is mock_client + mock_client_class.assert_called_once_with("builtin-key") + @patch("uipath.telemetry._track._HAS_APPINSIGHTS", False) def test_initialize_no_appinsights_package(self): """Test initialization when applicationinsights package is not available.""" diff --git a/uv.lock b/uv.lock index 7276aa4ef..f203d4027 100644 --- a/uv.lock +++ b/uv.lock @@ -2531,7 +2531,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.9.1" +version = "2.9.2" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2652,7 +2652,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.0.1" +version = "0.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2661,9 +2661,9 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/39/771b69a8a5857776da47c2e51df928a3977975822bd65b88868296d25c4e/uipath_platform-0.0.1.tar.gz", hash = "sha256:30a531fa9a1ae69fb74096aaab53a1f2b7bc42654d97f2caecacf1d16c0ea3d3", size = 253295, upload-time = "2026-02-23T13:21:48.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/53/cd3293926c89c4a2da477b1cb386b84490905cf7a49a54e7788085932f10/uipath_platform-0.0.2.tar.gz", hash = "sha256:1d59c8be6e1b168cb08449416e25532dc5085e02574bd11d422f6fd9c053a5f5", size = 253303, upload-time = "2026-02-23T16:37:00.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/70/0039692caf10c53f95c359313f7e96362e585684959560b5246d79aaa29b/uipath_platform-0.0.1-py3-none-any.whl", hash = "sha256:60926ec5c9147d89c3b226c1da5f46dce6bc399630185591855aceb72d59aad3", size = 152869, upload-time = "2026-02-23T13:21:47.33Z" }, + { url = "https://files.pythonhosted.org/packages/2e/dd/21f313aff40f54b7e56618676f057779a92922b83d3a9da643d8d79e5d72/uipath_platform-0.0.2-py3-none-any.whl", hash = "sha256:b0b963edf150a0a0951cc25a0aace632ab39e2b73be6e7edeaf7f53bf9718c74", size = 152879, upload-time = "2026-02-23T16:36:58.747Z" }, ] [[package]]