Skip to content

Commit 7e0db2a

Browse files
committed
feat: add stable agent id to uipath.json for packaging and spans
1 parent a93a2ce commit 7e0db2a

16 files changed

Lines changed: 447 additions & 86 deletions

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-platform"
3-
version = "0.1.64"
3+
version = "0.1.65"
44
description = "HTTP client library for programmatic access to UiPath Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-platform/src/uipath/platform/chat/llm_trace_context.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from uipath.core.tracing.span_utils import UiPathSpanUtils
66

77
from ..common._config import UiPathConfig
8-
from ..common._span_utils import _SpanUtils
8+
from ..common._span_utils import _SpanUtils, resolve_id
99

1010

1111
def build_trace_context_headers(
@@ -40,8 +40,8 @@ def build_trace_context_headers(
4040
baggage_parts: list[str] = list(extra_baggage) if extra_baggage else []
4141
if folder_key := UiPathConfig.folder_key:
4242
baggage_parts.append(f"folderKey={folder_key}")
43-
if agent_id := UiPathConfig.agent_id:
44-
baggage_parts.append(f"agentId={agent_id}")
43+
if id := resolve_id():
44+
baggage_parts.append(f"agentId={id}")
4545
if process_key := UiPathConfig.process_key:
4646
baggage_parts.append(f"processKey={process_key}")
4747
if baggage_parts:

packages/uipath-platform/src/uipath/platform/common/_span_utils.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from dataclasses import dataclass, field
66
from datetime import datetime
77
from enum import IntEnum
8+
from functools import lru_cache
89
from os import environ as env
910
from typing import Any, Dict, List, Optional
1011

@@ -19,6 +20,31 @@
1920
DEFAULT_SOURCE = 10
2021

2122

23+
@lru_cache(maxsize=1)
24+
def _read_config_id() -> Optional[str]:
25+
"""Return ``id`` from ``uipath.json``, cached for the process lifetime."""
26+
from uipath.platform.common._config import UiPathConfig
27+
28+
try:
29+
with open(UiPathConfig.config_file_path, "r") as f:
30+
id = json.load(f).get("id")
31+
except (OSError, json.JSONDecodeError):
32+
return None
33+
34+
return id if isinstance(id, str) and id else None
35+
36+
37+
def resolve_id() -> Optional[str]:
38+
"""Resolve the id: ``uipath.json#id`` then ``UIPATH_PROCESS_UUID``.
39+
40+
Single resolver for every consumer (span attribute, trace baggage, eval
41+
telemetry) so they cannot diverge.
42+
"""
43+
from uipath.platform.common._config import UiPathConfig
44+
45+
return _read_config_id() or UiPathConfig.process_uuid
46+
47+
2248
class AttachmentProvider(IntEnum):
2349
ORCHESTRATOR = 0
2450

@@ -281,9 +307,12 @@ def otel_span_to_uipath_span(
281307
]
282308
attributes_dict["links"] = links_list
283309

310+
id = resolve_id()
311+
if id:
312+
attributes_dict["agentId"] = id
313+
284314
# Add process context attributes from environment variables
285315
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287316
("UIPATH_PROCESS_KEY", "agentName"),
288317
("UIPATH_PROCESS_VERSION", "agentVersion"),
289318
):
@@ -297,10 +326,8 @@ def otel_span_to_uipath_span(
297326
# Top-level fields for internal tracing schema
298327
execution_type = attributes_dict.get("executionType")
299328
agent_version = attributes_dict.get("agentVersion")
300-
reference_id = (
301-
env.get("UIPATH_AGENT_ID")
302-
or attributes_dict.get("agentId")
303-
or attributes_dict.get("referenceId")
329+
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
330+
"referenceId"
304331
)
305332
verbosity_level = attributes_dict.get("verbosityLevel")
306333

packages/uipath-platform/tests/services/test_llm_trace_context.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,16 @@ class TestBaggageHeader:
110110
"""When enabled, x-uipath-tracebaggage is populated from UiPathConfig."""
111111

112112
def setup_method(self) -> None:
113+
from uipath.platform.common._span_utils import _read_config_id
114+
115+
_read_config_id.cache_clear()
113116
FeatureFlags.reset_flags()
114117
FeatureFlags.configure_flags({FEATURE_FLAG: True})
115118

116119
def test_all_env_vars_present(self) -> None:
117120
env = {
118121
"UIPATH_FOLDER_KEY": "folder-abc",
119-
"UIPATH_AGENT_ID": "agent-123",
122+
"UIPATH_PROCESS_UUID": "agent-123",
120123
"UIPATH_PROCESS_KEY": "process-789",
121124
}
122125
with patch.dict(os.environ, env, clear=True):
@@ -135,22 +138,14 @@ def test_partial_env_vars(self) -> None:
135138
baggage = headers["x-uipath-tracebaggage"]
136139
assert "folderKey=folder-only" in baggage
137140

138-
def test_agent_id_from_agent_id_env(self) -> None:
139-
env = {"UIPATH_AGENT_ID": "real-agent-id"}
141+
def test_agent_id_from_process_uuid_env(self) -> None:
142+
env = {"UIPATH_PROCESS_UUID": "real-agent-id"}
140143
with patch.dict(os.environ, env, clear=True):
141144
headers = build_trace_context_headers()
142145

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

146-
def test_agent_id_falls_back_to_project_id(self) -> None:
147-
env = {"UIPATH_PROJECT_ID": "project-123"}
148-
with patch.dict(os.environ, env, clear=True):
149-
headers = build_trace_context_headers()
150-
151-
baggage = headers["x-uipath-tracebaggage"]
152-
assert "agentId=project-123" in baggage
153-
154149
def test_no_agent_id_without_env_vars(self) -> None:
155150
env = {"UIPATH_FOLDER_KEY": "f1"}
156151
with patch.dict(os.environ, env, clear=True):
@@ -169,7 +164,7 @@ def test_no_baggage_without_env_vars(self) -> None:
169164
def test_baggage_comma_separated(self) -> None:
170165
env = {
171166
"UIPATH_FOLDER_KEY": "f1",
172-
"UIPATH_AGENT_ID": "a1",
167+
"UIPATH_PROCESS_UUID": "a1",
173168
}
174169
with patch.dict(os.environ, env, clear=True):
175170
headers = build_trace_context_headers()

packages/uipath-platform/tests/services/test_span_utils.py

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
from uipath.platform.common import UiPathSpan, _SpanUtils
1111

1212

13+
@pytest.fixture(autouse=True)
14+
def _clear_id_cache():
15+
"""Isolate the process-global id cache between tests."""
16+
from uipath.platform.common._span_utils import _read_config_id
17+
18+
_read_config_id.cache_clear()
19+
yield
20+
_read_config_id.cache_clear()
21+
22+
1323
class TestOTelToUiPathSpan:
1424
"""OTEL attribute -> top-level UiPathSpan field mapping.
1525
@@ -92,10 +102,11 @@ def test_verbosity_level_omitted_when_unset(self) -> None:
92102
class TestReferenceIdResolution:
93103
"""`reference_id` resolution chain.
94104
95-
Priority: `UIPATH_AGENT_ID` env var > `agentId` attribute > `referenceId`
96-
attribute. Falsy values (missing / empty string) at each step fall through
97-
to the next source. The `referenceId` fallback exists for backwards
98-
compatibility with older producers that only emit that attribute.
105+
`reference_id` is derived from the span's resolved `agentId` attribute
106+
(which itself goes through `resolve_id()`), falling back to the
107+
`referenceId` attribute. Falsy values (missing / empty string) at each step
108+
fall through to the next source. The `referenceId` fallback exists for
109+
backwards compatibility with older producers that only emit that attribute.
99110
"""
100111

101112
@pytest.mark.parametrize(
@@ -105,7 +116,7 @@ class TestReferenceIdResolution:
105116
"env-agent",
106117
{"agentId": "attr-agent", "referenceId": "attr-ref"},
107118
"env-agent",
108-
id="env-var-wins",
119+
id="env-var-overrides-attr",
109120
),
110121
pytest.param(
111122
None,
@@ -140,10 +151,13 @@ def test_reference_id_chain(
140151
expected: str | None,
141152
monkeypatch: pytest.MonkeyPatch,
142153
) -> None:
154+
from uipath.platform.common._span_utils import _read_config_id
155+
156+
_read_config_id.cache_clear()
143157
if env_value is None:
144-
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
158+
monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False)
145159
else:
146-
monkeypatch.setenv("UIPATH_AGENT_ID", env_value)
160+
monkeypatch.setenv("UIPATH_PROCESS_UUID", env_value)
147161

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

168182

183+
class TestAgentIdResolution:
184+
"""`agentId` span attribute resolution via `resolve_id()`.
185+
186+
Priority: `uipath.json#id` (cached, read once per process) >
187+
`UIPATH_PROCESS_UUID` env var injected by the executor at runtime. When no
188+
source is present the `agentId` attribute is omitted entirely.
189+
"""
190+
191+
@staticmethod
192+
def _make_span() -> Mock:
193+
mock_span = Mock(spec=OTelSpan)
194+
mock_context = SpanContext(
195+
trace_id=0x123456789ABCDEF0123456789ABCDEF0,
196+
span_id=0x0123456789ABCDEF,
197+
is_remote=False,
198+
)
199+
mock_span.get_span_context.return_value = mock_context
200+
mock_span.name = "test-span"
201+
mock_span.parent = None
202+
mock_span.status.status_code = StatusCode.OK
203+
mock_span.attributes = {}
204+
mock_span.events = []
205+
mock_span.links = []
206+
now_ns = int(datetime.now().timestamp() * 1e9)
207+
mock_span.start_time = now_ns
208+
mock_span.end_time = now_ns + 1_000_000
209+
return mock_span
210+
211+
@staticmethod
212+
def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object:
213+
from uipath.platform.common._span_utils import _read_config_id
214+
215+
_read_config_id.cache_clear()
216+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
217+
monkeypatch.chdir(tmp_path)
218+
uipath_span = _SpanUtils.otel_span_to_uipath_span(
219+
TestAgentIdResolution._make_span(), serialize_attributes=False
220+
)
221+
attributes = uipath_span.attributes
222+
assert isinstance(attributes, dict)
223+
return attributes.get("agentId")
224+
225+
def test_agent_id_from_uipath_json_wins_over_process_uuid(
226+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
227+
) -> None:
228+
(tmp_path / "uipath.json").write_text(json.dumps({"id": "from-config"}))
229+
monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env")
230+
assert self._resolve(monkeypatch, tmp_path) == "from-config"
231+
232+
def test_agent_id_falls_back_to_process_uuid(
233+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
234+
) -> None:
235+
# No uipath.json on disk.
236+
monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env")
237+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
238+
239+
def test_agent_id_falls_back_when_config_has_no_id(
240+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
241+
) -> None:
242+
(tmp_path / "uipath.json").write_text(json.dumps({"functions": {}}))
243+
monkeypatch.setenv("UIPATH_PROCESS_UUID", "from-env")
244+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
245+
246+
def test_agent_id_absent_when_no_source(
247+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
248+
) -> None:
249+
monkeypatch.delenv("UIPATH_PROCESS_UUID", raising=False)
250+
assert self._resolve(monkeypatch, tmp_path) is None
251+
252+
def test_config_id_is_cached(
253+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
254+
) -> None:
255+
from uipath.platform.common._span_utils import _read_config_id
256+
257+
_read_config_id.cache_clear()
258+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
259+
monkeypatch.chdir(tmp_path)
260+
config = tmp_path / "uipath.json"
261+
262+
config.write_text(json.dumps({"id": "first"}))
263+
assert _read_config_id() == "first"
264+
265+
# A later edit is not observed: the value is read once and cached.
266+
config.write_text(json.dumps({"id": "second"}))
267+
assert _read_config_id() == "first"
268+
269+
_read_config_id.cache_clear()
270+
assert _read_config_id() == "second"
271+
272+
169273
class TestNormalizeIds:
170274
"""Tests for OTEL ID normalization functions."""
171275

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
[project]
22
name = "uipath"
3-
version = "2.10.81"
3+
version = "2.10.82"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
88
"uipath-core>=0.5.17, <0.6.0",
99
"uipath-runtime>=0.11.0, <0.12.0",
10-
"uipath-platform>=0.1.63, <0.2.0",
10+
"uipath-platform>=0.1.65, <0.2.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",

packages/uipath/src/uipath/_cli/_evals/_telemetry.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,12 @@ def _enrich_properties(self, properties: dict[str, Any]) -> None:
308308
Args:
309309
properties: The properties dictionary to enrich.
310310
"""
311+
from uipath.platform.common._span_utils import resolve_id
312+
311313
if UiPathConfig.project_id:
312314
properties["ProjectId"] = UiPathConfig.project_id
313-
if UiPathConfig.agent_id:
314-
properties["AgentId"] = UiPathConfig.agent_id
315+
if id := resolve_id():
316+
properties["AgentId"] = id
315317

316318
if UiPathConfig.organization_id:
317319
properties["CloudOrganizationId"] = UiPathConfig.organization_id

0 commit comments

Comments
 (0)