diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 762af38c6..1b57a68a4 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -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" diff --git a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py index 111dd59ec..95e19e6c1 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -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. @@ -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 @@ -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") @@ -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( diff --git a/packages/uipath-platform/tests/services/test_reference_context.py b/packages/uipath-platform/tests/services/test_reference_context.py index 38be5aebf..55f54a378 100644 --- a/packages/uipath-platform/tests/services/test_reference_context.py +++ b/packages/uipath-platform/tests/services/test_reference_context.py @@ -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, @@ -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", + } + ] + } diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index b80d24e9d..210c7a498 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -321,11 +321,13 @@ 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( @@ -333,27 +335,94 @@ class TestReferenceIdResolution: [ 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, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index e084735b1..54ab45e3d 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "." } dependencies = [ { name = "anyio" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index bc92233bb..9ca759335 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2762,7 +2762,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.2.32" +version = "0.2.33" source = { editable = "../uipath-platform" } dependencies = [ { name = "anyio" },