Skip to content

Commit 7d80dec

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

22 files changed

Lines changed: 613 additions & 123 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.66"
3+
version = "0.1.67"
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_project_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_project_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: 41 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,40 @@
1921
DEFAULT_SOURCE = 10
2022

2123

24+
@lru_cache(maxsize=1)
25+
def _read_config_id() -> str | None:
26+
"""Return a valid GUID ``id`` from ``uipath.json``, cached for the process lifetime."""
27+
from uipath.platform.common._config import UiPathConfig
28+
29+
try:
30+
config_file = json.loads(UiPathConfig.config_file_path.read_text())
31+
except (FileNotFoundError, json.JSONDecodeError):
32+
return None
33+
34+
project_id = config_file.get("id")
35+
if not isinstance(project_id, str):
36+
logger.warning("'id' field not present in uipath.json")
37+
return None
38+
39+
try:
40+
uuid.UUID(project_id)
41+
except ValueError:
42+
logger.warning("Ignoring uipath.json 'id' %r: not a valid GUID.", project_id)
43+
return None
44+
45+
return project_id
46+
47+
48+
def resolve_project_id() -> str | None:
49+
"""Resolve the project id.
50+
51+
Prefers ``uipath.json#id``, falls back to env vars.
52+
"""
53+
from uipath.platform.common._config import UiPathConfig
54+
55+
return _read_config_id() or UiPathConfig.agent_id or UiPathConfig.project_key
56+
57+
2258
class AttachmentProvider(IntEnum):
2359
ORCHESTRATOR = 0
2460

@@ -281,9 +317,11 @@ def otel_span_to_uipath_span(
281317
]
282318
attributes_dict["links"] = links_list
283319

320+
if agent_id := resolve_project_id():
321+
attributes_dict["agentId"] = agent_id
322+
284323
# Add process context attributes from environment variables
285324
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287325
("UIPATH_PROCESS_KEY", "agentName"),
288326
("UIPATH_PROCESS_VERSION", "agentVersion"),
289327
):
@@ -297,10 +335,8 @@ def otel_span_to_uipath_span(
297335
# Top-level fields for internal tracing schema
298336
execution_type = attributes_dict.get("executionType")
299337
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")
338+
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
339+
"referenceId"
304340
)
305341
verbosity_level = attributes_dict.get("verbosityLevel")
306342

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_project_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_project_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.84"
3+
version = "2.11.0"
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.66, <0.2.0",
10+
"uipath-platform>=0.1.67, <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",

0 commit comments

Comments
 (0)