Skip to content

Commit ec87297

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

20 files changed

Lines changed: 539 additions & 119 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/docs/cli/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ Running `uipath init` will process these function definitions and create the cor
121121

122122
`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`.
123123
///
124+
125+
/// warning
126+
### About the `id` field
127+
128+
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.
129+
130+
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.
131+
///
124132
---
125133

126134
::: mkdocs-click

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.82"
3+
version = "2.10.83"
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/specs/uipath.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
"type": "string",
1010
"description": "Reference to this JSON schema for editor support"
1111
},
12+
"id": {
13+
"type": "string",
14+
"format": "uuid",
15+
"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."
16+
},
1217
"runtimeOptions": {
1318
"type": "object",
1419
"description": "Runtime behavior configuration",

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)