Skip to content
Open
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.64"
version = "0.1.65"
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_id


def build_trace_context_headers(
Expand Down Expand Up @@ -40,8 +40,8 @@
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:
baggage_parts.append(f"agentId={agent_id}")
if id := resolve_id():

Check warning on line 43 in packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this variable; it shadows a builtin.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ67cyvkW2PpECwMbjNY&open=AZ67cyvkW2PpECwMbjNY&pullRequest=1695
baggage_parts.append(f"agentId={id}")
if process_key := UiPathConfig.process_key:
baggage_parts.append(f"processKey={process_key}")
if baggage_parts:
Expand Down
37 changes: 32 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 @@ -5,6 +5,7 @@
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 +20,31 @@
DEFAULT_SOURCE = 10


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

try:
with open(UiPathConfig.config_file_path, "r") as f:
id = json.load(f).get("id")

Check warning on line 30 in packages/uipath-platform/src/uipath/platform/common/_span_utils.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this variable; it shadows a builtin.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ67cyy1W2PpECwMbjNZ&open=AZ67cyy1W2PpECwMbjNZ&pullRequest=1695
except (OSError, json.JSONDecodeError):
return None

return id if isinstance(id, str) and id else None


def resolve_id() -> Optional[str]:
"""Resolve the id: ``uipath.json#id`` then ``UIPATH_PROCESS_UUID``.

Single resolver for every consumer (span attribute, trace baggage, eval
telemetry) so they cannot diverge.
"""
from uipath.platform.common._config import UiPathConfig

return _read_config_id() or UiPathConfig.process_uuid


class AttachmentProvider(IntEnum):
ORCHESTRATOR = 0

Expand Down Expand Up @@ -281,9 +307,12 @@
]
attributes_dict["links"] = links_list

id = resolve_id()

Check warning on line 310 in packages/uipath-platform/src/uipath/platform/common/_span_utils.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this variable; it shadows a builtin.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ67cyy1W2PpECwMbjNa&open=AZ67cyy1W2PpECwMbjNa&pullRequest=1695
if id:
attributes_dict["agentId"] = 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 +326,8 @@
# 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
19 changes: 7 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 @@ -110,13 +110,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",
"UIPATH_PROCESS_UUID": "agent-123",
"UIPATH_PROCESS_KEY": "process-789",
}
with patch.dict(os.environ, env, clear=True):
Expand All @@ -135,22 +138,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_process_uuid_env(self) -> None:
env = {"UIPATH_PROCESS_UUID": "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 +164,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",
"UIPATH_PROCESS_UUID": "a1",
}
with patch.dict(os.environ, env, clear=True):
headers = build_trace_context_headers()
Expand Down
118 changes: 111 additions & 7 deletions packages/uipath-platform/tests/services/test_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
from uipath.platform.common import UiPathSpan, _SpanUtils


@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:
"""OTEL attribute -> top-level UiPathSpan field mapping.

Expand Down Expand Up @@ -92,10 +102,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_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 +116,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 +151,13 @@ 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()
if env_value is None:
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False)
else:
monkeypatch.setenv("UIPATH_AGENT_ID", env_value)
monkeypatch.setenv("UIPATH_PROCESS_UUID", env_value)

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


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

Priority: `uipath.json#id` (cached, read once per process) >
`UIPATH_PROCESS_UUID` 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.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_process_uuid(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
(tmp_path / "uipath.json").write_text(json.dumps({"id": "from-config"}))
monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env")
assert self._resolve(monkeypatch, tmp_path) == "from-config"

def test_agent_id_falls_back_to_process_uuid(
self, monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
# No uipath.json on disk.
monkeypatch.setenv("UIPATH_PROCESS_UUID", "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("UIPATH_PROCESS_UUID", "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("UIPATH_PROCESS_UUID", raising=False)
assert self._resolve(monkeypatch, tmp_path) is None

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

_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.

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.81"
version = "2.10.82"
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.63, <0.2.0",
"uipath-platform>=0.1.65, <0.2.0",
"click>=8.3.1",
"httpx>=0.28.1",
"pyjwt>=2.10.1",
Expand Down
6 changes: 4 additions & 2 deletions packages/uipath/src/uipath/_cli/_evals/_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,12 @@
Args:
properties: The properties dictionary to enrich.
"""
from uipath.platform.common._span_utils import resolve_id

if UiPathConfig.project_id:
properties["ProjectId"] = UiPathConfig.project_id
if UiPathConfig.agent_id:
properties["AgentId"] = UiPathConfig.agent_id
if id := resolve_id():

Check warning on line 315 in packages/uipath/src/uipath/_cli/_evals/_telemetry.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename this variable; it shadows a builtin.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-python&issues=AZ67cyzcW2PpECwMbjNb&open=AZ67cyzcW2PpECwMbjNb&pullRequest=1695
properties["AgentId"] = id

if UiPathConfig.organization_id:
properties["CloudOrganizationId"] = UiPathConfig.organization_id
Expand Down
Loading
Loading