Skip to content

Commit c47a2cf

Browse files
authored
feat(telemetry): emit lifecycle events for coded runs [PC-4888] (#1880)
1 parent 333ce46 commit c47a2cf

18 files changed

Lines changed: 896 additions & 27 deletions

File tree

‎.github/workflows/build-package.yml‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ on:
1313
type: boolean
1414
default: false
1515
secrets:
16-
APPLICATIONINSIGHTS_CONNECTION_STRING:
16+
APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA:
17+
required: false
18+
APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING:
19+
required: false
20+
APPLICATIONINSIGHTS_CONNECTION_STRING_PROD:
1721
required: false
1822

1923
env:
@@ -58,9 +62,11 @@ jobs:
5862
tmpfile=$(mktemp)
5963
trap 'rm -f "$tmpfile"' EXIT
6064
rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile"
61-
envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
65+
envsubst '$CONNECTION_STRING_ALPHA $CONNECTION_STRING_STAGING $CONNECTION_STRING_PROD' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
6266
env:
63-
CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
67+
CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
68+
CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
69+
CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
6470

6571
- name: Re-lock against PyPI
6672
if: inputs.needs-relock

‎.github/workflows/cd.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,9 @@ jobs:
167167
package: uipath
168168
needs-relock: true
169169
secrets:
170-
APPLICATIONINSIGHTS_CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
170+
APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
171+
APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
172+
APPLICATIONINSIGHTS_CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
171173

172174
publish-uipath:
173175
name: Publish uipath

‎.github/workflows/publish-dev.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,11 @@ jobs:
7676
trap 'rm -f "$tmpfile"' EXIT
7777
7878
rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile"
79-
envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
79+
envsubst '$CONNECTION_STRING_ALPHA $CONNECTION_STRING_STAGING $CONNECTION_STRING_PROD' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
8080
env:
81-
CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
81+
CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
82+
CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
83+
CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
8284

8385
- name: Set development version
8486
shell: pwsh

‎packages/uipath-platform/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.2.24"
3+
version = "0.2.25"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

‎packages/uipath-platform/src/uipath/platform/constants/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID"
3636
ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION"
3737
ENV_UIPATH_CONFIG_PATH = "UIPATH_CONFIG_PATH"
38+
ENV_IMAGE_VERSION = "IMAGE_VERSION"
3839

3940
# Headers
4041
HEADER_FOLDER_KEY = "x-uipath-folderkey"

‎packages/uipath-platform/uv.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/uipath/pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath"
3-
version = "2.14.11"
3+
version = "2.14.12"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath-core>=0.5.30, <0.6.0",
99
"uipath-runtime>=0.13.1, <0.14.0",
10-
"uipath-platform>=0.2.21, <0.3.0",
10+
"uipath-platform>=0.2.25, <0.3.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Lifecycle events for coded agent and coded function runs.
2+
3+
The coded counterpart to low-code's ``AgentRun.*``. Unlike ``Cli.*`` these are not
4+
suppressed under a job key, so cloud runs report too.
5+
"""
6+
7+
import logging
8+
import os
9+
import time
10+
import uuid
11+
from enum import Enum
12+
from importlib.metadata import version
13+
from typing import Any
14+
15+
from uipath.platform.common import UiPathConfig, resolve_trace_id
16+
from uipath.platform.constants import ENV_IMAGE_VERSION, ENV_JOB_ID
17+
from uipath.runtime.errors import UiPathBaseRuntimeError, UiPathErrorContract
18+
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
19+
from uipath.telemetry._track import is_telemetry_enabled, track_event
20+
21+
logger = logging.getLogger(__name__)
22+
23+
APPLICATION_NAME = "UiPath.CodedAgent"
24+
25+
SOURCE = "uipath-python-cli"
26+
27+
NOT_AVAILABLE = "N/A"
28+
29+
_LOWCODE_AGENT_TYPE = "uipath_lowcode"
30+
_FUNCTION_FRAMEWORK = "python"
31+
32+
33+
class RunKind(str, Enum):
34+
"""Event-name prefix per run kind."""
35+
36+
CODED_AGENT = "CodedAgentRun"
37+
CODED_FUNCTION = "CodedFunctionRun"
38+
39+
40+
def classify_run(agent_type: str | None, agent_framework: str | None) -> RunKind | None:
41+
"""Run kind from factory settings, or ``None`` when low-code owns the run.
42+
43+
Both coded kinds report ``agent_type`` ``uipath_coded``, so only the framework
44+
separates them. A factory reporting nothing is assumed to wrap one.
45+
"""
46+
if agent_type and agent_type.strip().lower() == _LOWCODE_AGENT_TYPE:
47+
return None
48+
if agent_framework and agent_framework.strip().lower() == _FUNCTION_FRAMEWORK:
49+
return RunKind.CODED_FUNCTION
50+
return RunKind.CODED_AGENT
51+
52+
53+
def _cloud_user_id() -> str | None:
54+
if configured := UiPathConfig.cloud_user_id:
55+
return configured
56+
try:
57+
from uipath._cli._utils._common import get_claim_from_token
58+
59+
return get_claim_from_token("sub")
60+
except Exception:
61+
return None
62+
63+
64+
def _scope_properties() -> dict[str, Any]:
65+
"""Identity and scope, on every event so each one stands alone.
66+
67+
The cloud identity trio is required, so it reports ``N/A`` rather than being
68+
dropped; optional scope is omitted, so a local run has no folder.
69+
"""
70+
properties: dict[str, Any] = {
71+
"FolderKey": UiPathConfig.folder_key,
72+
"JobKey": UiPathConfig.job_key,
73+
"JobId": os.getenv(ENV_JOB_ID),
74+
"CloudOrganizationId": UiPathConfig.organization_id or NOT_AVAILABLE,
75+
"CloudTenantId": UiPathConfig.tenant_id or NOT_AVAILABLE,
76+
"CloudUserId": _cloud_user_id() or NOT_AVAILABLE,
77+
"ProjectId": UiPathConfig.project_id,
78+
"ProjectKey": UiPathConfig.project_key,
79+
"ProcessName": UiPathConfig.process_key,
80+
"ProcessUuid": UiPathConfig.process_uuid,
81+
"ProcessVersion": UiPathConfig.process_version,
82+
"TraceId": resolve_trace_id(),
83+
"ImageVersion": os.getenv(ENV_IMAGE_VERSION),
84+
}
85+
return {key: value for key, value in properties.items() if value is not None}
86+
87+
88+
def _error_properties(
89+
error_type: str | None, contract: UiPathErrorContract | None
90+
) -> dict[str, Any]:
91+
"""Error classification only, since message and traceback carry customer source.
92+
93+
``ErrorType`` is the exception class, so it is absent for a faulted result that
94+
never raised rather than borrowing the contract code.
95+
"""
96+
properties: dict[str, Any] = {}
97+
if error_type:
98+
properties["ErrorType"] = error_type
99+
if contract is None:
100+
return properties
101+
properties["ErrorCode"] = contract.code
102+
properties["ErrorTitle"] = contract.title
103+
category = contract.category
104+
properties["ErrorCategory"] = getattr(category, "value", category)
105+
return properties
106+
107+
108+
class RunTelemetry:
109+
"""Emits Start and one terminal event for a single coded run."""
110+
111+
def __init__(
112+
self,
113+
*,
114+
kind: RunKind,
115+
entrypoint: str | None,
116+
execution_source: str | None,
117+
is_conversational: bool,
118+
) -> None:
119+
"""Key the run on its job so a suspend/resume pair stays one run."""
120+
self._kind = kind
121+
self._entrypoint = entrypoint
122+
self._execution_source = execution_source
123+
self._is_conversational = is_conversational
124+
self._run_id = UiPathConfig.job_key or str(uuid.uuid4())
125+
self._started_at = time.monotonic()
126+
127+
@classmethod
128+
def start(
129+
cls,
130+
*,
131+
agent_type: str | None,
132+
agent_framework: str | None,
133+
entrypoint: str | None,
134+
execution_source: str | None = None,
135+
is_conversational: bool = False,
136+
) -> "RunTelemetry | None":
137+
"""Emit Start and return a handle, or ``None`` when not reporting.
138+
139+
``None`` when telemetry is off, or for low-code, which reports itself.
140+
"""
141+
if not is_telemetry_enabled():
142+
return None
143+
144+
kind = classify_run(agent_type, agent_framework)
145+
if kind is None:
146+
return None
147+
148+
instance = cls(
149+
kind=kind,
150+
entrypoint=entrypoint,
151+
execution_source=execution_source,
152+
is_conversational=is_conversational,
153+
)
154+
instance._emit("Start", {})
155+
return instance
156+
157+
def finished(self, result: UiPathRuntimeResult | None) -> None:
158+
"""Terminal event for a run that returned.
159+
160+
Faulted failed without raising; suspended is waiting, not broken.
161+
"""
162+
status = result.status if result else UiPathRuntimeStatus.SUCCESSFUL
163+
164+
if status == UiPathRuntimeStatus.FAULTED:
165+
contract = result.error if result else None
166+
self._emit(
167+
"Failed",
168+
{"Status": "Failed", **_error_properties(None, contract)},
169+
include_duration=True,
170+
)
171+
return
172+
173+
label = "Suspended" if status == UiPathRuntimeStatus.SUSPENDED else "Completed"
174+
self._emit("End", {"Status": label}, include_duration=True)
175+
176+
def failed(self, exception: BaseException) -> None:
177+
"""Terminal event for a run that raised."""
178+
contract = getattr(exception, "error_info", None)
179+
if not isinstance(exception, UiPathBaseRuntimeError):
180+
contract = None
181+
self._emit(
182+
"Failed",
183+
{
184+
"Status": "Failed",
185+
**_error_properties(type(exception).__name__, contract),
186+
},
187+
include_duration=True,
188+
)
189+
190+
def _emit(
191+
self, suffix: str, extra: dict[str, Any], include_duration: bool = False
192+
) -> None:
193+
try:
194+
properties: dict[str, Any] = {
195+
"AgentRunId": self._run_id,
196+
"AgentType": "Coded",
197+
"AgentRunSource": self._execution_source,
198+
"Entrypoint": self._entrypoint,
199+
"IsConversational": self._is_conversational,
200+
"Runtime": "URT",
201+
"ApplicationName": APPLICATION_NAME,
202+
"Source": SOURCE,
203+
"SDKVersion": version("uipath"),
204+
}
205+
properties = {
206+
key: value for key, value in properties.items() if value is not None
207+
}
208+
properties.update(_scope_properties())
209+
210+
if include_duration:
211+
properties["DurationMs"] = int(
212+
(time.monotonic() - self._started_at) * 1000
213+
)
214+
215+
properties.update(extra)
216+
track_event(f"{self._kind.value}.{suffix}", properties)
217+
except Exception:
218+
logger.debug("Failed to emit %s run event", suffix, exc_info=True)

‎packages/uipath/src/uipath/_cli/cli_debug.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter
3030

3131
from ._governance_bootstrap import GovernanceBootstrap, resolve_governance
32+
from ._run_telemetry import RunTelemetry
3233
from ._telemetry import track_command
3334
from ._utils._console import ConsoleLogger
3435
from .middlewares import Middlewares
@@ -154,6 +155,7 @@ async def execute_debug_runtime():
154155
with ExecutionSourceContext(ctx.execution_source), ctx:
155156
factory: UiPathRuntimeFactoryProtocol | None = None
156157
governance_bootstrap: GovernanceBootstrap | None = None
158+
run_telemetry: RunTelemetry | None = None
157159

158160
try:
159161
trigger_poll_interval: float = 5.0
@@ -173,6 +175,14 @@ async def execute_debug_runtime():
173175
if factory_settings
174176
else None
175177
)
178+
run_telemetry = RunTelemetry.start(
179+
agent_type=agent_type,
180+
agent_framework=agent_framework,
181+
entrypoint=entrypoint,
182+
execution_source=ctx.execution_source,
183+
is_conversational=ctx.conversation_id is not None,
184+
)
185+
176186
governance_bootstrap = await resolve_governance(
177187
agent_framework=agent_framework,
178188
agent_type=agent_type,
@@ -286,6 +296,12 @@ async def execute_debug_runtime():
286296
)
287297
await execute_debug_runtime()
288298

299+
if run_telemetry is not None:
300+
run_telemetry.finished(ctx.result)
301+
except Exception as e:
302+
if run_telemetry is not None:
303+
run_telemetry.failed(e)
304+
raise
289305
finally:
290306
try:
291307
if governance_bootstrap is not None:

0 commit comments

Comments
 (0)