diff --git a/packages/uipath-platform/src/uipath/platform/common/__init__.py b/packages/uipath-platform/src/uipath/platform/common/__init__.py index cefd92075..6a0e12863 100644 --- a/packages/uipath-platform/src/uipath/platform/common/__init__.py +++ b/packages/uipath-platform/src/uipath/platform/common/__init__.py @@ -22,7 +22,14 @@ from ._http_config import get_ca_bundle_path, get_httpx_client_kwargs from ._models import Endpoint, RequestSpec from ._service_url_overrides import inject_routing_headers, resolve_service_url -from ._span_utils import UiPathSpan, _SpanUtils +from ._span_utils import ( + ExecutionType, + SpanSource, + SpanStatus, + UiPathSpan, + VerbosityLevel, + _SpanUtils, +) from ._url import UiPathUrl from ._user_agent import user_agent_value from .auth import TokenData @@ -104,11 +111,15 @@ "jsonschema_to_pydantic", "ConnectionResourceOverwrite", "EntityResourceOverwrite", + "ExecutionType", "GenericResourceOverwrite", "ResourceOverwrite", "ResourceOverwriteParser", "ResourceOverwritesContext", + "SpanSource", + "SpanStatus", "UiPathSpan", + "VerbosityLevel", "_SpanUtils", "resolve_service_url", "inject_routing_headers", 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 ab91b3623..3f50b4246 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -4,7 +4,7 @@ import os from dataclasses import dataclass, field from datetime import datetime -from enum import IntEnum +from enum import IntEnum, StrEnum from os import environ as env from typing import Any, Dict, List, Optional @@ -15,8 +15,62 @@ logger = logging.getLogger(__name__) -# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 10 + +class SpanStatus(StrEnum): + UNSET = "Unset" + OK = "Ok" + ERROR = "Error" + RUNNING = "Running" + RESTRICTED = "Restricted" + CANCELLED = "Cancelled" + + +class SpanSource(StrEnum): + AGENTS = "Agents" + PROCESS_ORCHESTRATION = "ProcessOrchestration" + API_WORKFLOWS = "ApiWorkflows" + ROBOTS = "Robots" + CODED_AGENTS = "CodedAgents" + + +class VerbosityLevel(StrEnum): + VERBOSE = "Verbose" + TRACE = "Trace" + INFORMATION = "Information" + WARNING = "Warning" + ERROR = "Error" + CRITICAL = "Critical" + OFF = "Off" + + +class ExecutionType(StrEnum): + DEBUG = "Debug" + RUNTIME = "Runtime" + + +# Int→StrEnum lookup tables for converting raw OTEL attribute integers +_EXECUTION_TYPE_BY_INT: dict[int, ExecutionType] = { + 0: ExecutionType.DEBUG, + 1: ExecutionType.RUNTIME, +} + +_VERBOSITY_LEVEL_BY_INT: dict[int, VerbosityLevel] = { + 0: VerbosityLevel.VERBOSE, + 1: VerbosityLevel.TRACE, + 2: VerbosityLevel.INFORMATION, + 3: VerbosityLevel.WARNING, + 4: VerbosityLevel.ERROR, + 5: VerbosityLevel.CRITICAL, + 6: VerbosityLevel.OFF, +} + +_SOURCE_BY_INT: dict[int, SpanSource] = { + 1: SpanSource.AGENTS, + 2: SpanSource.PROCESS_ORCHESTRATION, + 3: SpanSource.API_WORKFLOWS, + 4: SpanSource.ROBOTS, + 10: SpanSource.CODED_AGENTS, +} class AttachmentProvider(IntEnum): @@ -29,16 +83,6 @@ class AttachmentDirection(IntEnum): OUT = 2 -class VerbosityLevel(IntEnum): - VERBOSE = 0 - TRACE = 1 - INFORMATION = 2 - WARNING = 3 - ERROR = 4 - CRITICAL = 5 - OFF = 6 - - class SpanAttachment(BaseModel): """Represents an attachment in the UiPath tracing system.""" @@ -70,7 +114,7 @@ class UiPathSpan: parent_id: Optional[str] = None # 16-char hex (OTEL span ID format) start_time: str = field(default_factory=lambda: datetime.now().isoformat()) end_time: str = field(default_factory=lambda: datetime.now().isoformat()) - status: int = 1 + status: SpanStatus = SpanStatus.OK created_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") updated_at: str = field(default_factory=lambda: datetime.now().isoformat() + "Z") organization_id: Optional[str] = field( @@ -83,7 +127,7 @@ class UiPathSpan: folder_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") ) - source: int = DEFAULT_SOURCE + source: SpanSource = SpanSource.CODED_AGENTS span_type: str = "Coded Agents" process_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_PROCESS_UUID") @@ -95,9 +139,9 @@ class UiPathSpan: job_key: Optional[str] = field(default_factory=lambda: env.get("UIPATH_JOB_KEY")) # Top-level fields for internal tracing schema - execution_type: Optional[int] = None + execution_type: Optional[ExecutionType] = None agent_version: Optional[str] = None - verbosity_level: Optional[int] = None + verbosity_level: Optional[VerbosityLevel] = None attachments: Optional[List[SpanAttachment]] = None def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: @@ -228,9 +272,9 @@ def otel_span_to_uipath_span( attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {} # Map status - status = 1 # Default to OK + status = SpanStatus.OK if otel_span.status.status_code == StatusCode.ERROR: - status = 2 # Error + status = SpanStatus.ERROR attributes_dict["error"] = otel_span.status.description # Process inputs - avoid redundant parsing if already parsed @@ -295,18 +339,32 @@ def otel_span_to_uipath_span( span_type = str(span_type_value) # Top-level fields for internal tracing schema - execution_type = attributes_dict.get("executionType") + execution_type_raw = attributes_dict.get("executionType") + execution_type: Optional[ExecutionType] = ( + _EXECUTION_TYPE_BY_INT.get(execution_type_raw) + if isinstance(execution_type_raw, int) + else None + ) agent_version = attributes_dict.get("agentVersion") reference_id = ( env.get("UIPATH_AGENT_ID") or attributes_dict.get("agentId") or attributes_dict.get("referenceId") ) - verbosity_level = attributes_dict.get("verbosityLevel") + verbosity_level_raw = attributes_dict.get("verbosityLevel") + verbosity_level: Optional[VerbosityLevel] = ( + _VERBOSITY_LEVEL_BY_INT.get(verbosity_level_raw) + if isinstance(verbosity_level_raw, int) + else None + ) - # Source: override via uipath.source attribute, else DEFAULT_SOURCE - uipath_source = attributes_dict.get("uipath.source") - source = uipath_source if isinstance(uipath_source, int) else DEFAULT_SOURCE + # Source: override via uipath.source attribute, else CodedAgents + uipath_source_raw = attributes_dict.get("uipath.source") + source: SpanSource = ( + _SOURCE_BY_INT.get(uipath_source_raw, SpanSource.CODED_AGENTS) + if isinstance(uipath_source_raw, int) + else SpanSource.CODED_AGENTS + ) attachments = None attachments_data = attributes_dict.get("attachments") diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 03f728eb8..002e6c61b 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -8,6 +8,48 @@ from opentelemetry.trace import SpanContext, StatusCode from uipath.platform.common import UiPathSpan, _SpanUtils +from uipath.platform.common._span_utils import ( + ExecutionType, + SpanSource, + SpanStatus, + VerbosityLevel, +) + + +class TestStrEnums: + def test_span_status_string_values(self): + assert SpanStatus.UNSET == "Unset" + assert SpanStatus.OK == "Ok" + assert SpanStatus.ERROR == "Error" + assert SpanStatus.RUNNING == "Running" + assert SpanStatus.RESTRICTED == "Restricted" + assert SpanStatus.CANCELLED == "Cancelled" + + def test_span_source_string_values(self): + assert SpanSource.CODED_AGENTS == "CodedAgents" + assert SpanSource.AGENTS == "Agents" + assert SpanSource.PROCESS_ORCHESTRATION == "ProcessOrchestration" + assert SpanSource.API_WORKFLOWS == "ApiWorkflows" + assert SpanSource.ROBOTS == "Robots" + + def test_verbosity_level_string_values(self): + assert VerbosityLevel.VERBOSE == "Verbose" + assert VerbosityLevel.TRACE == "Trace" + assert VerbosityLevel.INFORMATION == "Information" + assert VerbosityLevel.WARNING == "Warning" + assert VerbosityLevel.ERROR == "Error" + assert VerbosityLevel.CRITICAL == "Critical" + assert VerbosityLevel.OFF == "Off" + + def test_execution_type_string_values(self): + assert ExecutionType.DEBUG == "Debug" + assert ExecutionType.RUNTIME == "Runtime" + + def test_enums_are_strings(self): + assert isinstance(SpanStatus.OK, str) + assert isinstance(SpanSource.CODED_AGENTS, str) + assert isinstance(VerbosityLevel.INFORMATION, str) + assert isinstance(ExecutionType.RUNTIME, str) class TestOTelToUiPathSpan: @@ -21,16 +63,18 @@ class TestOTelToUiPathSpan: """ ATTRIBUTE_FIELD_MAP = [ - ("executionType", "execution_type", "ExecutionType", 1), - ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), - ("agentId", "reference_id", "ReferenceId", "ref-abc"), - ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6), + # (otel_attr, span_field, top_level_key, otel_input_int, expected_output) + ("executionType", "execution_type", "ExecutionType", 1, ExecutionType.RUNTIME), + ("agentVersion", "agent_version", "AgentVersion", "1.2.3", "1.2.3"), + ("agentId", "reference_id", "ReferenceId", "ref-abc", "ref-abc"), + ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6, VerbosityLevel.OFF), ] @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_attributes_map_to_top_level_fields(self) -> None: attrs = { - otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP + otel_attr: otel_input + for otel_attr, _, _, otel_input, _ in self.ATTRIBUTE_FIELD_MAP } mock_span = Mock(spec=OTelSpan) @@ -53,9 +97,15 @@ def test_attributes_map_to_top_level_fields(self) -> None: uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - for _, span_field, top_level_key, value in self.ATTRIBUTE_FIELD_MAP: - assert getattr(uipath_span, span_field) == value, span_field - assert span_dict[top_level_key] == value, top_level_key + for ( + _, + span_field, + top_level_key, + _, + expected_output, + ) in self.ATTRIBUTE_FIELD_MAP: + assert getattr(uipath_span, span_field) == expected_output, span_field + assert span_dict[top_level_key] == expected_output, top_level_key @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_verbosity_level_omitted_when_unset(self) -> None: @@ -260,7 +310,7 @@ def test_otel_span_to_uipath_span(self): # Verify the conversion assert isinstance(uipath_span, UiPathSpan) assert uipath_span.name == "test-span" - assert uipath_span.status == 1 # OK + assert uipath_span.status == SpanStatus.OK assert uipath_span.span_type == "CustomSpanType" # Verify IDs are in OTEL hex format @@ -282,7 +332,7 @@ def test_otel_span_to_uipath_span(self): mock_span.status.description = "Test error description" mock_span.status.status_code = StatusCode.ERROR uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - assert uipath_span.status == 2 # Error + assert uipath_span.status == SpanStatus.ERROR @patch.dict( os.environ, @@ -434,8 +484,8 @@ def test_uipath_span_includes_execution_type(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["ExecutionType"] == 0 - assert uipath_span.execution_type == 0 + assert span_dict["ExecutionType"] == ExecutionType.DEBUG + assert uipath_span.execution_type == ExecutionType.DEBUG @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_includes_agent_version(self): @@ -488,7 +538,7 @@ def test_uipath_span_execution_type_and_agent_version_both(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["ExecutionType"] == 1 + assert span_dict["ExecutionType"] == ExecutionType.RUNTIME assert span_dict["AgentVersion"] == "1.0.0" @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) @@ -520,7 +570,7 @@ def test_uipath_span_missing_execution_type_and_agent_version(self): @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_source_defaults_to_coded_agents(self): - """Test that Source defaults to 10 (CodedAgents) and ignores attributes.source.""" + """Test that Source defaults to CodedAgents and ignores attributes.source.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -543,9 +593,9 @@ def test_uipath_span_source_defaults_to_coded_agents(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - # Top-level Source should be 10 (CodedAgents), string "runtime" is ignored - assert uipath_span.source == 10 - assert span_dict["Source"] == 10 + # Top-level Source should be CodedAgents, string "runtime" is ignored + assert uipath_span.source == SpanSource.CODED_AGENTS + assert span_dict["Source"] == "CodedAgents" # attributes.source string should still be in Attributes JSON attrs = json.loads(span_dict["Attributes"]) @@ -577,9 +627,114 @@ def test_uipath_span_source_override_with_uipath_source(self): span_dict = uipath_span.to_dict() # uipath.source overrides - low-code agents use 1 (Agents) - assert uipath_span.source == 1 - assert span_dict["Source"] == 1 + assert uipath_span.source == SpanSource.AGENTS + assert span_dict["Source"] == "Agents" # String source still in Attributes JSON attrs = json.loads(span_dict["Attributes"]) assert attrs["source"] == "runtime" + + +class TestUiPathSpanDictUsesStrings: + def test_default_status_is_ok_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + ) + d = span.to_dict() + assert d["Status"] == "Ok" + + def test_default_source_is_coded_agents_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + ) + d = span.to_dict() + assert d["Source"] == "CodedAgents" + + def test_verbosity_level_serializes_as_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + verbosity_level=VerbosityLevel.OFF, + ) + d = span.to_dict() + assert d["VerbosityLevel"] == "Off" + + def test_execution_type_serializes_as_string(self): + span = UiPathSpan( + id="a" * 16, + trace_id="b" * 32, + name="test", + attributes={}, + execution_type=ExecutionType.RUNTIME, + ) + d = span.to_dict() + assert d["ExecutionType"] == "Runtime" + + +class TestOtelSpanConversionUsesStrEnums: + def _make_mock_span(self, status_code=StatusCode.OK, attributes=None): + from datetime import datetime + from unittest.mock import Mock + + from opentelemetry.trace import SpanContext + + mock_span = Mock() + mock_context = SpanContext( + trace_id=0x123456789ABCDEF0123456789ABCDEF0, + span_id=0x0123456789ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-span" + mock_span.parent = None + mock_span.status.status_code = status_code + mock_span.status.description = None + mock_span.attributes = attributes or {} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_ok_status_maps_to_str_enum(self): + span = _SpanUtils.otel_span_to_uipath_span(self._make_mock_span()) + assert span.status == SpanStatus.OK + assert span.to_dict()["Status"] == "Ok" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_error_status_maps_to_str_enum(self): + mock_span = self._make_mock_span(status_code=StatusCode.ERROR) + mock_span.status.description = "something went wrong" + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.status == SpanStatus.ERROR + assert span.to_dict()["Status"] == "Error" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_default_source_is_coded_agents(self): + span = _SpanUtils.otel_span_to_uipath_span(self._make_mock_span()) + assert span.source == SpanSource.CODED_AGENTS + assert span.to_dict()["Source"] == "CodedAgents" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_execution_type_int_maps_to_str_enum(self): + mock_span = self._make_mock_span(attributes={"executionType": 1}) + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.execution_type == ExecutionType.RUNTIME + assert span.to_dict()["ExecutionType"] == "Runtime" + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_verbosity_level_int_maps_to_str_enum(self): + mock_span = self._make_mock_span(attributes={"verbosityLevel": 6}) + span = _SpanUtils.otel_span_to_uipath_span(mock_span) + assert span.verbosity_level == VerbosityLevel.OFF + assert span.to_dict()["VerbosityLevel"] == "Off" diff --git a/packages/uipath/src/uipath/tracing/__init__.py b/packages/uipath/src/uipath/tracing/__init__.py index e6c37bc99..4fcf2b2db 100644 --- a/packages/uipath/src/uipath/tracing/__init__.py +++ b/packages/uipath/src/uipath/tracing/__init__.py @@ -5,6 +5,7 @@ AttachmentDirection, AttachmentProvider, SpanAttachment, + SpanStatus, VerbosityLevel, ) @@ -12,7 +13,6 @@ from ._otel_exporters import ( # noqa: D104 JsonLinesFileExporter, LlmOpsHttpExporter, - SpanStatus, ) __all__ = [ diff --git a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py index 85bcca1ba..203504611 100644 --- a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py +++ b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py @@ -5,7 +5,8 @@ from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from uipath.core.tracing import UiPathTraceSettings -from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus +from uipath.platform.common._span_utils import SpanStatus +from uipath.tracing._otel_exporters import LlmOpsHttpExporter logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ def __init__( ) def _upsert_span_async( - self, span: Span | ReadableSpan, status_override: int | None = None + self, span: Span | ReadableSpan, status_override: SpanStatus | None = None ) -> None: """Run upsert_span in a background thread without blocking. diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index d2bf3a7c1..6137c9e60 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -13,6 +13,7 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs from uipath.platform.common import _SpanUtils +from uipath.platform.common._span_utils import SpanStatus from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES logger = logging.getLogger(__name__) @@ -24,17 +25,6 @@ def _normalize_process_key(value: Optional[str]) -> Optional[str]: return None if not value or value == _NIL_UUID else value -class SpanStatus: - """Span status values matching LLMOps StatusEnum.""" - - UNSET = 0 - OK = 1 - ERROR = 2 - RUNNING = 3 - RESTRICTED = 4 - CANCELLED = 5 - - def _safe_parse_json(s: Any) -> Any: """Safely parse a JSON string, returning the original if not a string or on error.""" if not isinstance(s, str): @@ -106,11 +96,6 @@ class LlmOpsHttpExporter(SpanExporter): # Add more mappings as needed } - class Status: - SUCCESS = 1 - ERROR = 2 - INTERRUPTED = 3 - def __init__( self, trace_id: Optional[str] = None, @@ -148,7 +133,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: return SpanExportResult.SUCCESS logger.debug( - f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/spans" + f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/v3/spans" ) # Use optimized path: keep attributes as dict for processing @@ -188,7 +173,7 @@ def force_flush(self, timeout_millis: int = 30000) -> bool: def upsert_span( self, span: ReadableSpan, - status_override: Optional[int] = None, + status_override: Optional[SpanStatus] = None, ) -> SpanExportResult: """Upsert a single span to LLMOps for real-time state updates. @@ -312,12 +297,12 @@ def _map_tool_call_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any return result - def _determine_status(self, error: Optional[Any]) -> int: + def _determine_status(self, error: Optional[Any]) -> SpanStatus: if error: if isinstance(error, str) and error.startswith("GraphInterrupt("): - return self.Status.INTERRUPTED - return self.Status.ERROR - return self.Status.SUCCESS + return SpanStatus.CANCELLED + return SpanStatus.ERROR + return SpanStatus.OK def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: """Extracts, transforms, and maps attributes for a span in-place. @@ -389,7 +374,9 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None: def _build_url(self, span_list: list[Dict[str, Any]]) -> str: """Construct the URL for the API request.""" trace_id = str(span_list[0]["TraceId"]) - return f"{self.base_url}/api/Traces/spans?traceId={trace_id}&source=CodedAgents" + return ( + f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source=CodedAgents" + ) def _send_with_retries( self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4 diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index fc5a370c0..73445cf7b 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -7,10 +7,8 @@ from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.sdk.trace.export import SpanExportResult -from uipath.tracing._otel_exporters import ( - LlmOpsHttpExporter, - SpanStatus, -) +from uipath.platform.common._span_utils import SpanStatus +from uipath.tracing._otel_exporters import LlmOpsHttpExporter @pytest.fixture @@ -54,7 +52,7 @@ def exporter(mock_env_vars): exporter = LlmOpsHttpExporter() # Mock _build_url to include query parameters as in the actual implementation exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -107,7 +105,7 @@ def test_export_success(exporter, mock_span): [{"span": "data", "TraceId": "test-trace-id"}] ) exporter.http_client.post.assert_called_once_with( - "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents", + "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents", json=[{"span": "data", "TraceId": "test-trace-id"}], ) @@ -277,6 +275,38 @@ def test_send_with_retries_success(): ) +def test_build_url_uses_v3_endpoint(mock_env_vars): + """_build_url must point to /api/Traces/v3/spans, not /api/Traces/spans.""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + span_list = [{"TraceId": "ab" * 16}] + url = exporter._build_url(span_list) + assert "/api/Traces/v3/spans" in url + # Ensure the v2 path (without /v3/) is not present + assert "/api/Traces/spans" not in url.replace("/api/Traces/v3/spans", "") + + +def test_determine_status_ok_returns_string(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + assert exporter._determine_status(None) == "Ok" + assert exporter._determine_status(None) == SpanStatus.OK + + +def test_determine_status_error_returns_string(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + assert exporter._determine_status("some error") == "Error" + assert exporter._determine_status("some error") == SpanStatus.ERROR + + +def test_determine_status_graph_interrupt_returns_cancelled(mock_env_vars): + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + assert exporter._determine_status("GraphInterrupt()") == "Cancelled" + assert exporter._determine_status("GraphInterrupt()") == SpanStatus.CANCELLED + + class TestLangchainExporter(unittest.TestCase): def setUp(self): self.exporter = LlmOpsHttpExporter() @@ -685,7 +715,7 @@ def exporter_with_mocks(self, mock_env_vars): with patch("uipath.tracing._otel_exporters.httpx.Client"): exporter = LlmOpsHttpExporter() exporter._build_url = MagicMock( # type: ignore - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/spans?traceId=test-trace-id&source=CodedAgents" + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" ) yield exporter @@ -820,7 +850,86 @@ def test_uipath_tracing_reexports_verbosity_level(self) -> None: from uipath.tracing import VerbosityLevel as _TracingVerbosity assert _TracingVerbosity is _CommonVerbosity - assert _TracingVerbosity.OFF == 6 + assert _TracingVerbosity.OFF == "Off" + + +class TestV3EndToEnd: + """Integration-style tests verifying string enum values reach the v3 URL end-to-end.""" + + def _make_real_otel_span(self, status_code=None): + """Build a minimal mock OTel ReadableSpan with a real SpanContext.""" + from datetime import datetime + from unittest.mock import Mock + + from opentelemetry.trace import SpanContext, StatusCode + + if status_code is None: + status_code = StatusCode.OK + + mock_span = Mock(spec=ReadableSpan) + mock_context = SpanContext( + trace_id=0xABCDEF1234567890ABCDEF1234567890, + span_id=0x1234567890ABCDEF, + is_remote=False, + ) + mock_span.get_span_context.return_value = mock_context + mock_span.name = "test-v3-span" + mock_span.parent = None + mock_span.status.status_code = status_code + mock_span.status.description = None + mock_span.attributes = {"uipath.custom_instrumentation": True} + mock_span.events = [] + mock_span.links = [] + now_ns = int(datetime.now().timestamp() * 1e9) + mock_span.start_time = now_ns + mock_span.end_time = now_ns + 1_000_000 + return mock_span + + def test_export_posts_to_v3_url_with_string_enums(self, mock_env_vars): + """Exporting a span must POST to /api/Traces/v3/spans with string Status and Source.""" + from opentelemetry.trace import StatusCode + + otel_span = self._make_real_otel_span(status_code=StatusCode.OK) + + with patch("uipath.tracing._otel_exporters.httpx.Client") as mock_client_cls: + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_client.post.return_value = mock_response + + exporter = LlmOpsHttpExporter() + result = exporter.export([otel_span]) + + assert result == SpanExportResult.SUCCESS + + # Verify the POST was made + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + + # URL must contain v3/spans + posted_url = ( + call_args.args[0] if call_args.args else call_args.kwargs.get("url", "") + ) + assert "v3/spans" in posted_url, f"Expected v3/spans in URL, got: {posted_url}" + + # Body must contain string enum values, not integers + payload: list[dict[str, object]] = call_args.kwargs.get("json") or call_args.args[1] + assert len(payload) == 1 + span_payload = payload[0] + + # Status should be the string "Ok", not integer 1 + assert span_payload["Status"] == "Ok", ( + f"Expected Status='Ok' (string), got {span_payload['Status']!r}" + ) + assert span_payload["Status"] != 1, "Status must not be integer 1 (v2 format)" + + # Source should be the string "CodedAgents", not integer 10 + assert span_payload["Source"] == "CodedAgents", ( + f"Expected Source='CodedAgents' (string), got {span_payload['Source']!r}" + ) + assert span_payload["Source"] != 10, "Source must not be integer 10 (v2 format)" if __name__ == "__main__":