Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/build-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ on:
type: boolean
default: false
secrets:
APPLICATIONINSIGHTS_CONNECTION_STRING:
APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA:
required: false
APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING:
required: false
APPLICATIONINSIGHTS_CONNECTION_STRING_PROD:
required: false

env:
Expand Down Expand Up @@ -58,9 +62,11 @@ jobs:
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT
rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile"
envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
envsubst '$CONNECTION_STRING_ALPHA $CONNECTION_STRING_STAGING $CONNECTION_STRING_PROD' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
env:
CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}

- name: Re-lock against PyPI
if: inputs.needs-relock
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,9 @@ jobs:
package: uipath
needs-relock: true
secrets:
APPLICATIONINSIGHTS_CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
APPLICATIONINSIGHTS_CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}

publish-uipath:
name: Publish uipath
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/publish-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ jobs:
trap 'rm -f "$tmpfile"' EXIT

rsync -a --no-whole-file --ignore-existing "$originalfile" "$tmpfile"
envsubst '$CONNECTION_STRING' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
envsubst '$CONNECTION_STRING_ALPHA $CONNECTION_STRING_STAGING $CONNECTION_STRING_PROD' < "$originalfile" > "$tmpfile" && mv "$tmpfile" "$originalfile"
env:
CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
CONNECTION_STRING_ALPHA: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_ALPHA }}
CONNECTION_STRING_STAGING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_STAGING }}
CONNECTION_STRING_PROD: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}

- name: Set development version
shell: pwsh
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.24"
version = "0.2.25"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
ENV_UIPATH_TRACE_ID = "UIPATH_TRACE_ID"
ENV_UIPATH_PROCESS_VERSION = "UIPATH_PROCESS_VERSION"
ENV_UIPATH_CONFIG_PATH = "UIPATH_CONFIG_PATH"
ENV_IMAGE_VERSION = "IMAGE_VERSION"

# Headers
HEADER_FOLDER_KEY = "x-uipath-folderkey"
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "uipath"
version = "2.14.11"
version = "2.14.12"
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"
dependencies = [
"uipath-core>=0.5.30, <0.6.0",
"uipath-runtime>=0.13.1, <0.14.0",
"uipath-platform>=0.2.21, <0.3.0",
"uipath-platform>=0.2.25, <0.3.0",
"click>=8.3.1",
"httpx>=0.28.1",
"pyjwt>=2.10.1",
Expand Down
218 changes: 218 additions & 0 deletions packages/uipath/src/uipath/_cli/_run_telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""Lifecycle events for coded agent and coded function runs.

The coded counterpart to low-code's ``AgentRun.*``. Unlike ``Cli.*`` these are not
suppressed under a job key, so cloud runs report too.
"""

import logging
import os
import time
import uuid
from enum import Enum
from importlib.metadata import version
from typing import Any

from uipath.platform.common import UiPathConfig, resolve_trace_id
from uipath.platform.constants import ENV_IMAGE_VERSION, ENV_JOB_ID
from uipath.runtime.errors import UiPathBaseRuntimeError, UiPathErrorContract
from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus
from uipath.telemetry._track import is_telemetry_enabled, track_event

logger = logging.getLogger(__name__)

APPLICATION_NAME = "UiPath.CodedAgent"

SOURCE = "uipath-python-cli"

NOT_AVAILABLE = "N/A"

_LOWCODE_AGENT_TYPE = "uipath_lowcode"
_FUNCTION_FRAMEWORK = "python"


class RunKind(str, Enum):
"""Event-name prefix per run kind."""

CODED_AGENT = "CodedAgentRun"
CODED_FUNCTION = "CodedFunctionRun"


def classify_run(agent_type: str | None, agent_framework: str | None) -> RunKind | None:
"""Run kind from factory settings, or ``None`` when low-code owns the run.

Both coded kinds report ``agent_type`` ``uipath_coded``, so only the framework
separates them. A factory reporting nothing is assumed to wrap one.
"""
if agent_type and agent_type.strip().lower() == _LOWCODE_AGENT_TYPE:
return None
if agent_framework and agent_framework.strip().lower() == _FUNCTION_FRAMEWORK:
return RunKind.CODED_FUNCTION
return RunKind.CODED_AGENT


def _cloud_user_id() -> str | None:
if configured := UiPathConfig.cloud_user_id:
return configured
try:
from uipath._cli._utils._common import get_claim_from_token

return get_claim_from_token("sub")
except Exception:
return None


def _scope_properties() -> dict[str, Any]:
"""Identity and scope, on every event so each one stands alone.

The cloud identity trio is required, so it reports ``N/A`` rather than being
dropped; optional scope is omitted, so a local run has no folder.
"""
Comment thread
tudormatei1 marked this conversation as resolved.
properties: dict[str, Any] = {
"FolderKey": UiPathConfig.folder_key,
"JobKey": UiPathConfig.job_key,
"JobId": os.getenv(ENV_JOB_ID),
"CloudOrganizationId": UiPathConfig.organization_id or NOT_AVAILABLE,
"CloudTenantId": UiPathConfig.tenant_id or NOT_AVAILABLE,
"CloudUserId": _cloud_user_id() or NOT_AVAILABLE,
"ProjectId": UiPathConfig.project_id,
"ProjectKey": UiPathConfig.project_key,
"ProcessName": UiPathConfig.process_key,
"ProcessUuid": UiPathConfig.process_uuid,
"ProcessVersion": UiPathConfig.process_version,
"TraceId": resolve_trace_id(),
"ImageVersion": os.getenv(ENV_IMAGE_VERSION),
}
return {key: value for key, value in properties.items() if value is not None}


def _error_properties(
error_type: str | None, contract: UiPathErrorContract | None
) -> dict[str, Any]:
"""Error classification only, since message and traceback carry customer source.

``ErrorType`` is the exception class, so it is absent for a faulted result that
never raised rather than borrowing the contract code.
"""
properties: dict[str, Any] = {}
if error_type:
properties["ErrorType"] = error_type
if contract is None:
return properties
properties["ErrorCode"] = contract.code
properties["ErrorTitle"] = contract.title
category = contract.category
properties["ErrorCategory"] = getattr(category, "value", category)
return properties


class RunTelemetry:
"""Emits Start and one terminal event for a single coded run."""

def __init__(
self,
*,
kind: RunKind,
entrypoint: str | None,
execution_source: str | None,
is_conversational: bool,
) -> None:
"""Key the run on its job so a suspend/resume pair stays one run."""
self._kind = kind
self._entrypoint = entrypoint
self._execution_source = execution_source
self._is_conversational = is_conversational
self._run_id = UiPathConfig.job_key or str(uuid.uuid4())
self._started_at = time.monotonic()

@classmethod
def start(
cls,
*,
agent_type: str | None,
agent_framework: str | None,
entrypoint: str | None,
execution_source: str | None = None,
is_conversational: bool = False,
) -> "RunTelemetry | None":
"""Emit Start and return a handle, or ``None`` when not reporting.

``None`` when telemetry is off, or for low-code, which reports itself.
"""
if not is_telemetry_enabled():
return None

kind = classify_run(agent_type, agent_framework)
if kind is None:
return None

instance = cls(
kind=kind,
entrypoint=entrypoint,
execution_source=execution_source,
is_conversational=is_conversational,
)
instance._emit("Start", {})
return instance

def finished(self, result: UiPathRuntimeResult | None) -> None:
"""Terminal event for a run that returned.

Faulted failed without raising; suspended is waiting, not broken.
"""
status = result.status if result else UiPathRuntimeStatus.SUCCESSFUL

if status == UiPathRuntimeStatus.FAULTED:
contract = result.error if result else None
self._emit(
"Failed",
{"Status": "Failed", **_error_properties(None, contract)},
include_duration=True,
)
return

label = "Suspended" if status == UiPathRuntimeStatus.SUSPENDED else "Completed"
self._emit("End", {"Status": label}, include_duration=True)

def failed(self, exception: BaseException) -> None:
"""Terminal event for a run that raised."""
contract = getattr(exception, "error_info", None)
if not isinstance(exception, UiPathBaseRuntimeError):
contract = None
self._emit(
"Failed",
{
"Status": "Failed",
**_error_properties(type(exception).__name__, contract),
},
include_duration=True,
)

def _emit(
self, suffix: str, extra: dict[str, Any], include_duration: bool = False
) -> None:
try:
properties: dict[str, Any] = {
"AgentRunId": self._run_id,
"AgentType": "Coded",
"AgentRunSource": self._execution_source,
"Entrypoint": self._entrypoint,
"IsConversational": self._is_conversational,
"Runtime": "URT",
"ApplicationName": APPLICATION_NAME,
"Source": SOURCE,
"SDKVersion": version("uipath"),
}
properties = {
key: value for key, value in properties.items() if value is not None
}
properties.update(_scope_properties())

if include_duration:
properties["DurationMs"] = int(
(time.monotonic() - self._started_at) * 1000
)

properties.update(extra)
track_event(f"{self._kind.value}.{suffix}", properties)
except Exception:
logger.debug("Failed to emit %s run event", suffix, exc_info=True)
16 changes: 16 additions & 0 deletions packages/uipath/src/uipath/_cli/cli_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from uipath.tracing import LiveTrackingSpanProcessor, LlmOpsHttpExporter

from ._governance_bootstrap import GovernanceBootstrap, resolve_governance
from ._run_telemetry import RunTelemetry
from ._telemetry import track_command
from ._utils._console import ConsoleLogger
from .middlewares import Middlewares
Expand Down Expand Up @@ -154,6 +155,7 @@ async def execute_debug_runtime():
with ExecutionSourceContext(ctx.execution_source), ctx:
factory: UiPathRuntimeFactoryProtocol | None = None
governance_bootstrap: GovernanceBootstrap | None = None
run_telemetry: RunTelemetry | None = None

try:
trigger_poll_interval: float = 5.0
Expand All @@ -173,6 +175,14 @@ async def execute_debug_runtime():
if factory_settings
else None
)
run_telemetry = RunTelemetry.start(
agent_type=agent_type,
agent_framework=agent_framework,
entrypoint=entrypoint,
execution_source=ctx.execution_source,
is_conversational=ctx.conversation_id is not None,
)

governance_bootstrap = await resolve_governance(
agent_framework=agent_framework,
agent_type=agent_type,
Expand Down Expand Up @@ -286,6 +296,12 @@ async def execute_debug_runtime():
)
await execute_debug_runtime()

if run_telemetry is not None:
run_telemetry.finished(ctx.result)
except Exception as e:
if run_telemetry is not None:
run_telemetry.failed(e)
raise
finally:
try:
if governance_bootstrap is not None:
Expand Down
Loading
Loading