diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index d1601847e..6ea57d530 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.91" +version = "0.1.92" 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/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index 8f4b23d57..888197974 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -20,9 +20,15 @@ from ._folder_context import FolderContext, header_folder from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs from ._models import Endpoint, RequestSpec +from ._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, + ReferenceEntry, +) from ._service_url_overrides import inject_routing_headers, resolve_service_url from ._span_utils import ( ExecutionType, + ReferenceHierarchySpanProcessor, SpanSource, SpanStatus, UiPathSpan, @@ -117,6 +123,10 @@ "ResourceOverwrite", "ResourceOverwriteParser", "ResourceOverwritesContext", + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", + "ReferenceHierarchySpanProcessor", "SpanSource", "SpanStatus", "UiPathSpan", diff --git a/packages/uipath-platform/src/uipath/platform/common/_reference_context.py b/packages/uipath-platform/src/uipath/platform/common/_reference_context.py new file mode 100644 index 000000000..fc67a5e84 --- /dev/null +++ b/packages/uipath-platform/src/uipath/platform/common/_reference_context.py @@ -0,0 +1,260 @@ +"""Immutable reference-hierarchy context for span propagation. + +Follows the same design as service-common BaggageContext: +- Immutable, copy-on-write — each mutating call returns a NEW instance so + sibling spans cannot bleed context into each other. +- ContextVar-backed accessor — flows across await boundaries without + threading the value through every function signature. +- Wire format compatible with the ``ref.*`` keys in ``x-uipath-tracebaggage`` + so context parsed by service-common middleware is understood here and + vice-versa. +""" +from __future__ import annotations + +import contextvars +import uuid +from dataclasses import dataclass +from typing import ClassVar, Dict, Iterator, List, Optional, Tuple + + +__all__ = [ + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", + "BAGGAGE_HEADER_NAME", + "BAGGAGE_KEY_TYPE", + "BAGGAGE_KEY_ID", + "BAGGAGE_KEY_VERSION", +] + +BAGGAGE_HEADER_NAME = "x-uipath-tracebaggage" + +# Key names — matches service-common ReferenceHierarchyKeys +BAGGAGE_KEY_TYPE = "ref.type" +BAGGAGE_KEY_ID = "ref.id" +BAGGAGE_KEY_VERSION = "ref.v" + + +@dataclass(frozen=True) +class ReferenceEntry: + """A single node in the reference hierarchy call chain.""" + + service_type: str + reference_id: str # UUID string + version: Optional[str] = None + + +class ReferenceContext: + """Immutable, copy-on-write ordered list of reference entries. + + Outermost caller first, current service appended last. + Each mutating call returns a new instance — the original is never + modified, preventing sibling spans from sharing context. + + Usage:: + + ctx = ReferenceContext.Empty + ctx = ctx.add("maestro", process_id, "2.1.0") + ctx = ctx.add("agent", agent_id) + token = ReferenceContextAccessor.set(ctx) + try: + ... + finally: + ReferenceContextAccessor.reset(token) + """ + + Empty: ClassVar["ReferenceContext"] + __slots__ = ("_entries",) + + def __init__(self, entries: Tuple[ReferenceEntry, ...] = ()) -> None: + self._entries: Tuple[ReferenceEntry, ...] = entries + + @property + def entries(self) -> Tuple[ReferenceEntry, ...]: + return self._entries + + def __len__(self) -> int: + return len(self._entries) + + def __iter__(self) -> Iterator[ReferenceEntry]: + return iter(self._entries) + + def __bool__(self) -> bool: + return len(self._entries) > 0 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ReferenceContext): + return NotImplemented + return self._entries == other._entries + + def __hash__(self) -> int: + return hash(self._entries) + + def add( + self, + service_type: str, + reference_id: str | uuid.UUID, + version: Optional[str] = None, + ) -> "ReferenceContext": + """Returns a new context with this entry appended (copy-on-write). + + Args: + service_type: Identifier for the calling service (e.g. ``"agent"``, + ``"maestro"``). + reference_id: UUID of the referenced entity (UUID object or string). + version: Optional version string. + + Returns: + A new :class:`ReferenceContext` with the entry appended. + """ + if not service_type or not service_type.strip(): + raise ValueError("service_type must be a non-empty string.") + if isinstance(reference_id, uuid.UUID): + id_str = str(reference_id) + elif isinstance(reference_id, str): + try: + id_str = str(uuid.UUID(reference_id)) + except ValueError: + raise ValueError( + f"reference_id {reference_id!r} is not a valid UUID." + ) + else: + raise TypeError("reference_id must be a UUID or string.") + entry = ReferenceEntry( + service_type=service_type, + reference_id=id_str, + version=version if version and version.strip() else None, + ) + return ReferenceContext(self._entries + (entry,)) + + def to_wire_list(self) -> List[Dict[str, str]]: + """Serialize to the ``referenceHierarchy`` wire format. + + Returns: + A list of dicts suitable for JSON serialization as + ``Context.referenceHierarchy`` in the span payload. + """ + result: List[Dict[str, str]] = [] + for e in self._entries: + item: Dict[str, str] = { + "serviceType": e.service_type, + "referenceId": e.reference_id, + } + if e.version: + item["version"] = e.version + result.append(item) + return result + + @staticmethod + def from_baggage_header(header_value: Optional[str]) -> "ReferenceContext": + """Parse ``x-uipath-tracebaggage`` header value into a ReferenceContext. + + Only entries that carry the ``ref.*`` shape (type + valid UUID id) are + included. Malformed or plain-KV entries are silently skipped so a bad + header from an upstream service cannot crash this one. + + Args: + header_value: Raw header string, e.g. + ``"ref.type=agent;ref.id=;ref.v=1.0,ref.type=maestro;ref.id="`` + + Returns: + Parsed :class:`ReferenceContext`, or :attr:`ReferenceContext.Empty` + if the header is absent, empty, or contains no valid ref entries. + """ + if not header_value or not header_value.strip(): + return ReferenceContext.Empty + + entries: List[ReferenceEntry] = [] + for raw_entry in header_value.split(","): + entry_text = raw_entry.strip() + if not entry_text: + continue + props: dict[str, str] = {} + for raw_pair in entry_text.split(";"): + pair_text = raw_pair.strip() + eq = pair_text.find("=") + if eq <= 0 or eq >= len(pair_text) - 1: + continue + key = pair_text[:eq].strip() + value = pair_text[eq + 1:].strip() + if key and value: + props[key] = value + + type_v = props.get(BAGGAGE_KEY_TYPE) + id_v = props.get(BAGGAGE_KEY_ID) + if not type_v or not id_v: + continue + try: + parsed_uuid = uuid.UUID(id_v) + except (ValueError, AttributeError): + continue + entries.append( + ReferenceEntry( + service_type=type_v, + reference_id=str(parsed_uuid), + version=props.get(BAGGAGE_KEY_VERSION) or None, + ) + ) + + if not entries: + return ReferenceContext.Empty + return ReferenceContext(tuple(entries)) + + def to_baggage_header_value(self) -> str: + """Serialize to ``x-uipath-tracebaggage`` header value. + + Returns: + Comma-separated entries; each is a semicolon-separated list of + ``key=value`` pairs. Empty context returns ``""``. + """ + if not self._entries: + return "" + parts: List[str] = [] + for e in self._entries: + kv = f"{BAGGAGE_KEY_TYPE}={e.service_type};{BAGGAGE_KEY_ID}={e.reference_id}" + if e.version: + kv += f";{BAGGAGE_KEY_VERSION}={e.version}" + parts.append(kv) + return ",".join(parts) + + +# Assigned after class body so ReferenceContext is fully bound. +ReferenceContext.Empty = ReferenceContext() + + +class ReferenceContextAccessor: + """Ambient accessor for the current :class:`ReferenceContext`. + + Backed by :mod:`contextvars` so the value propagates across ``await`` + boundaries without being threaded through every call signature. + + Usage:: + + token = ReferenceContextAccessor.set(ctx) + try: + ... # code here sees ReferenceContextAccessor.get() == ctx + finally: + ReferenceContextAccessor.reset(token) + """ + + _current: contextvars.ContextVar[Optional[ReferenceContext]] = ( + contextvars.ContextVar("uipath_reference_context", default=None) + ) + + @classmethod + def get(cls) -> Optional[ReferenceContext]: + """Return the current ambient context, or ``None`` if not set.""" + return cls._current.get() + + @classmethod + def set(cls, value: Optional[ReferenceContext]) -> contextvars.Token[Optional[ReferenceContext]]: + """Set the ambient context. Returns a token for restoration. + + Pass the token to :meth:`reset` in a ``finally`` block. + """ + return cls._current.set(value) + + @classmethod + def reset(cls, token: contextvars.Token[Optional[ReferenceContext]]) -> None: + """Restore the ambient context to its prior value.""" + cls._current.reset(token) 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 3d23bfa5a..185de933a 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -10,11 +10,11 @@ from os import environ as env from typing import Any, Dict, List, Optional, TypeVar -from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry import context as context_api +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from opentelemetry.trace import StatusCode from pydantic import BaseModel, ConfigDict, Field from uipath.core.serialization import serialize_json - from uipath.platform.constants import ( ENV_FOLDER_KEY, ENV_JOB_KEY, @@ -26,6 +26,30 @@ ENV_UIPATH_TRACE_ID, ) +from ._reference_context import ReferenceContextAccessor + + +def _inject_reference_hierarchy(span: Span) -> None: + ref_ctx = ReferenceContextAccessor.get() + if ref_ctx: + wire = ref_ctx.to_wire_list() + if wire: + span.set_attribute("uipath.reference_hierarchy", json.dumps(wire)) + + +class ReferenceHierarchySpanProcessor(SpanProcessor): + """Stamps uipath.reference_hierarchy on every span at creation time. + + Runs on_start in the span-creating thread so ContextVar values are live. + Register this before any processor that reads the attribute on on_start + (e.g. LiveTrackingSpanProcessor). + """ + + def on_start( + self, span: Span, parent_context: context_api.Context | None = None + ) -> None: + _inject_reference_hierarchy(span) + logger = logging.getLogger(__name__) @@ -245,6 +269,7 @@ class UiPathSpan: agent_version: Optional[str] = None verbosity_level: Optional[VerbosityLevel] = None attachments: Optional[List[SpanAttachment]] = None + context: Optional[Dict[str, Any]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: """Convert the Span to a dictionary suitable for JSON serialization. @@ -304,6 +329,8 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: del result[guid_key] if self.verbosity_level is not None: result["VerbosityLevel"] = self.verbosity_level + if self.context is not None: + result["Context"] = self.context return result @@ -380,6 +407,11 @@ def otel_span_to_uipath_span( # Only copy if we need to modify - we'll build attributes_dict lazily attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} + # Pull the reference hierarchy stamped by the span-start hook (runs in the + # 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) + # Map status status = SpanStatus.OK if otel_span.status.status_code == StatusCode.ERROR: @@ -496,6 +528,13 @@ def otel_span_to_uipath_span( except Exception as e: 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 + # Create UiPathSpan from OpenTelemetry span start_time = datetime.fromtimestamp( (otel_span.start_time or 0) / 1e9 @@ -527,6 +566,7 @@ def otel_span_to_uipath_span( reference_id=reference_id, source=source, attachments=attachments, + context=context, ) @staticmethod diff --git a/packages/uipath-platform/tests/services/test_reference_context.py b/packages/uipath-platform/tests/services/test_reference_context.py new file mode 100644 index 000000000..547a720fe --- /dev/null +++ b/packages/uipath-platform/tests/services/test_reference_context.py @@ -0,0 +1,345 @@ +"""Tests for ReferenceContext, ReferenceContextAccessor, and span Context wiring.""" + +import json +from datetime import datetime +from unittest.mock import Mock + +import pytest +from opentelemetry.sdk.trace import Span as OTelSpan +from opentelemetry.trace import SpanContext, StatusCode + +from uipath.platform.common import _SpanUtils +from uipath.platform.common._reference_context import ReferenceContext, ReferenceContextAccessor + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_mock_span(attributes: dict[str, str] | None = None) -> Mock: + mock = Mock(spec=OTelSpan) + mock.get_span_context.return_value = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock.name = "test-span" + mock.parent = None + mock.status.status_code = StatusCode.OK + mock.attributes = attributes or {} + mock.events = [] + mock.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock.start_time = now_ns + mock.end_time = now_ns + 1_000_000 + return mock + + +# --------------------------------------------------------------------------- +# ReferenceContext — immutability & copy-on-write +# --------------------------------------------------------------------------- + +class TestReferenceContextImmutability: + def test_empty_singleton_is_falsy(self) -> None: + assert not ReferenceContext.Empty + + def test_add_returns_new_instance(self) -> None: + base = ReferenceContext.Empty + child = base.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert child is not base + + def test_original_unmodified_after_add(self) -> None: + base = ReferenceContext.Empty + base.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert len(base) == 0 + + def test_siblings_do_not_share_entries(self) -> None: + base = ReferenceContext.Empty.add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0") + child_a = base.add("agent", "550e8400-e29b-41d4-a716-446655440011") + child_b = base.add("agent", "550e8400-e29b-41d4-a716-446655440012") + + assert len(base) == 1 + assert len(child_a) == 2 + assert len(child_b) == 2 + assert child_a.entries[1].reference_id != child_b.entries[1].reference_id + + def test_equality_and_hash(self) -> None: + a = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + b = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert a == b + assert hash(a) == hash(b) + + def test_different_entries_not_equal(self) -> None: + a = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + b = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440002") + assert a != b + + +# --------------------------------------------------------------------------- +# ReferenceContext.add — validation & UUID coercion +# --------------------------------------------------------------------------- + +class TestReferenceContextAdd: + def test_add_with_string_uuid(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001", "1.0") + assert len(ctx) == 1 + e = ctx.entries[0] + assert e.service_type == "agent" + assert e.reference_id == "550e8400-e29b-41d4-a716-446655440001" + assert e.version == "1.0" + + def test_add_with_uuid_object(self) -> None: + import uuid + uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440001") + ctx = ReferenceContext.Empty.add("agent", uid) + assert ctx.entries[0].reference_id == str(uid) + + def test_add_without_version(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + assert ctx.entries[0].version is None + + def test_add_blank_version_normalised_to_none(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001", " ") + assert ctx.entries[0].version is None + + def test_add_empty_service_type_raises(self) -> None: + with pytest.raises(ValueError, match="service_type"): + ReferenceContext.Empty.add("", "550e8400-e29b-41d4-a716-446655440001") + + def test_add_invalid_reference_id_type_raises(self) -> None: + with pytest.raises(TypeError, match="reference_id"): + ReferenceContext.Empty.add("agent", 12345) # type: ignore[arg-type] + + def test_entries_ordered_oldest_first(self) -> None: + ctx = ( + ReferenceContext.Empty + .add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0") + .add("agent", "550e8400-e29b-41d4-a716-446655440011") + ) + assert ctx.entries[0].service_type == "maestro" + assert ctx.entries[1].service_type == "agent" + + +# --------------------------------------------------------------------------- +# ReferenceContext.to_wire_list +# --------------------------------------------------------------------------- + +class TestToWireList: + def test_empty_produces_empty_list(self) -> None: + assert ReferenceContext.Empty.to_wire_list() == [] + + def test_single_entry_with_version(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001", "1.0.0") + wire = ctx.to_wire_list() + assert wire == [ + {"serviceType": "agent", "referenceId": "550e8400-e29b-41d4-a716-446655440001", "version": "1.0.0"} + ] + + def test_single_entry_without_version_omits_key(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + wire = ctx.to_wire_list() + assert "version" not in wire[0] + + def test_multiple_entries_order_preserved(self) -> None: + ctx = ( + ReferenceContext.Empty + .add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.1.0") + .add("agent", "550e8400-e29b-41d4-a716-446655440011") + ) + wire = ctx.to_wire_list() + assert len(wire) == 2 + assert wire[0]["serviceType"] == "maestro" + assert wire[0]["version"] == "2.1.0" + assert wire[1]["serviceType"] == "agent" + assert "version" not in wire[1] + + +# --------------------------------------------------------------------------- +# ReferenceContext.from_baggage_header / to_baggage_header_value round-trip +# --------------------------------------------------------------------------- + +class TestBaggageHeaderRoundTrip: + def test_round_trip_single_entry_with_version(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001", "1.0") + assert ReferenceContext.from_baggage_header(ctx.to_baggage_header_value()) == ctx + + def test_round_trip_multiple_entries(self) -> None: + ctx = ( + ReferenceContext.Empty + .add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0") + .add("agent", "550e8400-e29b-41d4-a716-446655440011") + ) + assert ReferenceContext.from_baggage_header(ctx.to_baggage_header_value()) == ctx + + def test_empty_header_returns_empty(self) -> None: + assert ReferenceContext.from_baggage_header("") == ReferenceContext.Empty + assert ReferenceContext.from_baggage_header(None) == ReferenceContext.Empty + + def test_malformed_entry_skipped_silently(self) -> None: + # Only the second entry is valid + header = "not-a-valid-entry,ref.type=agent;ref.id=550e8400-e29b-41d4-a716-446655440001" + ctx = ReferenceContext.from_baggage_header(header) + assert len(ctx) == 1 + assert ctx.entries[0].service_type == "agent" + + def test_entry_with_invalid_uuid_skipped(self) -> None: + header = "ref.type=agent;ref.id=not-a-uuid" + ctx = ReferenceContext.from_baggage_header(header) + assert ctx == ReferenceContext.Empty + + def test_entry_missing_type_skipped(self) -> None: + header = "ref.id=550e8400-e29b-41d4-a716-446655440001" + ctx = ReferenceContext.from_baggage_header(header) + assert ctx == ReferenceContext.Empty + + def test_empty_context_produces_empty_header(self) -> None: + assert ReferenceContext.Empty.to_baggage_header_value() == "" + + +# --------------------------------------------------------------------------- +# ReferenceContextAccessor — ContextVar semantics +# --------------------------------------------------------------------------- + +class TestReferenceContextAccessor: + def setup_method(self) -> None: + # Ensure clean state before each test + current = ReferenceContextAccessor.get() + if current is not None: + token = ReferenceContextAccessor.set(None) + # immediately reset to avoid polluting other tests + ReferenceContextAccessor.reset(token) + + def test_default_is_none(self) -> None: + assert ReferenceContextAccessor.get() is None + + def test_set_and_get(self) -> None: + ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + token = ReferenceContextAccessor.set(ctx) + try: + assert ReferenceContextAccessor.get() == ctx + finally: + ReferenceContextAccessor.reset(token) + + def test_reset_restores_prior_value(self) -> None: + ctx_a = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + ctx_b = ctx_a.add("langgraph", "550e8400-e29b-41d4-a716-446655440002") + + token_a = ReferenceContextAccessor.set(ctx_a) + token_b = ReferenceContextAccessor.set(ctx_b) + + assert ReferenceContextAccessor.get() == ctx_b + ReferenceContextAccessor.reset(token_b) + assert ReferenceContextAccessor.get() == ctx_a + ReferenceContextAccessor.reset(token_a) + assert ReferenceContextAccessor.get() is None + + +# --------------------------------------------------------------------------- +# UiPathSpan.context wiring via otel_span_to_uipath_span +# --------------------------------------------------------------------------- + +class TestContextWiring: + def test_context_absent_when_no_reference_context_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + span = _SpanUtils.otel_span_to_uipath_span(_make_mock_span()) + assert span.context is None + assert "Context" not in span.to_dict() + + def test_context_present_when_reference_context_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001", "1.0" + ) + mock = _make_mock_span( + attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())} + ) + span = _SpanUtils.otel_span_to_uipath_span(mock) + assert span.context == { + "referenceHierarchy": [ + { + "serviceType": "agent", + "referenceId": "550e8400-e29b-41d4-a716-446655440001", + "version": "1.0", + } + ] + } + wire = span.to_dict() + assert "Context" in wire + assert wire["Context"]["referenceHierarchy"][0]["serviceType"] == "agent" + + def test_context_carries_full_hierarchy( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ( + ReferenceContext.Empty + .add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0") + .add("agent", "550e8400-e29b-41d4-a716-446655440011") + ) + mock = _make_mock_span( + attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())} + ) + wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict() + hierarchy = wire["Context"]["referenceHierarchy"] + assert len(hierarchy) == 2 + assert hierarchy[0]["serviceType"] == "maestro" + assert hierarchy[0]["version"] == "2.0" + assert hierarchy[1]["serviceType"] == "agent" + assert "version" not in hierarchy[1] + + def test_reference_hierarchy_not_in_attributes_field( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org") + ref_ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001") + mock = _make_mock_span( + attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())} + ) + wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict() + attributes = json.loads(wire["Attributes"]) + assert "uipath.reference_hierarchy" not in attributes + + +# --------------------------------------------------------------------------- +# _inject_reference_hierarchy hook +# --------------------------------------------------------------------------- + +class TestReferenceHierarchyHook: + def setup_method(self) -> None: + token = ReferenceContextAccessor.set(None) + ReferenceContextAccessor.reset(token) + + def test_hook_stamps_attribute_when_context_set(self) -> None: + from uipath.platform.common._span_utils import _inject_reference_hierarchy + + ref_ctx = ReferenceContext.Empty.add( + "agent", "550e8400-e29b-41d4-a716-446655440001" + ) + token = ReferenceContextAccessor.set(ref_ctx) + try: + mock_span = Mock() + _inject_reference_hierarchy(mock_span) + mock_span.set_attribute.assert_called_once() + key, value = mock_span.set_attribute.call_args[0] + assert key == "uipath.reference_hierarchy" + hierarchy = json.loads(value) + assert len(hierarchy) == 1 + assert hierarchy[0]["serviceType"] == "agent" + assert hierarchy[0]["referenceId"] == "550e8400-e29b-41d4-a716-446655440001" + finally: + ReferenceContextAccessor.reset(token) + + def test_hook_noop_when_context_not_set(self) -> None: + from uipath.platform.common._span_utils import _inject_reference_hierarchy + + token = ReferenceContextAccessor.set(None) + try: + mock_span = Mock() + _inject_reference_hierarchy(mock_span) + mock_span.set_attribute.assert_not_called() + finally: + ReferenceContextAccessor.reset(token) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 561499d12..76339dd56 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "uipath" -version = "2.12.7" +version = "2.12.8" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.26, <0.6.0", "uipath-runtime>=0.11.7, <0.12.0", - "uipath-platform>=0.1.91, <0.2.0", + "uipath-platform>=0.1.92, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", diff --git a/packages/uipath/src/uipath/_cli/cli_debug.py b/packages/uipath/src/uipath/_cli/cli_debug.py index 92fc3ffea..9d3e0f107 100644 --- a/packages/uipath/src/uipath/_cli/cli_debug.py +++ b/packages/uipath/src/uipath/_cli/cli_debug.py @@ -13,6 +13,7 @@ from uipath.eval.mocks._mock_runtime import load_simulation_config from uipath.platform.common import ( ExecutionSourceContext, + ReferenceHierarchySpanProcessor, ResourceOverwritesContext, UiPathConfig, ) @@ -126,6 +127,7 @@ def debug( async def execute_debug_runtime(): trace_manager = UiPathTraceManager() + trace_manager.add_span_processor(ReferenceHierarchySpanProcessor()) ctx = UiPathRuntimeContext.with_defaults( input=input, diff --git a/packages/uipath/src/uipath/_cli/cli_dev.py b/packages/uipath/src/uipath/_cli/cli_dev.py index f6d7021bb..dc1bb4988 100644 --- a/packages/uipath/src/uipath/_cli/cli_dev.py +++ b/packages/uipath/src/uipath/_cli/cli_dev.py @@ -7,7 +7,10 @@ from uipath._cli._utils._debug import setup_debugging from uipath._cli.middlewares import Middlewares from uipath.core.tracing import UiPathTraceManager -from uipath.platform.common import ExecutionSourceContext +from uipath.platform.common import ( + ExecutionSourceContext, + ReferenceHierarchySpanProcessor, +) from uipath.runtime import UiPathRuntimeContext, UiPathRuntimeFactoryRegistry from ._telemetry import track_command @@ -87,6 +90,7 @@ async def run_terminal() -> None: factory = None try: trace_manager = UiPathTraceManager() + trace_manager.add_span_processor(ReferenceHierarchySpanProcessor()) context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperConsole( @@ -128,6 +132,7 @@ def signal_handler(sig, frame): try: trace_manager = UiPathTraceManager() + trace_manager.add_span_processor(ReferenceHierarchySpanProcessor()) context, factory = _create_dev_context_and_factory(trace_manager) app = UiPathDeveloperServer( diff --git a/packages/uipath/src/uipath/_cli/cli_eval.py b/packages/uipath/src/uipath/_cli/cli_eval.py index 310909e59..d4f9ef827 100644 --- a/packages/uipath/src/uipath/_cli/cli_eval.py +++ b/packages/uipath/src/uipath/_cli/cli_eval.py @@ -23,6 +23,7 @@ from uipath.platform.chat import set_llm_concurrency from uipath.platform.common import ( ExecutionSourceContext, + ReferenceHierarchySpanProcessor, ResourceOverwritesContext, UiPathConfig, ) @@ -313,6 +314,7 @@ async def execute_eval(): await telemetry_subscriber.subscribe_to_eval_runtime_events(event_bus) trace_manager = UiPathTraceManager() + trace_manager.add_span_processor(ReferenceHierarchySpanProcessor()) ctx = UiPathRuntimeContext.with_defaults( output_file=output_file, diff --git a/packages/uipath/src/uipath/_cli/cli_run.py b/packages/uipath/src/uipath/_cli/cli_run.py index 32b245b6f..525dd4584 100644 --- a/packages/uipath/src/uipath/_cli/cli_run.py +++ b/packages/uipath/src/uipath/_cli/cli_run.py @@ -12,6 +12,7 @@ from uipath.eval.mocks import SimulationConfig, UiPathMockRuntime, build_mocking_context from uipath.platform.common import ( ExecutionSourceContext, + ReferenceHierarchySpanProcessor, ResourceOverwritesContext, UiPathConfig, ) @@ -193,6 +194,7 @@ async def debug_runtime( async def execute() -> None: trace_manager = UiPathTraceManager() + trace_manager.add_span_processor(ReferenceHierarchySpanProcessor()) ctx = UiPathRuntimeContext.with_defaults( entrypoint=entrypoint, diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index 4fcf2b2db..a586e04b9 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -1,6 +1,11 @@ """Tracing utilities and OpenTelemetry exporters.""" from uipath.core import traced +from uipath.platform.common._reference_context import ( + ReferenceContext, + ReferenceContextAccessor, + ReferenceEntry, +) from uipath.platform.common._span_utils import ( AttachmentDirection, AttachmentProvider, @@ -25,4 +30,7 @@ "AttachmentProvider", "SpanAttachment", "VerbosityLevel", + "ReferenceEntry", + "ReferenceContext", + "ReferenceContextAccessor", ]