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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.1.66"
version = "0.1.67"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from uipath.core.tracing.span_utils import UiPathSpanUtils

from ..common._config import UiPathConfig
from ..common._span_utils import _SpanUtils
from ..common._span_utils import _SpanUtils, resolve_project_id


def build_trace_context_headers(
Expand Down Expand Up @@ -40,7 +40,7 @@ def build_trace_context_headers(
baggage_parts: list[str] = list(extra_baggage) if extra_baggage else []
if folder_key := UiPathConfig.folder_key:
baggage_parts.append(f"folderKey={folder_key}")
if agent_id := UiPathConfig.agent_id:
if agent_id := resolve_project_id():
baggage_parts.append(f"agentId={agent_id}")
if process_key := UiPathConfig.process_key:
baggage_parts.append(f"processKey={process_key}")
Expand Down
46 changes: 41 additions & 5 deletions packages/uipath-platform/src/uipath/platform/common/_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import json
import logging
import os
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import IntEnum
from functools import lru_cache
from os import environ as env
from typing import Any, Dict, List, Optional

Expand All @@ -19,6 +21,40 @@
DEFAULT_SOURCE = 10


@lru_cache(maxsize=1)
def _read_config_id() -> str | None:
"""Return a valid GUID ``id`` from ``uipath.json``, cached for the process lifetime."""
from uipath.platform.common._config import UiPathConfig

try:
config_file = json.loads(UiPathConfig.config_file_path.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return None

project_id = config_file.get("id")
if not isinstance(project_id, str):
logger.warning("'id' field not present in uipath.json")
return None

try:
uuid.UUID(project_id)
except ValueError:
logger.warning("Ignoring uipath.json 'id' %r: not a valid GUID.", project_id)
return None

return project_id


def resolve_project_id() -> str | None:
"""Resolve the project id.

Prefers ``uipath.json#id``, falls back to env vars.
"""
from uipath.platform.common._config import UiPathConfig

return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key


class AttachmentProvider(IntEnum):
ORCHESTRATOR = 0

Expand Down Expand Up @@ -281,9 +317,11 @@ def otel_span_to_uipath_span(
]
attributes_dict["links"] = links_list

if agent_id := resolve_project_id():
attributes_dict["agentId"] = agent_id

# Add process context attributes from environment variables
for env_key, attr_key in (
("PROJECT_KEY", "agentId"),
("UIPATH_PROCESS_KEY", "agentName"),
("UIPATH_PROCESS_VERSION", "agentVersion"),
):
Expand All @@ -297,10 +335,8 @@ def otel_span_to_uipath_span(
# Top-level fields for internal tracing schema
execution_type = attributes_dict.get("executionType")
agent_version = attributes_dict.get("agentVersion")
reference_id = (
env.get("UIPATH_AGENT_ID")
or attributes_dict.get("agentId")
or attributes_dict.get("referenceId")
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
"referenceId"
)
verbosity_level = attributes_dict.get("verbosityLevel")

Expand Down
20 changes: 8 additions & 12 deletions packages/uipath-platform/tests/services/test_llm_trace_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from uipath.core.feature_flags import FeatureFlags

from uipath.platform.chat.llm_trace_context import build_trace_context_headers
from uipath.platform.common.constants import ENV_PROJECT_KEY

FEATURE_FLAG = "EnableTraceContextHeaders"

Expand Down Expand Up @@ -110,13 +111,16 @@ class TestBaggageHeader:
"""When enabled, x-uipath-tracebaggage is populated from UiPathConfig."""

def setup_method(self) -> None:
from uipath.platform.common._span_utils import _read_config_id

_read_config_id.cache_clear()
FeatureFlags.reset_flags()
FeatureFlags.configure_flags({FEATURE_FLAG: True})

def test_all_env_vars_present(self) -> None:
env = {
"UIPATH_FOLDER_KEY": "folder-abc",
"UIPATH_AGENT_ID": "agent-123",
ENV_PROJECT_KEY: "agent-123",
"UIPATH_PROCESS_KEY": "process-789",
}
with patch.dict(os.environ, env, clear=True):
Expand All @@ -135,22 +139,14 @@ def test_partial_env_vars(self) -> None:
baggage = headers["x-uipath-tracebaggage"]
assert "folderKey=folder-only" in baggage

def test_agent_id_from_agent_id_env(self) -> None:
env = {"UIPATH_AGENT_ID": "real-agent-id"}
def test_agent_id_from_project_key_env(self) -> None:
env = {ENV_PROJECT_KEY: "real-agent-id"}
with patch.dict(os.environ, env, clear=True):
headers = build_trace_context_headers()

baggage = headers["x-uipath-tracebaggage"]
assert "agentId=real-agent-id" in baggage

def test_agent_id_falls_back_to_project_id(self) -> None:
env = {"UIPATH_PROJECT_ID": "project-123"}
with patch.dict(os.environ, env, clear=True):
headers = build_trace_context_headers()

baggage = headers["x-uipath-tracebaggage"]
assert "agentId=project-123" in baggage

def test_no_agent_id_without_env_vars(self) -> None:
env = {"UIPATH_FOLDER_KEY": "f1"}
with patch.dict(os.environ, env, clear=True):
Expand All @@ -169,7 +165,7 @@ def test_no_baggage_without_env_vars(self) -> None:
def test_baggage_comma_separated(self) -> None:
env = {
"UIPATH_FOLDER_KEY": "f1",
"UIPATH_AGENT_ID": "a1",
ENV_PROJECT_KEY: "a1",
}
with patch.dict(os.environ, env, clear=True):
headers = build_trace_context_headers()
Expand Down
144 changes: 137 additions & 7 deletions packages/uipath-platform/tests/services/test_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@
from opentelemetry.trace import SpanContext, StatusCode

from uipath.platform.common import UiPathSpan, _SpanUtils
from uipath.platform.common.constants import (
ENV_PROJECT_KEY,
ENV_UIPATH_AGENT_ID,
ENV_UIPATH_PROJECT_ID,
)


@pytest.fixture(autouse=True)
def _clear_id_cache():
"""Isolate the process-global id cache between tests."""
from uipath.platform.common._span_utils import _read_config_id

_read_config_id.cache_clear()
yield
_read_config_id.cache_clear()


class TestOTelToUiPathSpan:
Expand Down Expand Up @@ -92,10 +107,11 @@ def test_verbosity_level_omitted_when_unset(self) -> None:
class TestReferenceIdResolution:
"""`reference_id` resolution chain.

Priority: `UIPATH_AGENT_ID` env var > `agentId` attribute > `referenceId`
attribute. Falsy values (missing / empty string) at each step fall through
to the next source. The `referenceId` fallback exists for backwards
compatibility with older producers that only emit that attribute.
`reference_id` is derived from the span's resolved `agentId` attribute
(which itself goes through `resolve_project_id()`), falling back to the
`referenceId` attribute. Falsy values (missing / empty string) at each step
fall through to the next source. The `referenceId` fallback exists for
backwards compatibility with older producers that only emit that attribute.
"""

@pytest.mark.parametrize(
Expand All @@ -105,7 +121,7 @@ class TestReferenceIdResolution:
"env-agent",
{"agentId": "attr-agent", "referenceId": "attr-ref"},
"env-agent",
id="env-var-wins",
id="env-var-overrides-attr",
),
pytest.param(
None,
Expand Down Expand Up @@ -140,10 +156,15 @@ def test_reference_id_chain(
expected: str | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from uipath.platform.common._span_utils import _read_config_id

_read_config_id.cache_clear()
monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False)
monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False)
if env_value is None:
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
monkeypatch.delenv(ENV_PROJECT_KEY, raising=False)
else:
monkeypatch.setenv("UIPATH_AGENT_ID", env_value)
monkeypatch.setenv(ENV_PROJECT_KEY, env_value)

mock_span = Mock(spec=OTelSpan)
mock_context = SpanContext(
Expand All @@ -166,6 +187,115 @@ def test_reference_id_chain(
assert uipath_span.reference_id == expected


class TestAgentIdResolution:
"""`agentId` span attribute resolution via `resolve_project_id()`.

Priority: `uipath.json#id` (cached, read once per process) > `UIPATH_AGENT_ID`
/ `UIPATH_PROJECT_ID` > the legacy `PROJECT_KEY` env var injected by the
executor at runtime. When no source is present the `agentId` attribute is
omitted entirely.
"""

@staticmethod
def _make_span() -> Mock:
mock_span = Mock(spec=OTelSpan)
mock_context = SpanContext(
trace_id=0x123456789ABCDEF0123456789ABCDEF0,
span_id=0x0123456789ABCDEF,
is_remote=False,
)
mock_span.get_span_context.return_value = mock_context
mock_span.name = "test-span"
mock_span.parent = None
mock_span.status.status_code = StatusCode.OK
mock_span.attributes = {}
mock_span.events = []
mock_span.links = []
now_ns = int(datetime.now().timestamp() * 1e9)
mock_span.start_time = now_ns
mock_span.end_time = now_ns + 1_000_000
return mock_span

@staticmethod
def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object:
from uipath.platform.common._span_utils import _read_config_id

_read_config_id.cache_clear()
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False)
monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False)
monkeypatch.chdir(tmp_path)
uipath_span = _SpanUtils.otel_span_to_uipath_span(
TestAgentIdResolution._make_span(), serialize_attributes=False
)
attributes = uipath_span.attributes
assert isinstance(attributes, dict)
return attributes.get("agentId")

def test_agent_id_from_uipath_json_wins_over_env(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
(tmp_path / "uipath.json").write_text(
json.dumps({"id": "00000000-0000-0000-0000-000000000001"})
)
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
assert (
self._resolve(monkeypatch, tmp_path)
== "00000000-0000-0000-0000-000000000001"
)

def test_agent_id_falls_back_to_project_key(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
# No uipath.json on disk.
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
assert self._resolve(monkeypatch, tmp_path) == "from-env"

def test_agent_id_falls_back_when_config_has_no_id(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
(tmp_path / "uipath.json").write_text(json.dumps({"functions": {}}))
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
assert self._resolve(monkeypatch, tmp_path) == "from-env"

def test_agent_id_absent_when_no_source(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
monkeypatch.delenv(ENV_PROJECT_KEY, raising=False)
assert self._resolve(monkeypatch, tmp_path) is None

def test_non_guid_config_id_is_ignored_and_falls_back(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
# A malformed (non-GUID) id must not reach ReferenceId; fall back to env.
(tmp_path / "uipath.json").write_text(json.dumps({"id": "not-a-guid"}))
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
assert self._resolve(monkeypatch, tmp_path) == "from-env"

def test_config_id_is_cached(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
from uipath.platform.common._span_utils import _read_config_id

first = "00000000-0000-0000-0000-000000000001"
second = "00000000-0000-0000-0000-000000000002"

_read_config_id.cache_clear()
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
monkeypatch.chdir(tmp_path)
config = tmp_path / "uipath.json"

config.write_text(json.dumps({"id": first}))
assert _read_config_id() == first

# A later edit is not observed: the value is read once and cached.
config.write_text(json.dumps({"id": second}))
assert _read_config_id() == first

_read_config_id.cache_clear()
assert _read_config_id() == second


class TestNormalizeIds:
"""Tests for OTEL ID normalization functions."""

Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

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

8 changes: 8 additions & 0 deletions packages/uipath/docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ Running `uipath init` will process these function definitions and create the cor

`uipath init` generates one `<entrypoint>.mermaid` file per function/agent containing a static call graph, rendered in the UiPath Orchestrator UI. These files are regenerated on every `uipath init`.
///

/// warning
### About the `id` field

The first `uipath init` mints a stable `id` (GUID) into `uipath.json` and preserves it across subsequent runs. It is what identifies your project consistently wherever it is deployed and run.

Do not change or remove it. Changing it makes the project look like a brand-new, unrelated one, so you lose the link to everything previously published and tracked under the old id. `uipath pack` rejects an `id` that is not a valid GUID.
///
---

::: mkdocs-click
Expand Down
4 changes: 2 additions & 2 deletions packages/uipath/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[project]
name = "uipath"
version = "2.10.84"
version = "2.11.0"
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.17, <0.6.0",
"uipath-runtime>=0.11.0, <0.12.0",
"uipath-platform>=0.1.66, <0.2.0",
"uipath-platform>=0.1.67, <0.2.0",
"click>=8.3.1",
"httpx>=0.28.1",
"pyjwt>=2.10.1",
Expand Down
5 changes: 5 additions & 0 deletions packages/uipath/specs/uipath.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"type": "string",
"description": "Reference to this JSON schema for editor support"
},
"id": {
"type": "string",
"format": "uuid",
"description": "Stable unique identifier for the project, minted once on the first 'uipath init' and preserved for its lifetime. Used as the package 'projectId' at pack time. Do not change it."
},
"runtimeOptions": {
"type": "object",
"description": "Runtime behavior configuration",
Expand Down
Loading
Loading