Skip to content

Commit 2708d1e

Browse files
committed
feat: add telemetry for cli
1 parent c7355ef commit 2708d1e

File tree

8 files changed

+686
-0
lines changed

8 files changed

+686
-0
lines changed

src/uipath/_cli/_telemetry.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import logging
2+
import os
3+
import time
4+
from functools import wraps
5+
from typing import Any, Callable, Dict, Optional
6+
7+
from uipath.telemetry._track import flush_events, is_telemetry_enabled, track_event
8+
9+
logger = logging.getLogger(__name__)
10+
11+
# Telemetry event name templates for Application Insights
12+
CLI_COMMAND_STARTED = "Cli.{command}.Start.URT"
13+
CLI_COMMAND_COMPLETED = "Cli.{command}.End.URT"
14+
CLI_COMMAND_FAILED = "Cli.{command}.Failed.URT"
15+
16+
17+
class CliTelemetryTracker:
18+
"""Tracks CLI command execution and sends telemetry to Application Insights.
19+
20+
This class handles tracking of CLI command lifecycle events:
21+
- Command start events
22+
- Command completion events (success)
23+
- Command failure events (with error details)
24+
"""
25+
26+
def __init__(self) -> None:
27+
self._start_times: Dict[str, float] = {}
28+
29+
@staticmethod
30+
def _get_event_name(command: str, status: str) -> str:
31+
return f"Cli.{command.capitalize()}.{status}.URT"
32+
33+
def _enrich_properties(self, properties: Dict[str, Any]) -> None:
34+
"""Enrich properties with common context information.
35+
36+
Args:
37+
properties: The properties dictionary to enrich.
38+
"""
39+
# Add UiPath context
40+
project_id = os.getenv("UIPATH_PROJECT_ID")
41+
if project_id:
42+
properties["ProjectId"] = project_id
43+
44+
org_id = os.getenv("UIPATH_CLOUD_ORGANIZATION_ID")
45+
if org_id:
46+
properties["CloudOrganizationId"] = org_id
47+
48+
user_id = os.getenv("UIPATH_CLOUD_USER_ID")
49+
if user_id:
50+
properties["CloudUserId"] = user_id
51+
52+
tenant_id = os.getenv("UIPATH_TENANT_ID")
53+
if tenant_id:
54+
properties["TenantId"] = tenant_id
55+
56+
# Add source identifier
57+
properties["Source"] = "uipath-python-cli"
58+
properties["ApplicationName"] = "UiPath.Cli"
59+
60+
def track_command_start(self, command: str) -> None:
61+
try:
62+
self._start_times[command] = time.time()
63+
64+
properties: Dict[str, Any] = {"Command": command}
65+
self._enrich_properties(properties)
66+
67+
track_event(self._get_event_name(command, "Start"), properties)
68+
logger.debug(f"Tracked CLI command started: {command}")
69+
70+
except Exception as e:
71+
logger.debug(f"Error tracking CLI command start: {e}")
72+
73+
def track_command_end(
74+
self,
75+
command: str,
76+
duration_ms: Optional[int] = None,
77+
) -> None:
78+
try:
79+
if duration_ms is None:
80+
start_time = self._start_times.pop(command, None)
81+
if start_time:
82+
duration_ms = int((time.time() - start_time) * 1000)
83+
84+
properties: Dict[str, Any] = {
85+
"Command": command,
86+
"Success": True,
87+
}
88+
89+
if duration_ms is not None:
90+
properties["DurationMs"] = duration_ms
91+
92+
self._enrich_properties(properties)
93+
94+
track_event(self._get_event_name(command, "End"), properties)
95+
logger.debug(f"Tracked CLI command completed: {command}")
96+
97+
except Exception as e:
98+
logger.debug(f"Error tracking CLI command end: {e}")
99+
100+
def track_command_failed(
101+
self,
102+
command: str,
103+
duration_ms: Optional[int] = None,
104+
exception: Optional[Exception] = None,
105+
) -> None:
106+
try:
107+
if duration_ms is None:
108+
start_time = self._start_times.pop(command, None)
109+
if start_time:
110+
duration_ms = int((time.time() - start_time) * 1000)
111+
112+
properties: Dict[str, Any] = {
113+
"Command": command,
114+
"Success": False,
115+
}
116+
117+
if duration_ms is not None:
118+
properties["DurationMs"] = duration_ms
119+
120+
if exception is not None:
121+
properties["ErrorType"] = type(exception).__name__
122+
properties["ErrorMessage"] = str(exception)[:500]
123+
124+
self._enrich_properties(properties)
125+
126+
track_event(self._get_event_name(command, "Failed"), properties)
127+
logger.debug(f"Tracked CLI command failed: {command}")
128+
129+
except Exception as e:
130+
logger.debug(f"Error tracking CLI command failed: {e}")
131+
132+
def flush(self) -> None:
133+
"""Flush any pending telemetry events."""
134+
try:
135+
flush_events()
136+
except Exception as e:
137+
logger.debug(f"Error flushing CLI telemetry events: {e}")
138+
139+
140+
def track_cli_command(command: str) -> Callable[..., Any]:
141+
"""Decorator to track CLI command execution.
142+
143+
Tracks the following events to Application Insights:
144+
- Cli.<Command>.Start.URT - when command begins
145+
- Cli.<Command>.End.URT - on successful completion
146+
- Cli.<Command>.Failed.URT - on exception
147+
148+
Properties tracked include:
149+
- Command: The command name
150+
- Success: Whether the command succeeded
151+
- DurationMs: Execution time in milliseconds
152+
- ErrorType: Exception type name (on failure)
153+
- ErrorMessage: Exception message (on failure, truncated to 500 chars)
154+
- ProjectId, CloudOrganizationId, etc. (if available)
155+
156+
Telemetry failures are silently ignored to ensure CLI execution
157+
is never blocked by telemetry issues.
158+
159+
Args:
160+
command: The CLI command name (e.g., "pack", "publish", "run").
161+
162+
Returns:
163+
A decorator function that wraps the CLI command.
164+
165+
Example:
166+
@click.command()
167+
@track_cli_command("pack")
168+
def pack(root, nolock):
169+
...
170+
"""
171+
172+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
173+
@wraps(func)
174+
def wrapper(*args: Any, **kwargs: Any) -> Any:
175+
if not is_telemetry_enabled():
176+
return func(*args, **kwargs)
177+
178+
tracker = CliTelemetryTracker()
179+
tracker.track_command_start(command)
180+
181+
try:
182+
result = func(*args, **kwargs)
183+
tracker.track_command_end(command)
184+
return result
185+
186+
except Exception as e:
187+
tracker.track_command_failed(command, exception=e)
188+
raise
189+
190+
finally:
191+
tracker.flush()
192+
193+
return wrapper
194+
195+
return decorator

src/uipath/_cli/cli_init.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from .._utils.constants import ENV_TELEMETRY_ENABLED
2929
from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE
30+
from ._telemetry import track_cli_command
3031
from ._utils._console import ConsoleLogger
3132
from .middlewares import Middlewares
3233
from .models.runtime_schema import Bindings
@@ -252,6 +253,7 @@ def _add_graph_to_chart(chart: Chart | Subgraph, graph: UiPathRuntimeGraph) -> N
252253
default=False,
253254
help="Won't override existing .agent files and AGENTS.md file.",
254255
)
256+
@track_cli_command("init")
255257
def init(no_agents_md_override: bool) -> None:
256258
"""Initialize the project."""
257259
with console.spinner("Initializing UiPath project ..."):

src/uipath/_cli/cli_invoke.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import httpx
88

99
from .._utils._ssl_context import get_httpx_client_kwargs
10+
from ._telemetry import track_cli_command
1011
from ._utils._common import get_env_vars
1112
from ._utils._console import ConsoleLogger
1213
from ._utils._folders import get_personal_workspace_info_async
@@ -43,6 +44,7 @@ def _read_project_details() -> tuple[str, str]:
4344
type=click.Path(exists=True),
4445
help="File path for the .json input",
4546
)
47+
@track_cli_command("invoke")
4648
def invoke(entrypoint: str | None, input: str | None, file: str | None) -> None:
4749
"""Invoke an agent published in my workspace."""
4850
if file:

src/uipath/_cli/cli_new.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import click
66

7+
from ._telemetry import track_cli_command
78
from ._utils._console import ConsoleLogger
89
from .middlewares import Middlewares
910

@@ -46,6 +47,7 @@ def generate_uipath_json(target_directory):
4647

4748
@click.command()
4849
@click.argument("name", type=str, default="")
50+
@track_cli_command("new")
4951
def new(name: str):
5052
"""Generate a quick-start project."""
5153
directory = os.getcwd()

src/uipath/_cli/cli_pack.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from uipath.platform.common import UiPathConfig
1515

1616
from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE
17+
from ._telemetry import track_cli_command
1718
from ._utils._console import ConsoleLogger
1819
from ._utils._project_files import (
1920
ensure_config_file,
@@ -336,6 +337,7 @@ def display_project_info(config):
336337
is_flag=True,
337338
help="Skip running uv lock and exclude uv.lock from the package",
338339
)
340+
@track_cli_command("pack")
339341
def pack(root, nolock):
340342
"""Pack the project."""
341343
version = get_project_version(root)

src/uipath/_cli/cli_publish.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import httpx
77

88
from .._utils._ssl_context import get_httpx_client_kwargs
9+
from ._telemetry import track_cli_command
910
from ._utils._common import get_env_vars
1011
from ._utils._console import ConsoleLogger
1112
from ._utils._folders import get_personal_workspace_info_async
@@ -118,6 +119,7 @@ def find_feed_by_folder_name(
118119
type=str,
119120
help="Folder name to publish to (skips interactive selection)",
120121
)
122+
@track_cli_command("publish")
121123
def publish(feed, folder):
122124
"""Publish the package."""
123125
[base_url, token] = get_env_vars()

src/uipath/_cli/cli_run.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from uipath._utils._bindings import ResourceOverwritesContext
2424
from uipath.tracing import JsonLinesFileExporter, LlmOpsHttpExporter
2525

26+
from ._telemetry import track_cli_command
2627
from ._utils._console import ConsoleLogger
2728
from .middlewares import Middlewares
2829

@@ -80,6 +81,7 @@
8081
is_flag=True,
8182
help="Keep the temporary state file even when not resuming and no job id is provided",
8283
)
84+
@track_cli_command("run")
8385
def run(
8486
entrypoint: str | None,
8587
input: str | None,

0 commit comments

Comments
 (0)