Skip to content

Commit ffd3ad1

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

16 files changed

Lines changed: 445 additions & 76 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.59"
3+
version = "0.1.60"
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: 2 additions & 2 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_agent_id
99

1010

1111
def build_trace_context_headers(
@@ -40,7 +40,7 @@ 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:
43+
if agent_id := resolve_agent_id():
4444
baggage_parts.append(f"agentId={agent_id}")
4545
if process_key := UiPathConfig.process_key:
4646
baggage_parts.append(f"processKey={process_key}")

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

Lines changed: 39 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,38 @@
1920
DEFAULT_SOURCE = 10
2021

2122

23+
@lru_cache(maxsize=1)
24+
def _read_config_agent_id() -> Optional[str]:
25+
"""Return ``id`` from ``uipath.json``, cached for the process lifetime.
26+
27+
Spans are produced on a hot path, so the project descriptor is read at most
28+
once. Returns ``None`` when the file or the ``id`` field is absent, so
29+
the caller can fall back to the legacy env vars.
30+
"""
31+
from uipath.platform.common._config import UiPathConfig
32+
33+
try:
34+
with open(UiPathConfig.config_file_path, "r") as f:
35+
agent_id = json.load(f).get("id")
36+
except (OSError, json.JSONDecodeError):
37+
return None
38+
39+
return agent_id if isinstance(agent_id, str) and agent_id else None
40+
41+
42+
def resolve_agent_id() -> Optional[str]:
43+
"""Resolve the agent id.
44+
45+
``uipath.json#id`` (minted at init time) falling back to
46+
``UIPATH_PROCESS_UUID`` injected by the executor at runtime. Single resolver
47+
for every consumer carrying an agent identity (span attribute, trace
48+
baggage, eval telemetry) so they cannot diverge.
49+
"""
50+
from uipath.platform.common._config import UiPathConfig
51+
52+
return _read_config_agent_id() or UiPathConfig.process_uuid
53+
54+
2255
class AttachmentProvider(IntEnum):
2356
ORCHESTRATOR = 0
2457

@@ -281,9 +314,12 @@ def otel_span_to_uipath_span(
281314
]
282315
attributes_dict["links"] = links_list
283316

317+
agent_id = resolve_agent_id()
318+
if agent_id:
319+
attributes_dict["agentId"] = agent_id
320+
284321
# Add process context attributes from environment variables
285322
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287323
("UIPATH_PROCESS_KEY", "agentName"),
288324
("UIPATH_PROCESS_VERSION", "agentVersion"),
289325
):
@@ -297,10 +333,8 @@ def otel_span_to_uipath_span(
297333
# Top-level fields for internal tracing schema
298334
execution_type = attributes_dict.get("executionType")
299335
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")
336+
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
337+
"referenceId"
304338
)
305339
verbosity_level = attributes_dict.get("verbosityLevel")
306340

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_agent_id
114+
115+
_read_config_agent_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_agent_id_cache():
15+
"""Isolate the process-global agentId cache between tests."""
16+
from uipath.platform.common._span_utils import _read_config_agent_id
17+
18+
_read_config_agent_id.cache_clear()
19+
yield
20+
_read_config_agent_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_agent_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_agent_id
155+
156+
_read_config_agent_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_agent_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_agent_id
214+
215+
_read_config_agent_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_agent_id_is_cached(
253+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
254+
) -> None:
255+
from uipath.platform.common._span_utils import _read_config_agent_id
256+
257+
_read_config_agent_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_agent_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_agent_id() == "first"
268+
269+
_read_config_agent_id.cache_clear()
270+
assert _read_config_agent_id() == "second"
271+
272+
169273
class TestNormalizeIds:
170274
"""Tests for OTEL ID normalization functions."""
171275

packages/uipath-platform/uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/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"
3-
version = "2.10.75"
3+
version = "2.10.76"
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"

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_agent_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 agent_id := resolve_agent_id():
316+
properties["AgentId"] = agent_id
315317

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

packages/uipath/src/uipath/_cli/_utils/_project_files.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@
2525
logger = logging.getLogger(__name__)
2626

2727

28+
def resolve_existing_project_id(directory: str = ".") -> Optional[str]:
29+
"""Return an already-established project id for this project, if any.
30+
31+
Checks the Studio Web project env var first, then falls back to the legacy
32+
``ProjectKey`` stored in ``.uipath/.telemetry.json``. Returns ``None`` when
33+
neither is present.
34+
35+
Args:
36+
directory: The project root directory to look for the telemetry file in.
37+
"""
38+
from ...telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE
39+
40+
if project_id := UiPathConfig.project_id:
41+
return project_id
42+
43+
telemetry_file = os.path.join(directory, ".uipath", _TELEMETRY_CONFIG_FILE)
44+
if os.path.exists(telemetry_file):
45+
try:
46+
with open(telemetry_file, "r") as f:
47+
telemetry_data = json.load(f)
48+
if project_id := telemetry_data.get(_PROJECT_KEY):
49+
return project_id
50+
except (json.JSONDecodeError, IOError):
51+
pass
52+
53+
return None
54+
55+
2856
class Severity(IntEnum):
2957
LOG = 0
3058
WARNING = 1

0 commit comments

Comments
 (0)