diff --git a/.github/workflows/build-package.yml b/.github/workflows/build-package.yml index 939c0eb10..7234334d9 100644 --- a/.github/workflows/build-package.yml +++ b/.github/workflows/build-package.yml @@ -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: @@ -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 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 49b554219..3bc6a7c5c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -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 diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index a24503feb..9d2f1d121 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -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 diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 2f1b6a7c8..8ec53ff03 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/constants/__init__.py b/packages/uipath-platform/src/uipath/platform/constants/__init__.py index 652017075..2e73b7eca 100644 --- a/packages/uipath-platform/src/uipath/platform/constants/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/constants/__init__.py @@ -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" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 931b1cd38..8d8f014f7 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-08-25T00:14:17.8766896Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.24" +version = "0.2.25" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index f5ca8f457..05594e827 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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", diff --git a/packages/uipath/src/uipath/_cli/_run_telemetry.py b/packages/uipath/src/uipath/_cli/_run_telemetry.py new file mode 100644 index 000000000..7b33fbd0f --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_run_telemetry.py @@ -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. + """ + 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) diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 7d7eceba2..fc2372f0d 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -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 @@ -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 @@ -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, @@ -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: diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 9d12a86c3..ffd89be9c 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -36,6 +36,7 @@ from ._errors import EntrypointDiscoveryException 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 @@ -221,6 +222,7 @@ async def execute() -> None: chat_runtime: UiPathRuntimeProtocol | None = None factory: UiPathRuntimeFactoryProtocol | None = None governance_bootstrap: GovernanceBootstrap | None = None + run_telemetry: RunTelemetry | None = None try: factory = UiPathRuntimeFactoryRegistry.get(context=ctx) @@ -248,6 +250,14 @@ async def execute() -> None: if factory_settings else None ) + run_telemetry = RunTelemetry.start( + agent_type=agent_type, + agent_framework=agent_framework, + entrypoint=resolved_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, @@ -315,6 +325,13 @@ async def execute() -> None: ) else: ctx.result = await debug_runtime(ctx, 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 chat_runtime: diff --git a/packages/uipath/src/uipath/telemetry/_constants.py b/packages/uipath/src/uipath/telemetry/_constants.py index 7a9104deb..fd9098def 100644 --- a/packages/uipath/src/uipath/telemetry/_constants.py +++ b/packages/uipath/src/uipath/telemetry/_constants.py @@ -1,4 +1,6 @@ -_CONNECTION_STRING = "$CONNECTION_STRING" +_CONNECTION_STRING_ALPHA = "$CONNECTION_STRING_ALPHA" +_CONNECTION_STRING_STAGING = "$CONNECTION_STRING_STAGING" +_CONNECTION_STRING_PROD = "$CONNECTION_STRING_PROD" PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG = "EnablePeriodicTelemetryFlush" diff --git a/packages/uipath/src/uipath/telemetry/_environment.py b/packages/uipath/src/uipath/telemetry/_environment.py new file mode 100644 index 000000000..25084c286 --- /dev/null +++ b/packages/uipath/src/uipath/telemetry/_environment.py @@ -0,0 +1,43 @@ +"""Resolve which UiPath environment a run reports telemetry to. + +Derived from the base URL ``uipath auth`` writes to ``.env``, so authenticating +against alpha reports to alpha. An unrecognized host falls back to production, +which covers unauthenticated runs, Automation Suite and custom domains. +""" + +import os +from typing import Literal +from urllib.parse import urlparse + +from uipath.platform.constants import ENV_BASE_URL + +UiPathEnvironment = Literal["alpha", "staging", "cloud"] + +DEFAULT_ENVIRONMENT: UiPathEnvironment = "cloud" + +_ENVIRONMENT_BY_DOMAIN: dict[str, UiPathEnvironment] = { + "alpha.uipath.com": "alpha", + "staging.uipath.com": "staging", + "cloud.uipath.com": "cloud", +} + + +def environment_from_base_url(base_url: str | None) -> UiPathEnvironment: + """Map a UiPath base URL to its environment, defaulting to production.""" + if not base_url: + return DEFAULT_ENVIRONMENT + + try: + hostname = urlparse(base_url).hostname + except ValueError: + return DEFAULT_ENVIRONMENT + + if not hostname: + return DEFAULT_ENVIRONMENT + + return _ENVIRONMENT_BY_DOMAIN.get(hostname, DEFAULT_ENVIRONMENT) + + +def resolve_environment() -> UiPathEnvironment: + """Resolve the environment from the ambient ``UIPATH_URL``.""" + return environment_from_base_url(os.getenv(ENV_BASE_URL)) diff --git a/packages/uipath/src/uipath/telemetry/_track.py b/packages/uipath/src/uipath/telemetry/_track.py index 8d09b69fc..b2be4f90d 100644 --- a/packages/uipath/src/uipath/telemetry/_track.py +++ b/packages/uipath/src/uipath/telemetry/_track.py @@ -28,7 +28,9 @@ _CODE_FILEPATH, _CODE_FUNCTION, _CODE_LINENO, - _CONNECTION_STRING, + _CONNECTION_STRING_ALPHA, + _CONNECTION_STRING_PROD, + _CONNECTION_STRING_STAGING, _OTEL_RESOURCE_ATTRIBUTES, _PROJECT_KEY, _SDK_VERSION, @@ -36,6 +38,7 @@ _UNKNOWN, PERIODIC_TELEMETRY_FLUSH_FEATURE_FLAG, ) +from ._environment import UiPathEnvironment, resolve_environment # Try to import Application Insights client for custom events # Note: applicationinsights is not typed, as it was deprecated in favor of the @@ -96,18 +99,61 @@ def _parse_connection_string( _PERIODIC_TELEMETRY_FLUSH_INTERVAL_SECONDS = 5.0 +def _substituted(value: str, placeholder: str) -> str | None: + """Return ``value`` unless the build left it unset. + + An untouched constant still holds its ``$NAME`` marker; one whose secret was + missing is substituted to an empty string. Neither is usable. + """ + if value and value != placeholder: + return value + return None + + +def _connection_string_slot(environment: UiPathEnvironment) -> tuple[str, str, str]: + """Return the ``(env var, baked constant, placeholder)`` for an environment. + + Rebuilt per call so the constants stay patchable in tests. + """ + slots: dict[UiPathEnvironment, tuple[str, str, str]] = { + "alpha": ( + "TELEMETRY_CONNECTION_STRING_ALPHA", + _CONNECTION_STRING_ALPHA, + "$CONNECTION_STRING_ALPHA", + ), + "staging": ( + "TELEMETRY_CONNECTION_STRING_STAGING", + _CONNECTION_STRING_STAGING, + "$CONNECTION_STRING_STAGING", + ), + "cloud": ( + "TELEMETRY_CONNECTION_STRING_PROD", + _CONNECTION_STRING_PROD, + "$CONNECTION_STRING_PROD", + ), + } + return slots[environment] + + def _get_connection_string() -> str | None: - """Get the Application Insights connection string. + """Get the Application Insights connection string for this run. - Checks the TELEMETRY_CONNECTION_STRING env var first, then falls back - to the _CONNECTION_STRING constant. + ``TELEMETRY_CONNECTION_STRING`` overrides everything. Otherwise the + environment of ``UIPATH_URL`` selects between its per-environment env var and + its baked constant. An environment with neither is left unconfigured, so + telemetry is never reported to another environment's instance. """ 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 + + env_var, baked, placeholder = _connection_string_slot(resolve_environment()) + + override = os.getenv(env_var) + if override: + return override + + return _substituted(baked, placeholder) def _get_project_key() -> str: diff --git a/packages/uipath/tests/cli/test_run_telemetry.py b/packages/uipath/tests/cli/test_run_telemetry.py new file mode 100644 index 000000000..bdd7a1260 --- /dev/null +++ b/packages/uipath/tests/cli/test_run_telemetry.py @@ -0,0 +1,333 @@ +"""Tests for coded run lifecycle telemetry.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from uipath._cli._run_telemetry import RunTelemetry +from uipath.runtime.errors import ( + UiPathErrorCategory, + UiPathErrorCode, + UiPathErrorContract, + UiPathRuntimeError, +) +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus + +FOLDER_KEY = "ce7d8971-90ec-4f94-beb5-127d1e05f7b1" +JOB_KEY = "8d1c0b2e-1111-4a2b-9c3d-4e5f60718293" + +SCOPE_KEYS = { + "FolderKey", + "JobKey", + "JobId", + "CloudOrganizationId", + "CloudTenantId", + "CloudUserId", + "ProjectId", + "ProjectKey", + "ProcessName", + "ProcessUuid", + "ProcessVersion", + "TraceId", +} + + +@pytest.fixture(autouse=True) +def cloud_job_env(monkeypatch): + monkeypatch.setenv("UIPATH_FOLDER_KEY", FOLDER_KEY) + monkeypatch.setenv("UIPATH_JOB_KEY", JOB_KEY) + monkeypatch.setenv("UIPATH_JOB_ID", "421") + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_TENANT_ID", "tenant-1") + monkeypatch.setenv("UIPATH_PROJECT_ID", "project-1") + monkeypatch.setenv("PROJECT_KEY", "project-key-1") + monkeypatch.setenv("UIPATH_PROCESS_KEY", "Invoice Triage Agent") + monkeypatch.setenv("UIPATH_PROCESS_UUID", "process-1") + monkeypatch.setenv("UIPATH_PROCESS_VERSION", "1.2.3") + monkeypatch.setenv("UIPATH_TRACE_ID", "0123456789abcdef0123456789abcdef") + monkeypatch.setenv("UIPATH_TELEMETRY_ENABLED", "true") + + +@pytest.fixture +def emitted(): + events: list[tuple[str, dict[str, Any]]] = [] + + def record(name: str, properties: dict[str, Any] | None = None) -> None: + events.append((name, properties or {})) + + with patch("uipath._cli._run_telemetry.track_event", side_effect=record): + yield events + + +def start( + agent_type: str | None = "uipath_coded", + agent_framework: str | None = "langchain", + **kwargs: Any, +) -> RunTelemetry | None: + return RunTelemetry.start( + agent_type=agent_type, + agent_framework=agent_framework, + entrypoint=kwargs.pop("entrypoint", "main"), + execution_source=kwargs.pop("execution_source", "runtime"), + is_conversational=kwargs.pop("is_conversational", False), + ) + + +def started() -> RunTelemetry: + handle = start() + assert handle is not None + return handle + + +def names(events: list[tuple[str, dict[str, Any]]]) -> list[str]: + return [name for name, _ in events] + + +def successful() -> UiPathRuntimeResult: + return UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL) + + +class TestClassification: + def test_python_framework_is_a_function_run(self, emitted): + start(agent_type="uipath_coded", agent_framework="python") + + assert names(emitted) == ["CodedFunctionRun.Start"] + + def test_non_python_framework_is_a_coded_agent_run(self, emitted): + start(agent_type="uipath_coded", agent_framework="langchain") + + assert names(emitted) == ["CodedAgentRun.Start"] + + def test_lowcode_is_skipped_because_the_agents_package_emits_its_own(self, emitted): + handle = start(agent_type="uipath_lowcode", agent_framework="langchain") + + assert handle is None + assert emitted == [] + + def test_factory_without_settings_defaults_to_coded_agent(self, emitted): + start(agent_type=None, agent_framework=None) + + assert names(emitted) == ["CodedAgentRun.Start"] + + +class TestScopeBlock: + def test_scope_is_present_on_start_and_terminal_events(self, emitted): + started().finished(successful()) + + assert len(emitted) == 2 + for name, properties in emitted: + missing = SCOPE_KEYS - properties.keys() + assert not missing, f"{name} is missing {sorted(missing)}" + + def test_scope_is_present_on_failed_events(self, emitted): + started().failed(RuntimeError("boom")) + + _, properties = emitted[-1] + missing = SCOPE_KEYS - properties.keys() + assert not missing, f"failed event is missing {sorted(missing)}" + + def test_scope_carries_the_real_values(self, emitted): + start() + + _, properties = emitted[0] + assert properties["FolderKey"] == FOLDER_KEY + assert properties["JobKey"] == JOB_KEY + assert properties["ProjectKey"] == "project-key-1" + assert properties["ProjectId"] == "project-1" + assert properties["ProcessName"] == "Invoice Triage Agent" + + def test_absent_scope_values_are_omitted_not_blank(self, emitted, monkeypatch): + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + + start() + + _, properties = emitted[0] + assert "FolderKey" not in properties + + +class TestUnauthenticated: + def test_required_identity_falls_back_to_the_schema_sentinel( + self, emitted, monkeypatch + ): + for var in ( + "UIPATH_ORGANIZATION_ID", + "UIPATH_TENANT_ID", + "UIPATH_CLOUD_USER_ID", + "UIPATH_ACCESS_TOKEN", + ): + monkeypatch.delenv(var, raising=False) + + start() + + _, properties = emitted[0] + assert properties["CloudOrganizationId"] == "N/A" + assert properties["CloudTenantId"] == "N/A" + assert properties["CloudUserId"] == "N/A" + + def test_optional_scope_is_still_omitted_when_absent(self, emitted, monkeypatch): + monkeypatch.delenv("UIPATH_FOLDER_KEY", raising=False) + monkeypatch.delenv("UIPATH_PROCESS_KEY", raising=False) + + start() + + _, properties = emitted[0] + assert "FolderKey" not in properties + assert "ProcessName" not in properties + + +class TestTerminalStatus: + def test_successful_result_ends_the_run(self, emitted): + started().finished(successful()) + + name, properties = emitted[-1] + assert name == "CodedAgentRun.End" + assert properties["Status"] == "Completed" + + def test_faulted_result_without_an_exception_is_still_a_failure(self, emitted): + started().finished( + UiPathRuntimeResult( + status=UiPathRuntimeStatus.FAULTED, + error=UiPathErrorContract( + code="Python.Boom", + title="It broke", + detail="stack and user data here", + category=UiPathErrorCategory.USER, + ), + ) + ) + + name, properties = emitted[-1] + assert name == "CodedAgentRun.Failed" + assert properties["Status"] == "Failed" + assert properties["ErrorCode"] == "Python.Boom" + assert properties["ErrorCategory"] == "User" + + def test_faulted_result_does_not_borrow_the_error_code_as_error_type(self, emitted): + started().finished( + UiPathRuntimeResult( + status=UiPathRuntimeStatus.FAULTED, + error=UiPathErrorContract( + code="Python.Boom", + title="It broke", + detail="d", + category=UiPathErrorCategory.USER, + ), + ) + ) + + _, properties = emitted[-1] + assert properties["ErrorCode"] == "Python.Boom" + assert "ErrorType" not in properties + + def test_faulted_result_without_a_contract_invents_nothing(self, emitted): + started().finished(UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED)) + + _, properties = emitted[-1] + assert properties["Status"] == "Failed" + assert "ErrorType" not in properties + assert "ErrorCode" not in properties + + def test_suspended_result_is_waiting_not_failing(self, emitted): + started().finished(UiPathRuntimeResult(status=UiPathRuntimeStatus.SUSPENDED)) + + name, properties = emitted[-1] + assert name == "CodedAgentRun.End" + assert properties["Status"] == "Suspended" + + def test_only_terminal_events_carry_a_duration(self, emitted): + started().finished(successful()) + + _, start_props = emitted[0] + _, end_props = emitted[-1] + assert "DurationMs" not in start_props + assert end_props["DurationMs"] >= 0 + + def test_duration_is_opt_in_not_inferred_from_the_suffix(self, emitted): + handle = started() + handle._emit("Interrupted", {}) + + _, properties = emitted[-1] + assert "DurationMs" not in properties + + +class TestTraceCorrelation: + def test_a_dashed_uuid_trace_id_is_normalized_to_hex(self, emitted, monkeypatch): + monkeypatch.setenv("UIPATH_TRACE_ID", "a1b2c3d4-e5f6-4788-9a0b-1c2d3e4f5a6b") + + start() + + _, properties = emitted[0] + assert properties["TraceId"] == "a1b2c3d4e5f647889a0b1c2d3e4f5a6b" + + +class TestRunId: + def test_a_job_run_is_identified_by_its_job_key(self, emitted): + started().finished(successful()) + + assert {p["AgentRunId"] for _, p in emitted} == {JOB_KEY} + + def test_a_local_run_still_gets_one_shared_id(self, emitted, monkeypatch): + monkeypatch.delenv("UIPATH_JOB_KEY", raising=False) + + started().finished(successful()) + + run_ids = {p["AgentRunId"] for _, p in emitted} + assert len(run_ids) == 1 + assert run_ids != {JOB_KEY} + + +class TestRaisedErrors: + def test_runtime_error_contributes_its_contract(self, emitted): + started().failed( + UiPathRuntimeError( + code=UiPathErrorCode.EXECUTION_ERROR, + title="Entrypoint blew up", + detail="secret customer value", + category=UiPathErrorCategory.USER, + include_traceback=False, + ) + ) + + name, properties = emitted[-1] + assert name == "CodedAgentRun.Failed" + assert properties["ErrorTitle"] == "Entrypoint blew up" + assert properties["ErrorCategory"] == "User" + assert properties["ErrorType"] == "UiPathRuntimeError" + + def test_plain_exception_still_reports_a_failure(self, emitted): + started().failed(ValueError("bad input")) + + name, properties = emitted[-1] + assert name == "CodedAgentRun.Failed" + assert properties["Status"] == "Failed" + assert properties["ErrorType"] == "ValueError" + + def test_customer_content_is_never_shipped(self, emitted): + started().failed(ValueError("account ACCT-QX7742 balance 99.99")) + + _, properties = emitted[-1] + assert "ACCT-QX7742" not in repr(properties) + assert "ErrorMessage" not in properties + assert "ErrorTraceback" not in properties + + +class TestSafety: + def test_disabled_telemetry_emits_nothing(self, emitted, monkeypatch): + monkeypatch.setenv("UIPATH_TELEMETRY_ENABLED", "false") + + handle = start() + + assert handle is None + assert emitted == [] + + def test_a_broken_backend_does_not_break_the_run(self): + with patch( + "uipath._cli._run_telemetry.track_event", + side_effect=RuntimeError("app insights is down"), + ): + handle = start() + + assert handle is not None + handle.finished(successful()) + handle.failed(ValueError("boom")) diff --git a/packages/uipath/tests/telemetry/test_connection_string.py b/packages/uipath/tests/telemetry/test_connection_string.py new file mode 100644 index 000000000..5400fc040 --- /dev/null +++ b/packages/uipath/tests/telemetry/test_connection_string.py @@ -0,0 +1,118 @@ +"""Tests for per-environment Application Insights connection string resolution.""" + +from unittest.mock import patch + +import pytest + +from uipath.platform.constants import ENV_BASE_URL +from uipath.telemetry._track import _get_connection_string + +ALPHA_URL = "https://alpha.uipath.com/myOrg/myTenant" +STAGING_URL = "https://staging.uipath.com/myOrg/myTenant" +CLOUD_URL = "https://cloud.uipath.com/myOrg/myTenant" + +_TELEMETRY_ENV_VARS = ( + "TELEMETRY_CONNECTION_STRING", + "TELEMETRY_CONNECTION_STRING_ALPHA", + "TELEMETRY_CONNECTION_STRING_STAGING", + "TELEMETRY_CONNECTION_STRING_PROD", +) + + +@pytest.fixture(autouse=True) +def clear_telemetry_env(monkeypatch): + for name in (*_TELEMETRY_ENV_VARS, ENV_BASE_URL): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture(autouse=True) +def unsubstituted_constants(): + """Default every baked constant to its unsubstituted placeholder.""" + with ( + patch( + "uipath.telemetry._track._CONNECTION_STRING_ALPHA", + "$CONNECTION_STRING_ALPHA", + ), + patch( + "uipath.telemetry._track._CONNECTION_STRING_STAGING", + "$CONNECTION_STRING_STAGING", + ), + patch( + "uipath.telemetry._track._CONNECTION_STRING_PROD", + "$CONNECTION_STRING_PROD", + ), + ): + yield + + +class TestEnvironmentRouting: + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + @patch("uipath.telemetry._track._CONNECTION_STRING_STAGING", "baked-staging") + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "baked-prod") + @pytest.mark.parametrize( + "base_url, expected", + [ + (ALPHA_URL, "baked-alpha"), + (STAGING_URL, "baked-staging"), + (CLOUD_URL, "baked-prod"), + ], + ) + def test_authenticated_run_uses_its_environment( + self, monkeypatch, base_url, expected + ): + monkeypatch.setenv(ENV_BASE_URL, base_url) + + assert _get_connection_string() == expected + + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "baked-prod") + def test_unauthenticated_run_uses_prod(self): + assert _get_connection_string() == "baked-prod" + + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "baked-prod") + def test_unrecognized_host_uses_prod(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, "https://automationsuite.mycorp.example.com") + + assert _get_connection_string() == "baked-prod" + + +class TestOverridePrecedence: + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + def test_bare_override_wins_over_environment_routing(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, ALPHA_URL) + monkeypatch.setenv("TELEMETRY_CONNECTION_STRING", "explicit") + monkeypatch.setenv("TELEMETRY_CONNECTION_STRING_ALPHA", "env-alpha") + + assert _get_connection_string() == "explicit" + + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + def test_per_environment_override_beats_baked_constant(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, ALPHA_URL) + monkeypatch.setenv("TELEMETRY_CONNECTION_STRING_ALPHA", "env-alpha") + + assert _get_connection_string() == "env-alpha" + + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "baked-alpha") + def test_override_for_another_environment_is_ignored(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, ALPHA_URL) + monkeypatch.setenv("TELEMETRY_CONNECTION_STRING_STAGING", "env-staging") + + assert _get_connection_string() == "baked-alpha" + + +class TestUnconfiguredEnvironment: + """A slot the build never populated reports nowhere, not elsewhere.""" + + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "baked-prod") + def test_unsubstituted_slot_does_not_fall_through(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, ALPHA_URL) + + assert _get_connection_string() is None + + @patch("uipath.telemetry._track._CONNECTION_STRING_ALPHA", "") + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "baked-prod") + def test_blank_slot_from_a_missing_secret_does_not_fall_through(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, ALPHA_URL) + + assert _get_connection_string() is None diff --git a/packages/uipath/tests/telemetry/test_environment.py b/packages/uipath/tests/telemetry/test_environment.py new file mode 100644 index 000000000..aee0c8238 --- /dev/null +++ b/packages/uipath/tests/telemetry/test_environment.py @@ -0,0 +1,65 @@ +"""Tests for resolving the UiPath environment from the configured base URL.""" + +import pytest + +from uipath.platform.constants import ENV_BASE_URL +from uipath.telemetry._environment import ( + environment_from_base_url, + resolve_environment, +) + + +class TestEnvironmentFromBaseUrl: + @pytest.mark.parametrize( + "base_url, expected", + [ + ("https://alpha.uipath.com/myOrg/myTenant", "alpha"), + ("https://staging.uipath.com/myOrg/myTenant", "staging"), + ("https://cloud.uipath.com/myOrg/myTenant", "cloud"), + ], + ) + def test_known_hosts_resolve_to_their_environment(self, base_url, expected): + assert environment_from_base_url(base_url) == expected + + def test_subdomain_is_not_a_recognised_host(self): + assert environment_from_base_url("https://tenant.alpha.uipath.com/o") == "cloud" + + def test_host_matching_is_case_insensitive(self): + assert environment_from_base_url("https://ALPHA.UiPath.COM/o") == "alpha" + + @pytest.mark.parametrize( + "base_url", + [ + "https://alpha.uipath.com.evil.com/o", + "https://alpha.uipath.com_evil.com/o", + ], + ) + def test_lookalike_suffix_does_not_match(self, base_url): + assert environment_from_base_url(base_url) == "cloud" + + def test_prefixed_hostname_does_not_match(self): + assert environment_from_base_url("https://notalpha.uipath.com/o") == "cloud" + + @pytest.mark.parametrize( + "base_url", + [ + None, + "https://automationsuite.mycorp.example.com/o", + "alpha.uipath.com/myOrg/myTenant", + "not a url at all", + ], + ) + def test_unresolvable_base_url_falls_back_to_cloud(self, base_url): + assert environment_from_base_url(base_url) == "cloud" + + +class TestResolveEnvironment: + def test_reads_base_url_from_environment(self, monkeypatch): + monkeypatch.setenv(ENV_BASE_URL, "https://alpha.uipath.com/myOrg/myTenant") + + assert resolve_environment() == "alpha" + + def test_unauthenticated_falls_back_to_cloud(self, monkeypatch): + monkeypatch.delenv(ENV_BASE_URL, raising=False) + + assert resolve_environment() == "cloud" diff --git a/packages/uipath/tests/telemetry/test_track.py b/packages/uipath/tests/telemetry/test_track.py index 738c4ea2b..af7c8a616 100644 --- a/packages/uipath/tests/telemetry/test_track.py +++ b/packages/uipath/tests/telemetry/test_track.py @@ -186,7 +186,7 @@ def teardown_method(self): _AppInsightsEventClient._connection_string_provider = None FeatureFlags.reset_flags() - @patch("uipath.telemetry._track._CONNECTION_STRING", "$CONNECTION_STRING") + @patch("uipath.telemetry._track._CONNECTION_STRING_PROD", "$CONNECTION_STRING_PROD") def test_initialize_no_connection_string(self): """Test initialization when no connection string is provided.""" with patch.dict(os.environ, {}, clear=True): @@ -204,13 +204,13 @@ def test_initialize_no_connection_string(self): @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) @patch("uipath.telemetry._track.AppInsightsTelemetryClient") @patch( - "uipath.telemetry._track._CONNECTION_STRING", + "uipath.telemetry._track._CONNECTION_STRING_PROD", "InstrumentationKey=builtin-key;IngestionEndpoint=https://example.com/", ) def test_initialize_falls_back_to_builtin_connection_string( self, mock_client_class, mock_sender_class, mock_queue_class, mock_channel_class ): - """Test initialization uses _CONNECTION_STRING when env var is not set.""" + """Test initialization uses the baked constant when env var is not set.""" mock_client = MagicMock() mock_client_class.return_value = mock_client @@ -399,13 +399,13 @@ def test_connection_string_provider_returning_none_skips_client(self): @patch("uipath.telemetry._track._HAS_APPINSIGHTS", True) @patch("uipath.telemetry._track.AppInsightsTelemetryClient") @patch( - "uipath.telemetry._track._CONNECTION_STRING", + "uipath.telemetry._track._CONNECTION_STRING_PROD", "InstrumentationKey=builtin-key", ) def test_provider_bypasses_builtin_fallback( self, mock_client_class, mock_sender_class, mock_queue_class, mock_channel_class ): - """Test that provider prevents fallback to _CONNECTION_STRING.""" + """Test that provider prevents fallback to the baked constant.""" mock_client = MagicMock() mock_client_class.return_value = mock_client diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a26f9d797..6080d9dae 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-08-25T00:14:27.5403279Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.11" +version = "2.14.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.24" +version = "0.2.25" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },