Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.2.32"
version = "0.2.33"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
46 changes: 39 additions & 7 deletions packages/uipath-platform/src/uipath/platform/common/_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ def _inject_reference_hierarchy(span: Span) -> None:
span.set_attribute("uipath.reference_hierarchy", json.dumps(wire))


def _hierarchy_leaf_reference_id(
hierarchy: Optional[List[Dict[str, Any]]],
) -> Optional[str]:
"""Return the innermost (last) entry's ``referenceId``, if usable."""
if not hierarchy:
return None
leaf = hierarchy[-1]
if not isinstance(leaf, dict):
return None
leaf_id = leaf.get("referenceId")
return leaf_id if isinstance(leaf_id, str) and leaf_id else None


class ReferenceHierarchySpanProcessor(SpanProcessor):
"""Stamps uipath.reference_hierarchy on every span at creation time.

Expand Down Expand Up @@ -417,6 +430,14 @@ def otel_span_to_uipath_span(
# correct thread/context; BatchSpanProcessor exports in a background thread
# where ContextVar values are not available).
ref_hierarchy_json = attributes_dict.pop("uipath.reference_hierarchy", None)
reference_hierarchy: Optional[List[Dict[str, Any]]] = None
if ref_hierarchy_json:
try:
parsed_hierarchy = json.loads(ref_hierarchy_json)
except (json.JSONDecodeError, TypeError):
parsed_hierarchy = None
if isinstance(parsed_hierarchy, list) and parsed_hierarchy:
reference_hierarchy = parsed_hierarchy

# Map status
status = SpanStatus.OK
Expand Down Expand Up @@ -494,8 +515,22 @@ def otel_span_to_uipath_span(
_EXECUTION_TYPE_BY_INT, attributes_dict.get("executionType")
)
agent_version = attributes_dict.get("agentVersion")
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
"referenceId"
# The runtime stamps `referenceId` and then pushes that same id onto the
# reference hierarchy, so the hierarchy leaf is a *consequence* of the
# reference id — not its source. Read `referenceId` first: when a service
# fails to push (non-UUID id), the leaf still holds its *caller's* id, and
# deriving from it would attribute the span to the wrong service.
#
# The leaf is the fallback for producers that push onto the hierarchy
# without stamping the attribute (e.g. the langgraph runtime).
#
# `agentId` is last: resolve_project_id() yields a *project* id
# (uipath.json#id / UIPATH_PROJECT_ID), which is not the running agent's id
# and would disagree with the hierarchy.
reference_id = (
attributes_dict.get("referenceId")
or _hierarchy_leaf_reference_id(reference_hierarchy)
or attributes_dict.get("agentId")
)
verbosity_level = _enum_from_raw(
_VERBOSITY_LEVEL_BY_INT, attributes_dict.get("verbosityLevel")
Expand Down Expand Up @@ -535,11 +570,8 @@ def otel_span_to_uipath_span(
logger.warning(f"Error processing attachments: {e}")

context: Optional[Dict[str, Any]] = None
if ref_hierarchy_json:
try:
context = {"referenceHierarchy": json.loads(ref_hierarchy_json)}
except (json.JSONDecodeError, TypeError):
pass
if reference_hierarchy:
context = {"referenceHierarchy": reference_hierarchy}

# Create UiPathSpan from OpenTelemetry span
start_time = datetime.fromtimestamp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from opentelemetry.sdk.trace import Span as OTelSpan
from opentelemetry.trace import SpanContext, StatusCode

from uipath.platform.common import _SpanUtils
from uipath.platform.common import UiPathSpan, _SpanUtils
from uipath.platform.common._reference_context import (
ReferenceContext,
ReferenceContextAccessor,
Expand Down Expand Up @@ -581,3 +581,73 @@ def test_hook_noop_when_context_not_set(self) -> None:
mock_span.set_attribute.assert_not_called()
finally:
ReferenceContextAccessor.reset(token)


class TestReferenceIdMatchesHierarchyLeaf:
"""`ReferenceId` and the innermost `referenceHierarchy` entry must agree.

`resolve_project_id()` yields a *project* id (``uipath.json#id`` /
``UIPATH_PROJECT_ID`` / ``PROJECT_KEY``), which is unrelated to the agent id
the runtime pushes onto the reference hierarchy. When it used to win the
`ReferenceId` race, every deployed agent span shipped a `ReferenceId` that
disagreed with its own hierarchy leaf.
"""

AGENT_ID = "550e8400-e29b-41d4-a716-446655440001"
PROJECT_ID = "550e8400-e29b-41d4-a716-4466554400ff"

def _convert(self, monkeypatch: pytest.MonkeyPatch) -> UiPathSpan:
from uipath.platform.common._span_utils import _read_config_id
from uipath.platform.constants import (
ENV_PROJECT_KEY,
ENV_UIPATH_AGENT_ID,
ENV_UIPATH_PROJECT_ID,
)

_read_config_id.cache_clear()
monkeypatch.delenv(ENV_UIPATH_AGENT_ID, raising=False)
monkeypatch.delenv(ENV_PROJECT_KEY, raising=False)
# Stands in for a populated `uipath.json#id`: a project id that differs
# from the agent id on the hierarchy.
monkeypatch.setenv(ENV_UIPATH_PROJECT_ID, self.PROJECT_ID)

ref_ctx = ReferenceContext.Empty.add("agent", self.AGENT_ID, "1.0.0")
span = _make_mock_span(
{
# What AgentRunSpanAttributes / apply_attributes stamp.
"agentId": self.AGENT_ID,
"referenceId": self.AGENT_ID,
"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list()),
}
)
return _SpanUtils.otel_span_to_uipath_span(span)

def test_reference_id_equals_hierarchy_leaf(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
uipath_span = self._convert(monkeypatch)

assert uipath_span.context is not None
leaf = uipath_span.context["referenceHierarchy"][-1]
assert uipath_span.reference_id == leaf["referenceId"]

def test_reference_id_is_agent_id_not_project_id(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
uipath_span = self._convert(monkeypatch)

assert uipath_span.reference_id == self.AGENT_ID
assert uipath_span.reference_id != self.PROJECT_ID

def test_hierarchy_still_emitted(self, monkeypatch: pytest.MonkeyPatch) -> None:
uipath_span = self._convert(monkeypatch)

assert uipath_span.context == {
"referenceHierarchy": [
{
"serviceType": "agent",
"referenceId": self.AGENT_ID,
"version": "1.0.0",
}
]
}
93 changes: 81 additions & 12 deletions packages/uipath-platform/tests/services/test_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,39 +321,108 @@ def test_source_accepts_int_and_string(self, raw, expected) -> None:
class TestReferenceIdResolution:
"""`reference_id` resolution chain.

`reference_id` is derived from the span's resolved `agentId` attribute
(which itself goes through `resolve_project_id()`), falling back to the
`referenceId` attribute. Falsy values (missing / empty string) at each step
fall through to the next source. The `referenceId` fallback exists for
backwards compatibility with older producers that only emit that attribute.
The runtime stamps `referenceId` and pushes that same id onto the reference
hierarchy, so `referenceId` is the source and the hierarchy leaf is the
consequence. `referenceId` therefore wins; the leaf is only a fallback for
producers that push onto the hierarchy without stamping the attribute. The
resolved `agentId` attribute is last — it goes through `resolve_project_id()`
and carries a *project* id, not the running agent's id. Falsy values at each
step fall through to the next source.
"""

@pytest.mark.parametrize(
("env_value", "attributes", "expected"),
[
pytest.param(
"env-agent",
{"agentId": "attr-agent", "referenceId": "attr-ref"},
{
"agentId": "attr-agent",
"referenceId": "attr-ref",
"uipath.reference_hierarchy": json.dumps(
[
{"serviceType": "maestro", "referenceId": "hier-outer"},
{"serviceType": "agent", "referenceId": "hier-leaf"},
]
),
},
"attr-ref",
id="reference-id-attr-wins-over-hierarchy-leaf",
),
pytest.param(
None,
{
"uipath.reference_hierarchy": json.dumps(
[
{"serviceType": "maestro", "referenceId": "hier-outer"},
{"serviceType": "langgraph", "referenceId": "hier-leaf"},
]
)
},
"hier-leaf",
id="hierarchy-leaf-when-reference-id-attr-absent",
),
pytest.param(
# A service that failed to push its own entry leaves the caller's
# id at the leaf. Deriving from it would misattribute the span.
None,
{
"referenceId": "this-agent",
"uipath.reference_hierarchy": json.dumps(
[{"serviceType": "maestro", "referenceId": "the-caller"}]
),
},
"this-agent",
id="unpushed-entry-does-not-borrow-callers-id",
),
pytest.param(
"env-agent",
id="env-var-overrides-attr",
{"agentId": "attr-agent", "referenceId": "attr-ref"},
"attr-ref",
id="reference-id-attr-beats-resolved-project-id",
),
pytest.param(
None,
{"agentId": "attr-agent", "referenceId": "attr-ref"},
"attr-agent",
id="agent-id-attr-when-env-unset",
"attr-ref",
id="reference-id-attr-beats-agent-id-attr",
),
pytest.param(
None,
{"referenceId": "attr-ref"},
"attr-ref",
id="reference-id-fallback-when-agent-id-missing",
id="reference-id-when-agent-id-missing",
),
pytest.param(
None,
{"agentId": "attr-agent"},
"attr-agent",
id="agent-id-fallback-when-reference-id-missing",
),
pytest.param(
None,
{"agentId": "attr-agent", "referenceId": ""},
"attr-agent",
id="agent-id-fallback-when-reference-id-empty",
),
pytest.param(
"env-agent",
{
"referenceId": "attr-ref",
"uipath.reference_hierarchy": "not-json",
},
"attr-ref",
id="malformed-hierarchy-falls-through",
),
pytest.param(
None,
{"agentId": "", "referenceId": "attr-ref"},
{
"referenceId": "attr-ref",
"uipath.reference_hierarchy": json.dumps(
[{"serviceType": "agent"}]
),
},
"attr-ref",
id="reference-id-fallback-when-agent-id-empty",
id="hierarchy-leaf-without-reference-id-falls-through",
),
pytest.param(
None,
Expand Down
2 changes: 1 addition & 1 deletion packages/uipath-platform/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/uipath/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading