Skip to content

Commit 47096b4

Browse files
committed
feat: add stable agentId to uipath.json for packaging and spans
1 parent fcd0c94 commit 47096b4

12 files changed

Lines changed: 462 additions & 57 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: 48 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,45 @@
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 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("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+
42+
def resolve_agent_id() -> Optional[str]:
43+
"""Resolve the agent id from a single, ordered set of sources.
44+
45+
Order: ``uipath.json#agentId`` (the stable id minted at init time) ->
46+
``UIPATH_AGENT_ID`` env var -> legacy ``PROJECT_KEY`` env var injected by
47+
the executor. All span fields carrying an agent identity must go through
48+
this helper so they cannot diverge.
49+
"""
50+
from uipath.platform.common.constants import (
51+
ENV_PROJECT_KEY,
52+
ENV_UIPATH_AGENT_ID,
53+
)
54+
55+
return (
56+
_read_config_agent_id()
57+
or env.get(ENV_UIPATH_AGENT_ID)
58+
or env.get(ENV_PROJECT_KEY)
59+
)
60+
61+
2262
class AttachmentProvider(IntEnum):
2363
ORCHESTRATOR = 0
2464

@@ -281,9 +321,14 @@ def otel_span_to_uipath_span(
281321
]
282322
attributes_dict["links"] = links_list
283323

324+
# agentId: prefer the stable id from uipath.json (cached), falling back
325+
# to the legacy env vars injected by the executor.
326+
agent_id = resolve_agent_id()
327+
if agent_id:
328+
attributes_dict["agentId"] = agent_id
329+
284330
# Add process context attributes from environment variables
285331
for env_key, attr_key in (
286-
("PROJECT_KEY", "agentId"),
287332
("UIPATH_PROCESS_KEY", "agentName"),
288333
("UIPATH_PROCESS_VERSION", "agentVersion"),
289334
):
@@ -297,10 +342,8 @@ def otel_span_to_uipath_span(
297342
# Top-level fields for internal tracing schema
298343
execution_type = attributes_dict.get("executionType")
299344
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")
345+
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
346+
"referenceId"
304347
)
305348
verbosity_level = attributes_dict.get("verbosityLevel")
306349

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

Lines changed: 143 additions & 5 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,6 +151,10 @@ 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()
157+
monkeypatch.delenv("PROJECT_KEY", raising=False)
143158
if env_value is None:
144159
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
145160
else:
@@ -166,6 +181,129 @@ def test_reference_id_chain(
166181
assert uipath_span.reference_id == expected
167182

168183

184+
class TestAgentIdResolution:
185+
"""`agentId` span attribute resolution via `resolve_agent_id()`.
186+
187+
Priority: `uipath.json#agentId` (cached, read once per process) >
188+
`UIPATH_AGENT_ID` env var > the legacy `PROJECT_KEY` env var injected by
189+
the executor. When no source is present the `agentId` attribute is omitted
190+
entirely.
191+
"""
192+
193+
@staticmethod
194+
def _make_span() -> Mock:
195+
mock_span = Mock(spec=OTelSpan)
196+
mock_context = SpanContext(
197+
trace_id=0x123456789ABCDEF0123456789ABCDEF0,
198+
span_id=0x0123456789ABCDEF,
199+
is_remote=False,
200+
)
201+
mock_span.get_span_context.return_value = mock_context
202+
mock_span.name = "test-span"
203+
mock_span.parent = None
204+
mock_span.status.status_code = StatusCode.OK
205+
mock_span.attributes = {}
206+
mock_span.events = []
207+
mock_span.links = []
208+
now_ns = int(datetime.now().timestamp() * 1e9)
209+
mock_span.start_time = now_ns
210+
mock_span.end_time = now_ns + 1_000_000
211+
return mock_span
212+
213+
@staticmethod
214+
def _resolve(monkeypatch: pytest.MonkeyPatch, tmp_path) -> object:
215+
from uipath.platform.common._span_utils import _read_config_agent_id
216+
217+
_read_config_agent_id.cache_clear()
218+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
219+
monkeypatch.delenv("UIPATH_AGENT_ID", raising=False)
220+
monkeypatch.chdir(tmp_path)
221+
uipath_span = _SpanUtils.otel_span_to_uipath_span(
222+
TestAgentIdResolution._make_span(), serialize_attributes=False
223+
)
224+
attributes = uipath_span.attributes
225+
assert isinstance(attributes, dict)
226+
return attributes.get("agentId")
227+
228+
def test_agent_id_from_uipath_json_wins_over_env(
229+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
230+
) -> None:
231+
(tmp_path / "uipath.json").write_text(json.dumps({"agentId": "from-config"}))
232+
monkeypatch.setenv("PROJECT_KEY", "from-env")
233+
assert self._resolve(monkeypatch, tmp_path) == "from-config"
234+
235+
def test_agent_id_from_uipath_json_wins_over_agent_id_env(
236+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
237+
) -> None:
238+
(tmp_path / "uipath.json").write_text(json.dumps({"agentId": "from-config"}))
239+
monkeypatch.setenv("UIPATH_AGENT_ID", "from-agent-env")
240+
# _resolve clears UIPATH_AGENT_ID, so set it back after the fixture work
241+
# is done via a direct resolve call instead.
242+
from uipath.platform.common._span_utils import (
243+
_read_config_agent_id,
244+
resolve_agent_id,
245+
)
246+
247+
_read_config_agent_id.cache_clear()
248+
monkeypatch.chdir(tmp_path)
249+
assert resolve_agent_id() == "from-config"
250+
251+
def test_agent_id_env_wins_over_project_key_env(
252+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
253+
) -> None:
254+
# No uipath.json on disk.
255+
from uipath.platform.common._span_utils import (
256+
_read_config_agent_id,
257+
resolve_agent_id,
258+
)
259+
260+
_read_config_agent_id.cache_clear()
261+
monkeypatch.chdir(tmp_path)
262+
monkeypatch.setenv("UIPATH_AGENT_ID", "from-agent-env")
263+
monkeypatch.setenv("PROJECT_KEY", "from-project-key")
264+
assert resolve_agent_id() == "from-agent-env"
265+
266+
def test_agent_id_falls_back_to_project_key_env(
267+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
268+
) -> None:
269+
# No uipath.json on disk.
270+
monkeypatch.setenv("PROJECT_KEY", "from-env")
271+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
272+
273+
def test_agent_id_falls_back_when_config_has_no_agent_id(
274+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
275+
) -> None:
276+
(tmp_path / "uipath.json").write_text(json.dumps({"functions": {}}))
277+
monkeypatch.setenv("PROJECT_KEY", "from-env")
278+
assert self._resolve(monkeypatch, tmp_path) == "from-env"
279+
280+
def test_agent_id_absent_when_no_source(
281+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
282+
) -> None:
283+
monkeypatch.delenv("PROJECT_KEY", raising=False)
284+
assert self._resolve(monkeypatch, tmp_path) is None
285+
286+
def test_config_agent_id_is_cached(
287+
self, monkeypatch: pytest.MonkeyPatch, tmp_path
288+
) -> None:
289+
from uipath.platform.common._span_utils import _read_config_agent_id
290+
291+
_read_config_agent_id.cache_clear()
292+
monkeypatch.delenv("UIPATH_CONFIG_PATH", raising=False)
293+
monkeypatch.chdir(tmp_path)
294+
config = tmp_path / "uipath.json"
295+
296+
config.write_text(json.dumps({"agentId": "first"}))
297+
assert _read_config_agent_id() == "first"
298+
299+
# A later edit is not observed: the value is read once and cached.
300+
config.write_text(json.dumps({"agentId": "second"}))
301+
assert _read_config_agent_id() == "first"
302+
303+
_read_config_agent_id.cache_clear()
304+
assert _read_config_agent_id() == "second"
305+
306+
169307
class TestNormalizeIds:
170308
"""Tests for OTEL ID normalization functions."""
171309

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/_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: 18 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,26 @@ 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. Edit the raw JSON so the
439+
# rest of the user's file (key order, omitted defaults) is
440+
# left untouched.
441+
with open(config_path, "r") as f:
442+
raw_config = json.load(f)
443+
if not raw_config.get("agentId"):
444+
raw_config["agentId"] = resolve_existing_project_id(
445+
current_directory
446+
) or str(uuid.uuid4())
447+
with open(config_path, "w") as f:
448+
json.dump(raw_config, f, indent=2)
449+
console.success(
450+
f"{Action.UPDATED.value} '{config_path}' file with 'agentId'."
451+
)
436452

437453
# Create bindings.json if it doesn't exist
438454
bindings_path = UiPathConfig.bindings_file_path

0 commit comments

Comments
 (0)