Skip to content

Commit 6d4a5dc

Browse files
committed
feat: add stable agent id to uipath.json for packaging and spans
1 parent 99b686b commit 6d4a5dc

22 files changed

Lines changed: 616 additions & 124 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.65"
3+
version = "0.1.66"
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: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
import json
33
import logging
44
import os
5+
import uuid
56
from dataclasses import dataclass, field
67
from datetime import datetime
78
from enum import IntEnum
9+
from functools import lru_cache
810
from os import environ as env
911
from typing import Any, Dict, List, Optional
1012

@@ -19,6 +21,42 @@
1921
DEFAULT_SOURCE = 10
2022

2123

24+
@lru_cache(maxsize=1)
25+
def _read_config_id() -> Optional[str]:
26+
"""Return ``id`` from ``uipath.json``, cached for the process lifetime.
27+
28+
Ignores a non-GUID ``id``.
29+
"""
30+
from uipath.platform.common._config import UiPathConfig
31+
32+
try:
33+
with open(UiPathConfig.config_file_path, "r") as f:
34+
id = json.load(f).get("id")
35+
except (OSError, json.JSONDecodeError):
36+
return None
37+
38+
if not isinstance(id, str) or not id:
39+
return None
40+
41+
try:
42+
uuid.UUID(id)
43+
except ValueError:
44+
logger.warning("Ignoring uipath.json 'id' %r: not a valid GUID.", id)
45+
return None
46+
47+
return id
48+
49+
50+
def resolve_id() -> Optional[str]:
51+
"""Resolve the project id.
52+
53+
Prefers ``uipath.json#id``, falls back to env vars.
54+
"""
55+
from uipath.platform.common._config import UiPathConfig
56+
57+
return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key
58+
59+
2260
class AttachmentProvider(IntEnum):
2361
ORCHESTRATOR = 0
2462

@@ -281,9 +319,11 @@ def otel_span_to_uipath_span(
281319
]
282320
attributes_dict["links"] = links_list
283321

322+
if id := resolve_id():
323+
attributes_dict["agentId"] = id
324+
284325
# Add process context attributes from environment variables
285326
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287327
("UIPATH_PROCESS_KEY", "agentName"),
288328
("UIPATH_PROCESS_VERSION", "agentVersion"),
289329
):
@@ -297,10 +337,8 @@ def otel_span_to_uipath_span(
297337
# Top-level fields for internal tracing schema
298338
execution_type = attributes_dict.get("executionType")
299339
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")
340+
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
341+
"referenceId"
304342
)
305343
verbosity_level = attributes_dict.get("verbosityLevel")
306344

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

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from uipath.core.feature_flags import FeatureFlags
99

1010
from uipath.platform.chat.llm_trace_context import build_trace_context_headers
11+
from uipath.platform.common.constants import ENV_PROJECT_KEY
1112

1213
FEATURE_FLAG = "EnableTraceContextHeaders"
1314

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

112113
def setup_method(self) -> None:
114+
from uipath.platform.common._span_utils import _read_config_id
115+
116+
_read_config_id.cache_clear()
113117
FeatureFlags.reset_flags()
114118
FeatureFlags.configure_flags({FEATURE_FLAG: True})
115119

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

138-
def test_agent_id_from_agent_id_env(self) -> None:
139-
env = {"UIPATH_AGENT_ID": "real-agent-id"}
142+
def test_agent_id_from_project_key_env(self) -> None:
143+
env = {ENV_PROJECT_KEY: "real-agent-id"}
140144
with patch.dict(os.environ, env, clear=True):
141145
headers = build_trace_context_headers()
142146

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

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-
154150
def test_no_agent_id_without_env_vars(self) -> None:
155151
env = {"UIPATH_FOLDER_KEY": "f1"}
156152
with patch.dict(os.environ, env, clear=True):
@@ -169,7 +165,7 @@ def test_no_baggage_without_env_vars(self) -> None:
169165
def test_baggage_comma_separated(self) -> None:
170166
env = {
171167
"UIPATH_FOLDER_KEY": "f1",
172-
"UIPATH_AGENT_ID": "a1",
168+
ENV_PROJECT_KEY: "a1",
173169
}
174170
with patch.dict(os.environ, env, clear=True):
175171
headers = build_trace_context_headers()

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

Lines changed: 137 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@
88
from opentelemetry.trace import SpanContext, StatusCode
99

1010
from uipath.platform.common import UiPathSpan, _SpanUtils
11+
from uipath.platform.common.constants import (
12+
ENV_PROJECT_KEY,
13+
ENV_UIPATH_AGENT_ID,
14+
ENV_UIPATH_PROJECT_ID,
15+
)
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _clear_id_cache():
20+
"""Isolate the process-global id cache between tests."""
21+
from uipath.platform.common._span_utils import _read_config_id
22+
23+
_read_config_id.cache_clear()
24+
yield
25+
_read_config_id.cache_clear()
1126

1227

1328
class TestOTelToUiPathSpan:
@@ -92,10 +107,11 @@ def test_verbosity_level_omitted_when_unset(self) -> None:
92107
class TestReferenceIdResolution:
93108
"""`reference_id` resolution chain.
94109
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.
110+
`reference_id` is derived from the span's resolved `agentId` attribute
111+
(which itself goes through `resolve_id()`), falling back to the
112+
`referenceId` attribute. Falsy values (missing / empty string) at each step
113+
fall through to the next source. The `referenceId` fallback exists for
114+
backwards compatibility with older producers that only emit that attribute.
99115
"""
100116

101117
@pytest.mark.parametrize(
@@ -105,7 +121,7 @@ class TestReferenceIdResolution:
105121
"env-agent",
106122
{"agentId": "attr-agent", "referenceId": "attr-ref"},
107123
"env-agent",
108-
id="env-var-wins",
124+
id="env-var-overrides-attr",
109125
),
110126
pytest.param(
111127
None,
@@ -140,10 +156,15 @@ def test_reference_id_chain(
140156
expected: str | None,
141157
monkeypatch: pytest.MonkeyPatch,
142158
) -> None:
159+
from uipath.platform.common._span_utils import _read_config_id
160+
161+
_read_config_id.cache_clear()
162+
monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False)
163+
monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False)
143164
if env_value is None:
144-
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
165+
monkeypatch.delenv(ENV_PROJECT_KEY, raising=False)
145166
else:
146-
monkeypatch.setenv("UIPATH_AGENT_ID", env_value)
167+
monkeypatch.setenv(ENV_PROJECT_KEY, env_value)
147168

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

168189

190+
class TestAgentIdResolution:
191+
"""`agentId` span attribute resolution via `resolve_id()`.
192+
193+
Priority: `uipath.json#id` (cached, read once per process) > `UIPATH_AGENT_ID`
194+
/ `UIPATH_PROJECT_ID` > the legacy `PROJECT_KEY` env var injected by the
195+
executor at runtime. When no source is present the `agentId` attribute is
196+
omitted entirely.
197+
"""
198+
199+
@staticmethod
200+
def _make_span() -> Mock:
201+
mock_span = Mock(spec=OTelSpan)
202+
mock_context = SpanContext(
203+
trace_id=0x123456789ABCDEF0123456789ABCDEF0,
204+
span_id=0x0123456789ABCDEF,
205+
is_remote=False,
206+
)
207+
mock_span.get_span_context.return_value = mock_context
208+
mock_span.name = "test-span"
209+
mock_span.parent = None
210+
mock_span.status.status_code = StatusCode.OK
211+
mock_span.attributes = {}
212+
mock_span.events = []
213+
mock_span.links = []
214+
now_ns = int(datetime.now().timestamp() * 1e9)
215+
mock_span.start_time = now_ns
216+
mock_span.end_time = now_ns + 1_000_000
217+
return mock_span
218+
219+
@staticmethod
220+
def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object:
221+
from uipath.platform.common._span_utils import _read_config_id
222+
223+
_read_config_id.cache_clear()
224+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
225+
monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False)
226+
monkeypatch.delenv(ENV_UIPATH_PROJECT_ID, raising=False)
227+
monkeypatch.chdir(tmp_path)
228+
uipath_span = _SpanUtils.otel_span_to_uipath_span(
229+
TestAgentIdResolution._make_span(), serialize_attributes=False
230+
)
231+
attributes = uipath_span.attributes
232+
assert isinstance(attributes, dict)
233+
return attributes.get("agentId")
234+
235+
def test_agent_id_from_uipath_json_wins_over_env(
236+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
237+
) -> None:
238+
(tmp_path / "uipath.json").write_text(
239+
json.dumps({"id": "00000000-0000-0000-0000-000000000001"})
240+
)
241+
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
242+
assert (
243+
self._resolve(monkeypatch, tmp_path)
244+
== "00000000-0000-0000-0000-000000000001"
245+
)
246+
247+
def test_agent_id_falls_back_to_project_key(
248+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
249+
) -> None:
250+
# No uipath.json on disk.
251+
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
252+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
253+
254+
def test_agent_id_falls_back_when_config_has_no_id(
255+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
256+
) -> None:
257+
(tmp_path / "uipath.json").write_text(json.dumps({"functions": {}}))
258+
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
259+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
260+
261+
def test_agent_id_absent_when_no_source(
262+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
263+
) -> None:
264+
monkeypatch.delenv(ENV_PROJECT_KEY, raising=False)
265+
assert self._resolve(monkeypatch, tmp_path) is None
266+
267+
def test_non_guid_config_id_is_ignored_and_falls_back(
268+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
269+
) -> None:
270+
# A malformed (non-GUID) id must not reach ReferenceId; fall back to env.
271+
(tmp_path / "uipath.json").write_text(json.dumps({"id": "not-a-guid"}))
272+
monkeypatch.setenv(ENV_PROJECT_KEY, "from-env")
273+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
274+
275+
def test_config_id_is_cached(
276+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
277+
) -> None:
278+
from uipath.platform.common._span_utils import _read_config_id
279+
280+
first = "00000000-0000-0000-0000-000000000001"
281+
second = "00000000-0000-0000-0000-000000000002"
282+
283+
_read_config_id.cache_clear()
284+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
285+
monkeypatch.chdir(tmp_path)
286+
config = tmp_path / "uipath.json"
287+
288+
config.write_text(json.dumps({"id": first}))
289+
assert _read_config_id() == first
290+
291+
# A later edit is not observed: the value is read once and cached.
292+
config.write_text(json.dumps({"id": second}))
293+
assert _read_config_id() == first
294+
295+
_read_config_id.cache_clear()
296+
assert _read_config_id() == second
297+
298+
169299
class TestNormalizeIds:
170300
"""Tests for OTEL ID normalization functions."""
171301

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.83"
3+
version = "2.10.84"
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.65, <0.2.0",
10+
"uipath-platform>=0.1.66, <0.2.0",
1111
"click>=8.3.1",
1212
"httpx>=0.28.1",
1313
"pyjwt>=2.10.1",

0 commit comments

Comments
 (0)