From 678b6ad906f6fb8dd88e375f2915658fb214fda6 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 1 Sep 2026 15:23:08 +0300 Subject: [PATCH 1/7] feat(telemetry): route events to per-environment App Insights The SDK baked a single connection string into the published wheel, so every run reported to the same instance regardless of which environment the user had authenticated against. Alpha and staging runs were landing in whichever instance that one secret happened to point at. The environment now comes from the host of UIPATH_URL, which uipath auth writes to .env, and selects between three baked constants. An unauthenticated run, an Automation Suite deployment or any unrecognized host resolves to production. Host matching is exact-or-subdomain on the parsed hostname so a lookalike domain cannot select another environment's instance. TELEMETRY_CONNECTION_STRING still overrides everything, since the platform injects it per environment for low-code agents. Three per-environment variants are read before the baked constants, which is what makes this testable from a source checkout where the constants are still placeholders. The old single constant is gone rather than kept as a fallback: a missing secret should leave telemetry unconfigured, not silently report production data to alpha. That means the three repo secrets have to exist before the next build, or the wheel ships with telemetry off. --- .github/workflows/build-package.yml | 12 +- .github/workflows/cd.yml | 4 +- .github/workflows/publish-dev.yml | 6 +- .../uipath/src/uipath/telemetry/_constants.py | 4 +- .../src/uipath/telemetry/_environment.py | 52 ++++++++ .../uipath/src/uipath/telemetry/_track.py | 60 +++++++-- .../tests/telemetry/test_connection_string.py | 118 ++++++++++++++++++ .../tests/telemetry/test_environment.py | 65 ++++++++++ packages/uipath/tests/telemetry/test_track.py | 10 +- 9 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 packages/uipath/src/uipath/telemetry/_environment.py create mode 100644 packages/uipath/tests/telemetry/test_connection_string.py create mode 100644 packages/uipath/tests/telemetry/test_environment.py 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/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..4f9a7de9e --- /dev/null +++ b/packages/uipath/src/uipath/telemetry/_environment.py @@ -0,0 +1,52 @@ +"""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 _is_domain_or_subdomain(hostname: str, domain: str) -> bool: + """Whether ``hostname`` is ``domain`` or a dot-separated subdomain of it.""" + return hostname == domain or hostname.endswith(f".{domain}") + + +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 + + for domain, environment in _ENVIRONMENT_BY_DOMAIN.items(): + if _is_domain_or_subdomain(hostname, domain): + return environment + + return 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/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..38e869d3e --- /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_resolves_to_the_parent_environment(self): + assert environment_from_base_url("https://tenant.alpha.uipath.com/o") == "alpha" + + 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 From 3959585821fd61a1b93bf75adaea3bbfeb92d105 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Tue, 1 Sep 2026 17:49:13 +0300 Subject: [PATCH 2/7] feat(telemetry): emit lifecycle events for coded runs Coded runs reported nothing once a job key was present. The only SDK telemetry was Cli.*, which is suppressed for Orchestrator jobs, so a coded agent running in the cloud was invisible in App Insights. CodedAgentRun.* and CodedFunctionRun.* now fire from the run path, mirroring the AgentRun.* contract low-code already emits. The two kinds are separated by the factory's agent_framework, since both report agent_type uipath_coded; low-code is skipped so uipath-agents-python stays its only emitter. Every event carries the folder, job, process, project and trace ids, so a run can be attributed without an Orchestrator lookup. Failed events carry the error code, title and category but not the message or traceback, which for a coded run hold customer source and values. The run is keyed on the job key so a suspend and resume pair stays one run, and TraceId goes through resolve_trace_id because UIPATH_TRACE_ID may be a dashed UUID while spans export as 32-char hex. --- .../uipath/src/uipath/_cli/_run_telemetry.py | 230 ++++++++++++++ packages/uipath/src/uipath/_cli/cli_run.py | 17 + .../uipath/tests/cli/test_run_telemetry.py | 300 ++++++++++++++++++ 3 files changed, 547 insertions(+) create mode 100644 packages/uipath/src/uipath/_cli/_run_telemetry.py create mode 100644 packages/uipath/tests/cli/test_run_telemetry.py 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..a6f5e5e05 --- /dev/null +++ b/packages/uipath/src/uipath/_cli/_run_telemetry.py @@ -0,0 +1,230 @@ +"""Lifecycle events for coded agent and coded function runs. + +The coded counterpart to low-code's ``AgentRun.*``: ``CodedAgentRun.*`` for a +framework-driven run, ``CodedFunctionRun.*`` for a plain Python entrypoint. +Unlike ``Cli.*`` these are not suppressed under a job key, so cloud runs report +too, and every event carries the full scope block so none needs an Orchestrator +lookup to attribute. +""" + +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_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" + +_ENV_IMAGE_VERSION = "IMAGE_VERSION" +_ENV_AS_CLUSTER_ID = "AUTOMATION_SUITE_CLUSTER_ID" +_ENV_AS_CLUSTER_VERSION = "AUTOMATION_SUITE_CLUSTER_VERSION" + + +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. + + ``agent_type`` cannot separate the coded kinds — functions and LangGraph both + report ``uipath_coded``. Only the functions factory reports framework + ``python``; a factory reporting nothing is assumed to wrap a framework. + """ + 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 on every Automation Cloud event, so an + unauthenticated run reports ``N/A`` rather than dropping it. Optional scope + is omitted when absent, so a local run does not look like it has a folder. + + ``UIPATH_PROCESS_KEY`` holds the display name, not an id. ``TraceId`` goes + through :func:`resolve_trace_id` because the env var may be a dashed UUID + while spans export as 32-char hex, and the two have to join. + """ + 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), + "AutomationSuiteClusterId": os.getenv(_ENV_AS_CLUSTER_ID), + "AutomationSuiteClusterVersion": os.getenv(_ENV_AS_CLUSTER_VERSION), + } + return {key: value for key, value in properties.items() if value is not None} + + +def _error_properties( + error_type: str, contract: UiPathErrorContract | None +) -> dict[str, Any]: + """Error classification only — no message, no traceback. + + Both carry customer source and values for a coded run; ``UiPathBaseRuntimeError`` + even appends the traceback to ``detail``. + """ + properties: dict[str, Any] = {"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 this run is not reported. + + ``None`` when telemetry is off, or for low-code, which + uipath-agents-python 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 is a failure the runtime handled rather than raised; suspended is + waiting on a trigger, not broken. + """ + status = result.status if result else UiPathRuntimeStatus.SUCCESSFUL + + if status == UiPathRuntimeStatus.FAULTED: + contract = result.error if result else None + error_type = contract.code if contract else "Unknown" + self._emit( + "Failed", + {"Status": "Failed", **_error_properties(error_type, contract)}, + ) + return + + label = "Suspended" if status == UiPathRuntimeStatus.SUSPENDED else "Completed" + self._emit("End", {"Status": label}) + + 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), + }, + ) + + def _emit(self, suffix: str, extra: dict[str, Any]) -> 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 suffix != "Start": + 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_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/tests/cli/test_run_telemetry.py b/packages/uipath/tests/cli/test_run_telemetry.py new file mode 100644 index 000000000..ef9da293a --- /dev/null +++ b/packages/uipath/tests/cli/test_run_telemetry.py @@ -0,0 +1,300 @@ +"""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", + "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_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 + + +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")) From 86bcec5705d355711fa4c5bed80c9993179d3301 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Thu, 3 Sep 2026 14:22:54 +0300 Subject: [PATCH 3/7] fix(telemetry): keep ErrorType meaning the exception class On a faulted result ErrorType was set to the contract code, so it duplicated ErrorCode and meant something different from the raised path, where it is the Python exception class. Grouping by ErrorType would mix class names with platform codes and undercount both. ErrorType is now the exception class only, and absent when the runtime returned a faulted result rather than raising. ErrorCode, ErrorTitle and ErrorCategory still carry the contract on both paths, so every failure event keeps a classification. Also adds CloudUserId to the scope assertion, which omitted it while asserting the other two cloud ids, and shortens the docstrings. --- .../uipath/src/uipath/_cli/_run_telemetry.py | 46 ++++++++----------- .../uipath/tests/cli/test_run_telemetry.py | 26 +++++++++++ 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/uipath/src/uipath/_cli/_run_telemetry.py b/packages/uipath/src/uipath/_cli/_run_telemetry.py index a6f5e5e05..603c85f48 100644 --- a/packages/uipath/src/uipath/_cli/_run_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_run_telemetry.py @@ -1,10 +1,7 @@ """Lifecycle events for coded agent and coded function runs. -The coded counterpart to low-code's ``AgentRun.*``: ``CodedAgentRun.*`` for a -framework-driven run, ``CodedFunctionRun.*`` for a plain Python entrypoint. -Unlike ``Cli.*`` these are not suppressed under a job key, so cloud runs report -too, and every event carries the full scope block so none needs an Orchestrator -lookup to attribute. +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 @@ -47,9 +44,8 @@ class RunKind(str, Enum): 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. - ``agent_type`` cannot separate the coded kinds — functions and LangGraph both - report ``uipath_coded``. Only the functions factory reports framework - ``python``; a factory reporting nothing is assumed to wrap a framework. + 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 @@ -72,13 +68,10 @@ def _cloud_user_id() -> str | None: def _scope_properties() -> dict[str, Any]: """Identity and scope, on every event so each one stands alone. - The cloud identity trio is required on every Automation Cloud event, so an - unauthenticated run reports ``N/A`` rather than dropping it. Optional scope - is omitted when absent, so a local run does not look like it has a folder. - - ``UIPATH_PROCESS_KEY`` holds the display name, not an id. ``TraceId`` goes - through :func:`resolve_trace_id` because the env var may be a dashed UUID - while spans export as 32-char hex, and the two have to join. + 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. Note + ``UIPATH_PROCESS_KEY`` holds a display name, and ``TraceId`` needs + :func:`resolve_trace_id` to normalize a dashed UUID into the hex spans use. """ properties: dict[str, Any] = { "FolderKey": UiPathConfig.folder_key, @@ -101,14 +94,16 @@ def _scope_properties() -> dict[str, Any]: def _error_properties( - error_type: str, contract: UiPathErrorContract | None + error_type: str | None, contract: UiPathErrorContract | None ) -> dict[str, Any]: - """Error classification only — no message, no traceback. + """Error classification only, since message and traceback carry customer source. - Both carry customer source and values for a coded run; ``UiPathBaseRuntimeError`` - even appends the traceback to ``detail``. + ``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] = {"ErrorType": error_type} + properties: dict[str, Any] = {} + if error_type: + properties["ErrorType"] = error_type if contract is None: return properties properties["ErrorCode"] = contract.code @@ -147,10 +142,9 @@ def start( execution_source: str | None = None, is_conversational: bool = False, ) -> "RunTelemetry | None": - """Emit Start and return a handle, or ``None`` when this run is not reported. + """Emit Start and return a handle, or ``None`` when not reporting. - ``None`` when telemetry is off, or for low-code, which - uipath-agents-python reports itself. + ``None`` when telemetry is off, or for low-code, which reports itself. """ if not is_telemetry_enabled(): return None @@ -171,17 +165,15 @@ def start( def finished(self, result: UiPathRuntimeResult | None) -> None: """Terminal event for a run that returned. - Faulted is a failure the runtime handled rather than raised; suspended is - waiting on a trigger, not broken. + 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 - error_type = contract.code if contract else "Unknown" self._emit( "Failed", - {"Status": "Failed", **_error_properties(error_type, contract)}, + {"Status": "Failed", **_error_properties(None, contract)}, ) return diff --git a/packages/uipath/tests/cli/test_run_telemetry.py b/packages/uipath/tests/cli/test_run_telemetry.py index ef9da293a..ae70cb513 100644 --- a/packages/uipath/tests/cli/test_run_telemetry.py +++ b/packages/uipath/tests/cli/test_run_telemetry.py @@ -23,6 +23,7 @@ "JobId", "CloudOrganizationId", "CloudTenantId", + "CloudUserId", "ProjectId", "ProjectKey", "ProcessName", @@ -202,6 +203,31 @@ def test_faulted_result_without_an_exception_is_still_a_failure(self, emitted): 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)) From 50f96afaa9ac0c13720a393906ad0cfe3920f538 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Thu, 3 Sep 2026 14:24:00 +0300 Subject: [PATCH 4/7] chore(uipath): bump version to 2.14.12 2.14.11 was published to PyPI while this branch was open, so the version-availability check started failing on it. --- packages/uipath/pyproject.toml | 2 +- packages/uipath/uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index f5ca8f457..8067cd9ac 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [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" diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a26f9d797..a960526b9 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2599,7 +2599,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.14.11" +version = "2.14.12" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, From 46e996bd5ff6336e47ebba3185c5bc3674491cae Mon Sep 17 00:00:00 2001 From: tudormatei Date: Thu, 3 Sep 2026 17:11:58 +0300 Subject: [PATCH 5/7] fix(telemetry): drop the unread Automation Suite properties AutomationSuiteClusterId and AutomationSuiteClusterVersion were copied from the low-code enrichment without checking whether anything sets or reads them. Only the first is set anywhere, by the autopilot-everyone-service chart rather than a job container, and the second is never set at all. Downstream neither has a column in agents_AC_telemetry_standardized, whose macro hardcodes ActivationType to AutomationCloud, so both would have been null on every row. IMAGE_VERSION moves to the platform constants alongside the other environment variable names, which pulls uipath-platform into the change. DurationMs is now opt-in per event rather than inferred from the event suffix, so a future non-terminal event does not silently acquire one. --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/constants/__init__.py | 1 + packages/uipath-platform/uv.lock | 4 ++-- packages/uipath/pyproject.toml | 2 +- .../uipath/src/uipath/_cli/_run_telemetry.py | 24 ++++++++----------- .../uipath/tests/cli/test_run_telemetry.py | 7 ++++++ packages/uipath/uv.lock | 4 ++-- 7 files changed, 24 insertions(+), 20 deletions(-) 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 8067cd9ac..05594e827 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ 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 index 603c85f48..7b33fbd0f 100644 --- a/packages/uipath/src/uipath/_cli/_run_telemetry.py +++ b/packages/uipath/src/uipath/_cli/_run_telemetry.py @@ -13,7 +13,7 @@ from typing import Any from uipath.platform.common import UiPathConfig, resolve_trace_id -from uipath.platform.constants import ENV_JOB_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 @@ -29,10 +29,6 @@ _LOWCODE_AGENT_TYPE = "uipath_lowcode" _FUNCTION_FRAMEWORK = "python" -_ENV_IMAGE_VERSION = "IMAGE_VERSION" -_ENV_AS_CLUSTER_ID = "AUTOMATION_SUITE_CLUSTER_ID" -_ENV_AS_CLUSTER_VERSION = "AUTOMATION_SUITE_CLUSTER_VERSION" - class RunKind(str, Enum): """Event-name prefix per run kind.""" @@ -69,9 +65,7 @@ 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. Note - ``UIPATH_PROCESS_KEY`` holds a display name, and ``TraceId`` needs - :func:`resolve_trace_id` to normalize a dashed UUID into the hex spans use. + dropped; optional scope is omitted, so a local run has no folder. """ properties: dict[str, Any] = { "FolderKey": UiPathConfig.folder_key, @@ -86,9 +80,7 @@ def _scope_properties() -> dict[str, Any]: "ProcessUuid": UiPathConfig.process_uuid, "ProcessVersion": UiPathConfig.process_version, "TraceId": resolve_trace_id(), - "ImageVersion": os.getenv(_ENV_IMAGE_VERSION), - "AutomationSuiteClusterId": os.getenv(_ENV_AS_CLUSTER_ID), - "AutomationSuiteClusterVersion": os.getenv(_ENV_AS_CLUSTER_VERSION), + "ImageVersion": os.getenv(ENV_IMAGE_VERSION), } return {key: value for key, value in properties.items() if value is not None} @@ -174,11 +166,12 @@ def finished(self, result: UiPathRuntimeResult | None) -> 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}) + self._emit("End", {"Status": label}, include_duration=True) def failed(self, exception: BaseException) -> None: """Terminal event for a run that raised.""" @@ -191,9 +184,12 @@ def failed(self, exception: BaseException) -> None: "Status": "Failed", **_error_properties(type(exception).__name__, contract), }, + include_duration=True, ) - def _emit(self, suffix: str, extra: dict[str, Any]) -> None: + def _emit( + self, suffix: str, extra: dict[str, Any], include_duration: bool = False + ) -> None: try: properties: dict[str, Any] = { "AgentRunId": self._run_id, @@ -211,7 +207,7 @@ def _emit(self, suffix: str, extra: dict[str, Any]) -> None: } properties.update(_scope_properties()) - if suffix != "Start": + if include_duration: properties["DurationMs"] = int( (time.monotonic() - self._started_at) * 1000 ) diff --git a/packages/uipath/tests/cli/test_run_telemetry.py b/packages/uipath/tests/cli/test_run_telemetry.py index ae70cb513..bdd7a1260 100644 --- a/packages/uipath/tests/cli/test_run_telemetry.py +++ b/packages/uipath/tests/cli/test_run_telemetry.py @@ -243,6 +243,13 @@ def test_only_terminal_events_carry_a_duration(self, emitted): 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): diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index a960526b9..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] @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.24" +version = "0.2.25" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" }, From c9f406357d9f3793841aeae78f301e451e7cee6a Mon Sep 17 00:00:00 2001 From: tudormatei Date: Thu, 3 Sep 2026 17:11:58 +0300 Subject: [PATCH 6/7] feat(telemetry): emit coded run events from the debug command Studio Web runs go through uipath debug, so without this a large share of coded runs report nothing. Same shape as the run command: start once the factory has classified the run, terminal event on the result, failed event on the way out. --- packages/uipath/src/uipath/_cli/cli_debug.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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: From b5fb3b121a348f5051234ac7f63a84bbd2ee9ed8 Mon Sep 17 00:00:00 2001 From: tudormatei Date: Thu, 3 Sep 2026 17:14:13 +0300 Subject: [PATCH 7/7] refactor(telemetry): match the environment host exactly Per-tenant subdomains do not appear in UIPATH_URL, so suffix matching was guarding against a case that cannot occur. Exact matching is stricter, so a lookalike host still falls back to production. --- packages/uipath/src/uipath/telemetry/_environment.py | 11 +---------- packages/uipath/tests/telemetry/test_environment.py | 4 ++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/uipath/src/uipath/telemetry/_environment.py b/packages/uipath/src/uipath/telemetry/_environment.py index 4f9a7de9e..25084c286 100644 --- a/packages/uipath/src/uipath/telemetry/_environment.py +++ b/packages/uipath/src/uipath/telemetry/_environment.py @@ -22,11 +22,6 @@ } -def _is_domain_or_subdomain(hostname: str, domain: str) -> bool: - """Whether ``hostname`` is ``domain`` or a dot-separated subdomain of it.""" - return hostname == domain or hostname.endswith(f".{domain}") - - 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: @@ -40,11 +35,7 @@ def environment_from_base_url(base_url: str | None) -> UiPathEnvironment: if not hostname: return DEFAULT_ENVIRONMENT - for domain, environment in _ENVIRONMENT_BY_DOMAIN.items(): - if _is_domain_or_subdomain(hostname, domain): - return environment - - return DEFAULT_ENVIRONMENT + return _ENVIRONMENT_BY_DOMAIN.get(hostname, DEFAULT_ENVIRONMENT) def resolve_environment() -> UiPathEnvironment: diff --git a/packages/uipath/tests/telemetry/test_environment.py b/packages/uipath/tests/telemetry/test_environment.py index 38e869d3e..aee0c8238 100644 --- a/packages/uipath/tests/telemetry/test_environment.py +++ b/packages/uipath/tests/telemetry/test_environment.py @@ -21,8 +21,8 @@ class TestEnvironmentFromBaseUrl: def test_known_hosts_resolve_to_their_environment(self, base_url, expected): assert environment_from_base_url(base_url) == expected - def test_subdomain_resolves_to_the_parent_environment(self): - assert environment_from_base_url("https://tenant.alpha.uipath.com/o") == "alpha" + 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"