Skip to content

Commit c5bb52b

Browse files
committed
feat: add stable agentId to uipath.json for packaging and spans
1 parent f234583 commit c5bb52b

12 files changed

Lines changed: 353 additions & 46 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/common/_span_utils.py

Lines changed: 26 additions & 1 deletion
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,25 @@
1920
DEFAULT_SOURCE = 10
2021

2122

23+
@lru_cache(maxsize=1)
24+
def _read_config_agent_id() -> Optional[str]:
25+
"""Return ``agentId`` 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 ``agentId`` field is absent, so
29+
the caller can fall back to the legacy ``PROJECT_KEY`` env var.
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("agentId")
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+
2242
class AttachmentProvider(IntEnum):
2343
ORCHESTRATOR = 0
2444

@@ -281,9 +301,14 @@ def otel_span_to_uipath_span(
281301
]
282302
attributes_dict["links"] = links_list
283303

304+
# agentId: prefer the stable id from uipath.json (cached), falling back
305+
# to the legacy PROJECT_KEY env var injected by the executor.
306+
agent_id = _read_config_agent_id() or env.get("PROJECT_KEY")
307+
if agent_id:
308+
attributes_dict["agentId"] = agent_id
309+
284310
# Add process context attributes from environment variables
285311
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287312
("UIPATH_PROCESS_KEY", "agentName"),
288313
("UIPATH_PROCESS_VERSION", "agentVersion"),
289314
):

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

Lines changed: 101 additions & 0 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
@@ -166,6 +176,97 @@ def test_reference_id_chain(
166176
assert uipath_span.reference_id == expected
167177

168178

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

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.73"
3+
version = "2.10.74"
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/_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

packages/uipath/src/uipath/_cli/cli_init.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from ._utils._common import determine_project_type
3737
from ._utils._console import ConsoleLogger
3838
from ._utils._constants import AGENT_INITIAL_CODE_VERSION, SCHEMA_VERSION
39-
from ._utils._project_files import read_toml_project
39+
from ._utils._project_files import read_toml_project, resolve_existing_project_id
4040
from .middlewares import Middlewares
4141
from .models.runtime_schema import Bindings, EntryPoint
4242
from .models.uipath_json_schema import UiPathJsonConfig
@@ -429,10 +429,22 @@ async def initialize() -> list[UiPathRuntimeSchema]:
429429
config_path = UiPathConfig.config_file_path
430430
if not config_path.exists():
431431
config = UiPathJsonConfig.create_default()
432+
config.agent_id = resolve_existing_project_id(
433+
current_directory
434+
) or str(uuid.uuid4())
432435
config.save_to_file(config_path)
433436
console.success(f"{Action.CREATED.value} '{config_path}' file.")
434437
else:
435-
console.info(f"'{config_path}' already exists, skipping.")
438+
# backfill agentId if not present
439+
config = UiPathJsonConfig.load_from_file(str(config_path))
440+
if not config.agent_id:
441+
config.agent_id = resolve_existing_project_id(
442+
current_directory
443+
) or str(uuid.uuid4())
444+
config.save_to_file(config_path)
445+
console.success(
446+
f"{Action.UPDATED.value} '{config_path}' file with 'agentId'."
447+
)
436448

437449
# Create bindings.json if it doesn't exist
438450
bindings_path = UiPathConfig.bindings_file_path

packages/uipath/src/uipath/_cli/cli_pack.py

Lines changed: 10 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,10 @@
88
from pydantic import TypeAdapter
99

1010
from uipath._cli.models.runtime_schema import Bindings, EntryPoint, EntryPoints
11-
from uipath._cli.models.uipath_json_schema import RuntimeOptions, UiPathJsonConfig
11+
from uipath._cli.models.uipath_json_schema import UiPathJsonConfig
1212
from uipath.eval.constants import EVALS_FOLDER, LEGACY_EVAL_FOLDER
1313
from uipath.platform.common import UiPathConfig
1414

15-
from ..telemetry._constants import _PROJECT_KEY, _TELEMETRY_CONFIG_FILE
1615
from ._telemetry import track_command
1716
from ._utils._common import determine_project_type
1817
from ._utils._console import ConsoleLogger
@@ -21,6 +20,7 @@
2120
files_to_include,
2221
get_project_config,
2322
read_toml_project,
23+
resolve_existing_project_id,
2424
validate_config,
2525
)
2626
from ._utils._uv_helpers import handle_uv_operations
@@ -30,31 +30,6 @@
3030
schema = "https://cloud.uipath.com/draft/2024-12/entry-point"
3131

3232

33-
def get_project_id() -> str:
34-
"""Get project ID from telemetry file if it exists, otherwise generate a new one.
35-
36-
Returns:
37-
Project ID string (either from telemetry file or newly generated).
38-
"""
39-
# first check if this is a studio project
40-
if project_id := UiPathConfig.project_id:
41-
return project_id
42-
43-
telemetry_file = os.path.join(".uipath", _TELEMETRY_CONFIG_FILE)
44-
45-
if os.path.exists(telemetry_file):
46-
try:
47-
with open(telemetry_file, "r") as f:
48-
telemetry_data = json.load(f)
49-
project_id = telemetry_data.get(_PROJECT_KEY)
50-
if project_id:
51-
return project_id
52-
except (json.JSONDecodeError, IOError):
53-
pass
54-
55-
return str(uuid.uuid4())
56-
57-
5833
def get_project_version(directory):
5934
toml_path = os.path.join(directory, "pyproject.toml")
6035
if not os.path.exists(toml_path):
@@ -72,14 +47,18 @@ def validate_config_structure(config_data):
7247

7348

7449
def generate_operate_file(
75-
entrypoints: list[EntryPoint], runtimeOptions: RuntimeOptions, dependencies=None
50+
entrypoints: list[EntryPoint],
51+
config: UiPathJsonConfig,
52+
dependencies=None,
7653
):
7754
if not entrypoints:
7855
raise ValueError(
7956
"No entry points found in entry-points.json. Please run 'uipath init' to generate valid entry points."
8057
)
8158

82-
project_id = get_project_id()
59+
# prefer agentId from uipath.json; fall back to the legacy
60+
# .telemetry.json or SW project id.
61+
project_id = config.agent_id or resolve_existing_project_id() or str(uuid.uuid4())
8362

8463
project_type = determine_project_type(entrypoints)
8564
first_entry = entrypoints[0]
@@ -94,7 +73,7 @@ def generate_operate_file(
9473
"runtimeOptions": {
9574
"requiresUserInteraction": False,
9675
"isAttended": False,
97-
"isConversational": runtimeOptions.is_conversational,
76+
"isConversational": config.runtime_options.is_conversational,
9877
},
9978
}
10079

@@ -238,9 +217,7 @@ def pack_fn(
238217
with open(config_path, "r") as f:
239218
config_data = TypeAdapter(UiPathJsonConfig).validate_python(json.load(f))
240219

241-
operate_file = generate_operate_file(
242-
entrypoints, config_data.runtime_options, dependencies
243-
)
220+
operate_file = generate_operate_file(entrypoints, config_data, dependencies)
244221

245222
# try to read bindings from bindings.json
246223
bindings_path = os.path.join(directory, str(UiPathConfig.bindings_file_path))

packages/uipath/src/uipath/_cli/models/uipath_json_schema.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ class UiPathJsonConfig(BaseModelWithDefaultConfig):
6868
alias="$schema",
6969
description="Reference to the JSON schema for editor support",
7070
)
71+
agent_id: str | None = Field(
72+
default=None,
73+
alias="agentId",
74+
description="Stable unique identifier for the agent. Minted once at "
75+
"project creation (by 'uipath init' or Studio Web) and preserved for the "
76+
"lifetime of the project. Used as the package 'projectId' at pack time.",
77+
)
7178
runtime_options: RuntimeOptions = Field(
7279
default_factory=RuntimeOptions,
7380
alias="runtimeOptions",

0 commit comments

Comments
 (0)