From d13f011a1410f08a153148e744ca7e0016489fb5 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:01:13 -0700 Subject: [PATCH 01/17] docs: add trace v3 ingestion migration design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-26-trace-v3-ingestion-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md diff --git a/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md b/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md new file mode 100644 index 000000000..90f0def4a --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md @@ -0,0 +1,151 @@ +# Trace V3 Ingestion Migration Design + +**Date:** 2026-05-26 +**Branch:** feat/trace-v3-migration +**Scope:** Ingest only (`POST /api/Traces/v3/spans`). Read-side migration is independent and deferred. + +--- + +## Context + +The UiPath LLM Observability backend is introducing V3 span APIs with insert-only (immutable) ingestion semantics. Duplicate records for the same span are merged on read using a fixed precedence rule: terminal status wins, then latest `UpdatedAt`. This eliminates write contention from the old mutable upsert model. + +V3 ingest enforces two breaking changes vs V2: +1. **Enum fields must be strings** — `"Ok"` not `1`. Affects: `Status`, `Source`, `VerbosityLevel`, `ExecutionType`. +2. **TraceId/SpanId must be OTEL hex** — 32-char and 16-char respectively. The SDK already produces OTEL hex IDs, so no change needed here. + +The Confluence migration guide confirms ingest and read can be migrated independently. V2 read endpoints (`GET /v2/spans`, `GET /v2/spans/otel`) already handle V3-written spans correctly at the storage layer. + +--- + +## What's Not Changing + +- ID format: SDK already emits 32-char hex traceIds and 16-char hex spanIds. No change. +- Live tracking: `LiveTrackingSpanProcessor` sends `RUNNING` on span start and `OK`/`ERROR` on span end. With V3 insert-only, each call creates a new record; the server merges on read (terminal status wins). Wire behavior is unchanged. +- Batch strategy: continue grouping spans by `traceId` and posting to the single-trace endpoint. The `/v3/spans/batch` endpoint is not used. +- `AttachmentProvider` / `AttachmentDirection`: server uses flexible enum converters for attachments — integers remain valid. No change. + +--- + +## Architecture + +### New Enum Types (`uipath-platform`) + +**File:** `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` + +Replace `IntEnum`-based types with `StrEnum` (Python 3.11+). Values match C# enum names exactly so they serialize correctly without any custom JSON logic. + +```python +class SpanStatus(StrEnum): + UNSET = "Unset" + OK = "Ok" + ERROR = "Error" + RUNNING = "Running" + RESTRICTED = "Restricted" + CANCELLED = "Cancelled" + +class SpanSource(StrEnum): + CODED_AGENTS = "CodedAgents" + AGENTS = "Agents" + PROCESS_ORCHESTRATION = "ProcessOrchestration" + API_WORKFLOWS = "ApiWorkflows" + ROBOTS = "Robots" + # extend as needed from server SourceEnum + +class VerbosityLevel(StrEnum): # replaces VerbosityLevel(IntEnum) + VERBOSE = "Verbose" + TRACE = "Trace" + INFORMATION = "Information" + WARNING = "Warning" + ERROR = "Error" + CRITICAL = "Critical" + OFF = "Off" + +class ExecutionType(StrEnum): + DEBUG = "Debug" + RUNTIME = "Runtime" +``` + +`DEFAULT_SOURCE = 10` constant is removed; `SpanSource.CODED_AGENTS` replaces all usages. + +### `UiPathSpan` Dataclass + +Field types change from `int`/`Optional[int]` to the new enums. `to_dict()` requires no changes — `StrEnum` values are plain strings and serialize correctly when placed in a dict. + +```python +@dataclass +class UiPathSpan: + # changed fields: + status: SpanStatus = SpanStatus.OK + source: SpanSource = SpanSource.CODED_AGENTS + execution_type: Optional[ExecutionType] = None + verbosity_level: Optional[VerbosityLevel] = None + # all other fields unchanged +``` + +`otel_span_to_uipath_span()` replaces integer literals (`status = 1`, `status = 2`) with `SpanStatus.OK` and `SpanStatus.ERROR`. The `uipath.source` attribute override path changes from `isinstance(uipath_source, int)` to accepting a `str` that maps to a `SpanSource` member. + +### `LlmOpsHttpExporter` (`uipath` package) + +**File:** `packages/uipath/src/uipath/tracing/_otel_exporters.py` + +Changes: +- Remove the `SpanStatus` integer class entirely. +- Import `SpanStatus` from `uipath.platform.common._span_utils`. +- `_build_url()`: `api/Traces/spans` → `api/Traces/v3/spans`. +- `upsert_span(status_override: Optional[SpanStatus] = None)` — type tightens from `Optional[int]`. +- `_determine_status()` return type changes from `int` to `SpanStatus`. +- Inner `Status` class (used for `INTERRUPTED`, `ERROR`, `SUCCESS`) is removed; map `INTERRUPTED` → `SpanStatus.CANCELLED`, `ERROR` → `SpanStatus.ERROR`, `SUCCESS` → `SpanStatus.OK`. + +### `LiveTrackingSpanProcessor` + +**File:** `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` + +Update import: `SpanStatus` comes from `uipath.platform.common._span_utils` instead of `_otel_exporters`. Usage (`SpanStatus.RUNNING`) is unchanged. + +--- + +## Data Flow + +``` +OTel span (StatusCode.OK / ERROR) + │ + ▼ +otel_span_to_uipath_span() + status = SpanStatus.OK / SpanStatus.ERROR ← was int 1/2 + source = SpanSource.CODED_AGENTS ← was int 10 + verbosity_level = VerbosityLevel.INFORMATION ← was int 2 + │ + ▼ +UiPathSpan.to_dict() + {"Status": "Ok", "Source": "CodedAgents", ...} ← strings, not ints + │ + ▼ +POST {base_url}/api/Traces/v3/spans?traceId=...&source=CodedAgents + (was /api/Traces/spans) +``` + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` | Replace `IntEnum` types; add `SpanStatus`, `SpanSource`, `ExecutionType` as `StrEnum`; update `UiPathSpan` field types; update `otel_span_to_uipath_span()` | +| `packages/uipath/src/uipath/tracing/_otel_exporters.py` | Remove `SpanStatus` int class; import from `_span_utils`; update `_build_url()`, `upsert_span()`, `_determine_status()` | +| `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` | Update `SpanStatus` import | +| `packages/uipath/tests/tracing/test_otel_exporters.py` | Update status/source/verbosity assertions from ints to strings; update URL assertions to `v3/spans` | + +--- + +## Error Handling + +No new error handling needed. The V3 endpoint returns `400` for malformed IDs or integer enums — these are programming errors (wrong enum values sent), not runtime conditions. Existing retry logic (4 attempts, exponential backoff) handles transient `5xx` responses unchanged. + +--- + +## Testing + +- Existing unit tests in `test_otel_exporters.py` updated to assert string enum values and `v3/spans` URL. +- No new test scenarios needed: the V3 format change is purely serialization; logic paths are the same. +- Live tracking test (`upsert_span` with `RUNNING`) updated to assert `"Status": "Running"`. From 1bd05ad535c160d9b72258a03075b936f96fb1de Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:07:25 -0700 Subject: [PATCH 02/17] docs: add trace v3 ingestion implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-26-trace-v3-ingestion.md | 875 ++++++++++++++++++ 1 file changed, 875 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md diff --git a/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md b/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md new file mode 100644 index 000000000..5990a19d8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md @@ -0,0 +1,875 @@ +# Trace V3 Ingestion Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate span ingest from the v2 endpoint (integer enums, `/api/Traces/spans`) to the v3 endpoint (string enums, `/api/Traces/v3/spans`). + +**Architecture:** Replace scattered integer constants and `IntEnum` types with `StrEnum` classes whose values match the C# server enum names exactly. `UiPathSpan.to_dict()` then serializes correctly without any custom JSON logic. The URL change is a one-liner in `_build_url()`. + +**Tech Stack:** Python 3.11+ `StrEnum`, `pytest`, `pytest-httpx`, `opentelemetry-sdk` + +--- + +## File Map + +| File | Change | +|------|--------| +| `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` | Add `SpanStatus`, `SpanSource`, `ExecutionType` StrEnums; change `VerbosityLevel` IntEnum→StrEnum; add int→enum mapping dicts; update `UiPathSpan` field types; update `otel_span_to_uipath_span()` | +| `packages/uipath-platform/src/uipath/platform/common/__init__.py` | Export `SpanStatus`, `SpanSource`, `ExecutionType`, `VerbosityLevel` | +| `packages/uipath/src/uipath/tracing/_otel_exporters.py` | Remove `SpanStatus` int class and inner `Status` class; import `SpanStatus` from `_span_utils`; update `_build_url()`, `_determine_status()`, `upsert_span()` | +| `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` | Update `SpanStatus` import; tighten `status_override` type annotation | +| `packages/uipath/src/uipath/tracing/__init__.py` | Re-export `SpanStatus` from new location | +| `packages/uipath-platform/tests/services/test_span_utils.py` | Update integer enum assertions to string values | +| `packages/uipath/tests/tracing/test_otel_exporters.py` | Update `SpanStatus` import; update URL, status, source assertions to strings | + +--- + +## Task 1: Add StrEnum types to `_span_utils.py` + +**Files:** +- Modify: `packages/uipath-platform/src/uipath/platform/common/_span_utils.py:7-39` +- Test: `packages/uipath-platform/tests/services/test_span_utils.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `packages/uipath-platform/tests/services/test_span_utils.py` after the existing imports: + +```python +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) +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +cd packages/uipath-platform && pytest tests/services/test_span_utils.py::TestStrEnums -v +``` +Expected: `ImportError` — `SpanStatus`, `SpanSource`, `ExecutionType` not defined yet; `VerbosityLevel` is still `IntEnum`. + +- [ ] **Step 3: Replace enum definitions in `_span_utils.py`** + +In `packages/uipath-platform/src/uipath/platform/common/_span_utils.py`, make these changes: + +Replace line 7: +```python +from enum import IntEnum +``` +with: +```python +from enum import IntEnum +from enum import StrEnum +``` + +Replace lines 18-39 (the `DEFAULT_SOURCE` constant and the three IntEnum classes): +```python +# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) +DEFAULT_SOURCE = 10 + + +class AttachmentProvider(IntEnum): + ORCHESTRATOR = 0 + + +class AttachmentDirection(IntEnum): + NONE = 0 + IN = 1 + OUT = 2 + + +class VerbosityLevel(IntEnum): + VERBOSE = 0 + TRACE = 1 + INFORMATION = 2 + WARNING = 3 + ERROR = 4 + CRITICAL = 5 + OFF = 6 +``` +with: +```python +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): + ORCHESTRATOR = 0 + + +class AttachmentDirection(IntEnum): + NONE = 0 + IN = 1 + OUT = 2 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd packages/uipath-platform && pytest tests/services/test_span_utils.py::TestStrEnums -v +``` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/uipath-platform/src/uipath/platform/common/_span_utils.py \ + packages/uipath-platform/tests/services/test_span_utils.py +git commit -m "feat(tracing): add SpanStatus, SpanSource, ExecutionType, VerbosityLevel StrEnums" +``` + +--- + +## Task 2: Update `UiPathSpan` dataclass and `otel_span_to_uipath_span()` + +**Files:** +- Modify: `packages/uipath-platform/src/uipath/platform/common/_span_utils.py:58-360` +- Test: `packages/uipath-platform/tests/services/test_span_utils.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `packages/uipath-platform/tests/services/test_span_utils.py`: + +```python +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" +``` + +Also update the existing `ATTRIBUTE_FIELD_MAP` in `TestOTelToUiPathSpan` — replace the `executionType` and `verbosityLevel` entries: + +```python +ATTRIBUTE_FIELD_MAP = [ + ("executionType", "execution_type", "ExecutionType", ExecutionType.RUNTIME), # was 1 + ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), + ("agentId", "reference_id", "ReferenceId", "ref-abc"), + ("verbosityLevel", "verbosity_level", "VerbosityLevel", VerbosityLevel.OFF), # was 6 +] +``` + +And update the attribute values passed in `test_attributes_map_to_top_level_fields` — the mock attributes dict must pass integers that get converted (since OTEL attributes are ints). The test helper sets `attrs = {otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP}` so it passes `{"executionType": ExecutionType.RUNTIME}`. But OTEL sends ints — update the map to pass the int that maps to each enum: + +```python +ATTRIBUTE_FIELD_MAP = [ + # (otel_attr, span_field, top_level_key, otel_int_or_str, expected_enum_or_str) + ("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), +] +``` + +And update `test_attributes_map_to_top_level_fields` to use the new 5-tuple: + +```python +@patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) +def test_attributes_map_to_top_level_fields(self) -> None: + attrs = { + otel_attr: otel_val for otel_attr, _, _, otel_val, _ in self.ATTRIBUTE_FIELD_MAP + } + + # ... (same mock setup) ... + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + for _, span_field, top_level_key, _, expected in self.ATTRIBUTE_FIELD_MAP: + assert getattr(uipath_span, span_field) == expected, span_field + assert span_dict[top_level_key] == expected, top_level_key +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +cd packages/uipath-platform && pytest tests/services/test_span_utils.py -v 2>&1 | tail -20 +``` +Expected: Multiple failures — `UiPathSpan.status` defaults to `1` (int) not `"Ok"`, `executionType` and `verbosityLevel` still passed through as raw ints. + +- [ ] **Step 3: Update `UiPathSpan` field types** + +In `packages/uipath-platform/src/uipath/platform/common/_span_utils.py`, update the `UiPathSpan` dataclass. Search for each field by its current content: + +Replace: +```python + status: int = 1 +``` +with: +```python + status: SpanStatus = SpanStatus.OK +``` + +Replace: +```python + source: int = DEFAULT_SOURCE +``` +with: +```python + source: SpanSource = SpanSource.CODED_AGENTS +``` + +Replace: +```python + execution_type: Optional[int] = None +``` +with: +```python + execution_type: Optional[ExecutionType] = None +``` + +Replace: +```python + verbosity_level: Optional[int] = None +``` +with: +```python + verbosity_level: Optional[VerbosityLevel] = None +``` + +- [ ] **Step 4: Update `otel_span_to_uipath_span()` to use enum members** + +In `_span_utils.py`, find the status mapping block (around line 230-234 after insertions): + +Replace: +```python + # Map status + status = 1 # Default to OK + if otel_span.status.status_code == StatusCode.ERROR: + status = 2 # Error + attributes_dict["error"] = otel_span.status.description +``` +with: +```python + # Map status + status = SpanStatus.OK + if otel_span.status.status_code == StatusCode.ERROR: + status = SpanStatus.ERROR + attributes_dict["error"] = otel_span.status.description +``` + +Find the source/execution_type/verbosity_level block (around line 297-309 after insertions): + +Replace: +```python + # Top-level fields for internal tracing schema + execution_type = attributes_dict.get("executionType") + 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") + + # 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 +``` +with: +```python + # Top-level fields for internal tracing schema + 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_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 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 + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cd packages/uipath-platform && pytest tests/services/test_span_utils.py -v +``` +Expected: All tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/uipath-platform/src/uipath/platform/common/_span_utils.py \ + packages/uipath-platform/tests/services/test_span_utils.py +git commit -m "feat(tracing): update UiPathSpan fields and otel conversion to use StrEnum types" +``` + +--- + +## Task 3: Export new enum types from `uipath.platform.common` + +**Files:** +- Modify: `packages/uipath-platform/src/uipath/platform/common/__init__.py` + +- [ ] **Step 1: Update the import line in `__init__.py`** + +In `packages/uipath-platform/src/uipath/platform/common/__init__.py`, find: +```python +from ._span_utils import UiPathSpan, _SpanUtils +``` +Replace with: +```python +from ._span_utils import ( + ExecutionType, + SpanSource, + SpanStatus, + UiPathSpan, + VerbosityLevel, + _SpanUtils, +) +``` + +Then add the new names to `__all__`: +```python + "ExecutionType", + "SpanSource", + "SpanStatus", + "VerbosityLevel", +``` + +- [ ] **Step 2: Verify import works** + +```bash +cd packages/uipath-platform && python -c "from uipath.platform.common import SpanStatus, SpanSource, ExecutionType, VerbosityLevel; print(SpanStatus.OK)" +``` +Expected output: `Ok` + +- [ ] **Step 3: Commit** + +```bash +git add packages/uipath-platform/src/uipath/platform/common/__init__.py +git commit -m "feat(tracing): export SpanStatus, SpanSource, ExecutionType, VerbosityLevel from platform.common" +``` + +--- + +## Task 4: Update `LlmOpsHttpExporter` — remove int class, fix URL, fix types + +**Files:** +- Modify: `packages/uipath/src/uipath/tracing/_otel_exporters.py` +- Test: `packages/uipath/tests/tracing/test_otel_exporters.py` + +- [ ] **Step 1: Write the failing tests** + +In `packages/uipath/tests/tracing/test_otel_exporters.py`, update the import at the top of the file: + +```python +from uipath.platform.common._span_utils import SpanStatus # new location +from uipath.tracing._otel_exporters import LlmOpsHttpExporter # SpanStatus removed from here +``` + +Add these new test cases after the existing `test_send_with_retries_success` test: + +```python +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 + assert "/api/Traces/spans" not in url.replace("/v3/", "/") + + +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 +``` + +Also update the existing `exporter` fixture mock URL to use `v3/spans`: + +```python +@pytest.fixture +def exporter(mock_env_vars): + """Create an exporter instance for testing.""" + with patch("uipath.tracing._otel_exporters.httpx.Client"): + exporter = LlmOpsHttpExporter() + exporter._build_url = MagicMock( + return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" + ) + yield exporter +``` + +And update `test_export_success` to assert the v3 URL: +```python + exporter.http_client.post.assert_called_once_with( + "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents", + json=[{"span": "data", "TraceId": "test-trace-id"}], + ) +``` + +- [ ] **Step 2: Run to verify tests fail** + +```bash +cd packages/uipath && pytest tests/tracing/test_otel_exporters.py::test_build_url_uses_v3_endpoint tests/tracing/test_otel_exporters.py::test_determine_status_ok_returns_string tests/tracing/test_otel_exporters.py::test_determine_status_error_returns_string tests/tracing/test_otel_exporters.py::test_determine_status_graph_interrupt_returns_cancelled -v +``` +Expected: `ImportError` (SpanStatus no longer in `_otel_exporters`) and assertion failures. + +- [ ] **Step 3: Update `_otel_exporters.py`** + +In `packages/uipath/src/uipath/tracing/_otel_exporters.py`: + +Add to the imports block at the top: +```python +from uipath.platform.common._span_utils import SpanStatus +``` + +Delete the entire `SpanStatus` class (lines 27-35): +```python +class SpanStatus: + """Span status values matching LLMOps StatusEnum.""" + + UNSET = 0 + OK = 1 + ERROR = 2 + RUNNING = 3 + RESTRICTED = 4 + CANCELLED = 5 +``` + +Delete the inner `Status` class inside `LlmOpsHttpExporter` (lines 109-112): +```python + class Status: + SUCCESS = 1 + ERROR = 2 + INTERRUPTED = 3 +``` + +Update `_determine_status` return type and body: +```python + def _determine_status(self, error: Optional[Any]) -> SpanStatus: + if error: + if isinstance(error, str) and error.startswith("GraphInterrupt("): + return SpanStatus.CANCELLED + return SpanStatus.ERROR + return SpanStatus.OK +``` + +Update `_build_url`: +```python + 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/v3/spans?traceId={trace_id}&source=CodedAgents" +``` + +Update `upsert_span` signature: +```python + def upsert_span( + self, + span: ReadableSpan, + status_override: Optional[SpanStatus] = None, + ) -> SpanExportResult: +``` + +Also update the debug log message in `export()`: +```python + logger.debug( + f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/v3/spans" + ) +``` + +- [ ] **Step 4: Run all exporter tests** + +```bash +cd packages/uipath && pytest tests/tracing/test_otel_exporters.py -v +``` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/uipath/src/uipath/tracing/_otel_exporters.py \ + packages/uipath/tests/tracing/test_otel_exporters.py +git commit -m "feat(tracing): migrate LlmOpsHttpExporter to v3 ingest endpoint with string enums" +``` + +--- + +## Task 5: Update `LiveTrackingSpanProcessor` and `uipath.tracing` re-exports + +**Files:** +- Modify: `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` +- Modify: `packages/uipath/src/uipath/tracing/__init__.py` +- Test: `packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py` + +- [ ] **Step 1: Update `_live_tracking_processor.py`** + +In `packages/uipath/src/uipath/tracing/_live_tracking_processor.py`, replace: +```python +from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus +``` +with: +```python +from uipath.platform.common._span_utils import SpanStatus +from uipath.tracing._otel_exporters import LlmOpsHttpExporter +``` + +Update `_upsert_span_async` type annotation: +```python + def _upsert_span_async( + self, span: Span | ReadableSpan, status_override: SpanStatus | None = None + ) -> None: +``` + +- [ ] **Step 2: Update `uipath.tracing.__init__.py` re-export** + +In `packages/uipath/src/uipath/tracing/__init__.py`, `SpanStatus` is currently imported from `._otel_exporters`. Move it to the existing `_span_utils` import block. + +Replace: +```python +from uipath.platform.common._span_utils import ( + AttachmentDirection, + AttachmentProvider, + SpanAttachment, + VerbosityLevel, +) + +from ._live_tracking_processor import LiveTrackingSpanProcessor +from ._otel_exporters import ( # noqa: D104 + JsonLinesFileExporter, + LlmOpsHttpExporter, + SpanStatus, +) +``` +with: +```python +from uipath.platform.common._span_utils import ( + AttachmentDirection, + AttachmentProvider, + SpanAttachment, + SpanStatus, + VerbosityLevel, +) + +from ._live_tracking_processor import LiveTrackingSpanProcessor +from ._otel_exporters import ( # noqa: D104 + JsonLinesFileExporter, + LlmOpsHttpExporter, +) +``` + +`SpanStatus` stays in `__all__` — no change needed there. + +- [ ] **Step 3: Run live tracking tests** + +```bash +cd packages/uipath && pytest tests/cli/eval/test_live_tracking_span_processor.py -v +``` +Expected: All tests PASS (they import `SpanStatus` from `uipath.tracing` which still re-exports it). + +- [ ] **Step 4: Run full test suite for both packages** + +```bash +cd packages/uipath-platform && pytest -x -q +cd packages/uipath && pytest -x -q +``` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/uipath/src/uipath/tracing/_live_tracking_processor.py \ + packages/uipath/src/uipath/tracing/__init__.py +git commit -m "feat(tracing): update LiveTrackingSpanProcessor to use SpanStatus from platform.common" +``` + +--- + +## Task 6: Final verification + +- [ ] **Step 1: Run linter and type checker** + +```bash +cd packages/uipath-platform && ruff check . && ruff format --check . && mypy src tests +cd packages/uipath && ruff check . && ruff format --check . && mypy src tests +``` +Expected: No errors. If ruff flags the unused `IntEnum` import after removing `VerbosityLevel(IntEnum)`, remove it. + +- [ ] **Step 2: Verify enum values in full export path with an integration-style test** + +Add this one-time verification test to `packages/uipath/tests/tracing/test_otel_exporters.py` (run it, then you can keep or delete it): + +```python +def test_full_export_sends_string_enums_to_v3_url(mock_env_vars): + """Integration-style: verify the full export pipeline sends string enums to v3 URL.""" + import json + from unittest.mock import MagicMock, patch + from opentelemetry.sdk.trace.export import SpanExportResult + + 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() + + # Create a minimal real OTel span + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + provider = TracerProvider() + tracer = provider.get_tracer("test") + with tracer.start_as_current_span("test-span") as span: + readable_spans = [] + + # Use mock span instead for simplicity + mock_uipath_span_dict = { + "TraceId": "ab" * 16, + "Id": "cd" * 8, + "Status": "Ok", + "Source": "CodedAgents", + "Attributes": "{}", + } + mock_uipath_span = MagicMock() + mock_uipath_span.to_dict.return_value = mock_uipath_span_dict + mock_readable = MagicMock() + + with patch("uipath.tracing._otel_exporters._SpanUtils.otel_span_to_uipath_span", return_value=mock_uipath_span): + result = exporter.export([mock_readable]) + + assert result == SpanExportResult.SUCCESS + call_args = mock_client.post.call_args + url = call_args.args[0] + payload = call_args.kwargs["json"] + + assert "/api/Traces/v3/spans" in url + assert payload[0]["Status"] == "Ok" + assert payload[0]["Source"] == "CodedAgents" +``` + +Run: +```bash +cd packages/uipath && pytest tests/tracing/test_otel_exporters.py::test_full_export_sends_string_enums_to_v3_url -v +``` +Expected: PASS. + +- [ ] **Step 3: Final commit** + +```bash +git add -p # stage any remaining changes +git commit -m "feat(tracing): complete v3 ingest migration — string enums, /api/Traces/v3/spans" +``` From 23e31a31a9cc911ccd630110cf4f4663e5c9cb71 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:12:16 -0700 Subject: [PATCH 03/17] feat(tracing): add SpanStatus, SpanSource, ExecutionType, VerbosityLevel StrEnums Co-Authored-By: Claude Sonnet 4.6 --- .../src/uipath/platform/common/_span_utils.py | 74 +++++++++++++++---- .../tests/services/test_span_utils.py | 42 +++++++++++ 2 files changed, 101 insertions(+), 15 deletions(-) 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..1fa9a5195 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -5,6 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime from enum import IntEnum +from enum import StrEnum from os import environ as env from typing import Any, Dict, List, Optional @@ -15,8 +16,61 @@ 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.""" @@ -83,7 +127,7 @@ class UiPathSpan: folder_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") ) - source: int = DEFAULT_SOURCE + source: int = 10 # 10 = CodedAgents; Task 2 will change this to SpanSource.CODED_AGENTS span_type: str = "Coded Agents" process_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_PROCESS_UUID") @@ -304,9 +348,9 @@ def otel_span_to_uipath_span( ) verbosity_level = attributes_dict.get("verbosityLevel") - # Source: override via uipath.source attribute, else DEFAULT_SOURCE + # Source: override via uipath.source attribute, else 10 (CodedAgents) uipath_source = attributes_dict.get("uipath.source") - source = uipath_source if isinstance(uipath_source, int) else DEFAULT_SOURCE + source = uipath_source if isinstance(uipath_source, int) else 10 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..693581956 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: From dcef46016f32f3ecc3724ae5665b6efde8856a19 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:15:32 -0700 Subject: [PATCH 04/17] fix(tracing): fix ruff E302, consolidate enum imports, add Task 2 TODO comments --- .../src/uipath/platform/common/_span_utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 1fa9a5195..9fbc06b4f 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -4,8 +4,7 @@ import os from dataclasses import dataclass, field from datetime import datetime -from enum import IntEnum -from enum import StrEnum +from enum import IntEnum, StrEnum from os import environ as env from typing import Any, Dict, List, Optional @@ -16,6 +15,7 @@ logger = logging.getLogger(__name__) + class SpanStatus(StrEnum): UNSET = "Unset" OK = "Ok" @@ -339,14 +339,14 @@ 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 = attributes_dict.get("executionType") # Task 2: use _EXECUTION_TYPE_BY_INT 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 = attributes_dict.get("verbosityLevel") # Task 2: use _VERBOSITY_LEVEL_BY_INT # Source: override via uipath.source attribute, else 10 (CodedAgents) uipath_source = attributes_dict.get("uipath.source") From a290d0762361e41e14ef219191b7c135967fb8a7 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:21:14 -0700 Subject: [PATCH 05/17] feat(tracing): update UiPathSpan fields and otel conversion to use StrEnum types Co-Authored-By: Claude Sonnet 4.6 --- .../src/uipath/platform/common/_span_utils.py | 36 +++-- .../tests/services/test_span_utils.py | 143 +++++++++++++++--- 2 files changed, 149 insertions(+), 30 deletions(-) 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 9fbc06b4f..3f50b4246 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -114,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( @@ -127,7 +127,7 @@ class UiPathSpan: folder_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") ) - source: int = 10 # 10 = CodedAgents; Task 2 will change this to SpanSource.CODED_AGENTS + source: SpanSource = SpanSource.CODED_AGENTS span_type: str = "Coded Agents" process_key: Optional[str] = field( default_factory=lambda: env.get("UIPATH_PROCESS_UUID") @@ -139,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]: @@ -272,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 @@ -339,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") # Task 2: use _EXECUTION_TYPE_BY_INT + 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") # Task 2: use _VERBOSITY_LEVEL_BY_INT + 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 10 (CodedAgents) - uipath_source = attributes_dict.get("uipath.source") - source = uipath_source if isinstance(uipath_source, int) else 10 + # 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 693581956..294371f31 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -63,16 +63,17 @@ 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) @@ -95,9 +96,9 @@ 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: @@ -302,7 +303,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 @@ -324,7 +325,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, @@ -476,8 +477,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): @@ -530,7 +531,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"}) @@ -562,7 +563,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 @@ -585,9 +586,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"]) @@ -619,9 +620,113 @@ 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" From ca0bf0a4ca96822b1947f4b4f75e494de670c129 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:24:44 -0700 Subject: [PATCH 06/17] feat(tracing): export SpanStatus, SpanSource, ExecutionType, VerbosityLevel from platform.common --- .../src/uipath/platform/common/__init__.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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", From 2f61f08816386e2c59d2fa5b036f39d83f289e74 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:30:58 -0700 Subject: [PATCH 07/17] feat(tracing): migrate LlmOpsHttpExporter to v3 ingest endpoint with string enums - Import SpanStatus from uipath.platform.common._span_utils (StrEnum) - Remove local int-based SpanStatus class and inner Status class - Update _build_url to /api/Traces/v3/spans - Update _determine_status() return type to SpanStatus (string values) - Update upsert_span status_override param type to Optional[SpanStatus] - Update debug log message to reference v3 path - Add 4 new tests verifying v3 URL and string enum status values - Fix VerbosityLevel.OFF assertion from int 6 to string "Off" Co-Authored-By: Claude Sonnet 4.6 --- .../src/uipath/tracing/_otel_exporters.py | 31 ++++--------- .../tests/tracing/test_otel_exporters.py | 44 ++++++++++++++++--- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index d2bf3a7c1..dafc64ab1 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,7 @@ 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..28ec0eb01 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() @@ -820,7 +850,7 @@ 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" if __name__ == "__main__": From 0e5526c691d66b1cdba555b670491402cad6bb62 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:33:32 -0700 Subject: [PATCH 08/17] fix(tracing): update stale v2 URL in TestUpsertSpan fixture --- packages/uipath/tests/tracing/test_otel_exporters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 28ec0eb01..ca59df24e 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -715,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 From 6451b5f72ba3975be68d69cb7137a6a4591d3652 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:38:41 -0700 Subject: [PATCH 09/17] feat(tracing): update LiveTrackingSpanProcessor to use SpanStatus from platform.common Co-Authored-By: Claude Sonnet 4.6 --- packages/uipath/src/uipath/tracing/__init__.py | 2 +- .../uipath/src/uipath/tracing/_live_tracking_processor.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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. From aad6b3eeb34c8ebd143baae53de06eaad3883421 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:44:53 -0700 Subject: [PATCH 10/17] chore(tracing): final lint, type-check and integration test for v3 migration Fix ruff import-sort and formatting in span_utils tests and otel_exporters; add TestV3EndToEnd integration test asserting v3/spans URL and string enum values (Status="Ok", Source="CodedAgents") reach the HTTP layer end-to-end. Co-Authored-By: Claude Sonnet 4.6 --- .../tests/services/test_span_utils.py | 12 ++- .../src/uipath/tracing/_otel_exporters.py | 4 +- .../tests/tracing/test_otel_exporters.py | 79 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 294371f31..002e6c61b 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -73,7 +73,8 @@ class TestOTelToUiPathSpan: @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_attributes_map_to_top_level_fields(self) -> None: attrs = { - otel_attr: otel_input for otel_attr, _, _, otel_input, _ in self.ATTRIBUTE_FIELD_MAP + otel_attr: otel_input + for otel_attr, _, _, otel_input, _ in self.ATTRIBUTE_FIELD_MAP } mock_span = Mock(spec=OTelSpan) @@ -96,7 +97,13 @@ 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, _, expected_output in self.ATTRIBUTE_FIELD_MAP: + 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 @@ -676,6 +683,7 @@ 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() diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index dafc64ab1..6137c9e60 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -374,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/v3/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 ca59df24e..1dfec8e96 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -853,5 +853,84 @@ def test_uipath_tracing_reexports_verbosity_level(self) -> None: 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 = 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__": unittest.main() From d9222ed3b555021cc8bf6476bb3abca5ac049ac9 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:48:42 -0700 Subject: [PATCH 11/17] chore(tracing): fix mypy type annotation in integration test, add showboat doc Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-26-trace-v3-task6-verification.md | 99 +++++++++++++++++++ .../tests/tracing/test_otel_exporters.py | 2 +- 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 docs/showboat/2026-05-26-trace-v3-task6-verification.md diff --git a/docs/showboat/2026-05-26-trace-v3-task6-verification.md b/docs/showboat/2026-05-26-trace-v3-task6-verification.md new file mode 100644 index 000000000..30fba7690 --- /dev/null +++ b/docs/showboat/2026-05-26-trace-v3-task6-verification.md @@ -0,0 +1,99 @@ +# Trace V3 Migration — Task 6: Final Lint, Type-Check & Integration Verification + +*2026-05-26T19:45:13Z by Showboat 0.6.1* + + +Ruff lint check on uipath-platform — verifies no style violations after StrEnum migration. + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run ruff check . && uv run ruff format --check . && echo 'uipath-platform lint: PASSED' 2>&1 +``` + +```output +All checks passed! +187 files already formatted +uipath-platform lint: PASSED +``` + +Ruff lint check on uipath (main package) — includes tracing files updated in Tasks 4–5. + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run ruff check . && uv run ruff format --check . && echo 'uipath lint: PASSED' 2>&1 +``` + +```output +All checks passed! +290 files already formatted +uipath lint: PASSED +``` + +mypy type check on uipath-platform — verifies StrEnum field types in UiPathSpan and otel_span_to_uipath_span(). + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run mypy src tests 2>&1 | tail -5 +``` + +```output +Success: no issues found in 187 source files +``` + +mypy type check on uipath — verifies SpanStatus import refactor in _otel_exporters.py and _live_tracking_processor.py. + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run mypy src tests 2>&1 | tail -5 +``` + +```output +Success: no issues found in 286 source files +``` + +Full test suite for uipath-platform — 1212 tests covering span utils, enum serialization, and all service tests. + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run pytest --tb=short -q 2>&1 | tail -10 +``` + +```output +-------------------------------------------------------------------------------------------------------------- +TOTAL 9187 1091 88.12% +=========================== short test summary info ============================ +SKIPPED [1] tests/services/test_llm_integration.py:59: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_llm_integration.py:77: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_llm_integration.py:104: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_uipath_llm_integration.py:42: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_uipath_llm_integration.py:66: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_uipath_llm_integration.py:121: Failed to get access token. Check your credentials. +SKIPPED [1] tests/services/test_uipath_llm_integration.py:177: Failed to get access token. Check your credentials. +``` + +Full test suite for uipath (main package) — includes TestV3EndToEnd integration test verifying string enums reach v3 URL. + +```bash +cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run pytest --no-cov -q 2>&1 | tail -3 +``` + +```output + model_fields = getattr(data[0], "model_fields", None) + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +``` + +Integration test confirming the v3 contract: string enums in payload, v3/spans URL. + +```bash +uv run pytest tests/tracing/test_otel_exporters.py::TestV3EndToEnd -v --no-cov 2>&1 +``` + +```output +============================= test session starts ============================== +platform darwin -- Python 3.11.14, pytest-9.0.2, pluggy-1.6.0 +rootdir: /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath +configfile: pyproject.toml +plugins: anyio-4.12.1, mock-3.15.1, httpx-0.36.0, timeout-2.4.0, trio-0.8.0, asyncio-1.3.0, cov-7.0.0 +asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function +collected 1 item + +tests/tracing/test_otel_exporters.py . [100%] + +============================== 1 passed in 0.02s =============================== +``` diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 1dfec8e96..73445cf7b 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -915,7 +915,7 @@ def test_export_posts_to_v3_url_with_string_enums(self, mock_env_vars): 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 = call_args.kwargs.get("json") or call_args.args[1] + payload: list[dict[str, object]] = call_args.kwargs.get("json") or call_args.args[1] assert len(payload) == 1 span_payload = payload[0] From e2c0a5506db11099cef62c794dff9aa6828d4f53 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 14:31:09 -0700 Subject: [PATCH 12/17] chore: remove docs from branch (keep locally only) Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-26-trace-v3-task6-verification.md | 99 -- .../plans/2026-05-26-trace-v3-ingestion.md | 875 ------------------ .../2026-05-26-trace-v3-ingestion-design.md | 151 --- 3 files changed, 1125 deletions(-) delete mode 100644 docs/showboat/2026-05-26-trace-v3-task6-verification.md delete mode 100644 docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md delete mode 100644 docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md diff --git a/docs/showboat/2026-05-26-trace-v3-task6-verification.md b/docs/showboat/2026-05-26-trace-v3-task6-verification.md deleted file mode 100644 index 30fba7690..000000000 --- a/docs/showboat/2026-05-26-trace-v3-task6-verification.md +++ /dev/null @@ -1,99 +0,0 @@ -# Trace V3 Migration — Task 6: Final Lint, Type-Check & Integration Verification - -*2026-05-26T19:45:13Z by Showboat 0.6.1* - - -Ruff lint check on uipath-platform — verifies no style violations after StrEnum migration. - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run ruff check . && uv run ruff format --check . && echo 'uipath-platform lint: PASSED' 2>&1 -``` - -```output -All checks passed! -187 files already formatted -uipath-platform lint: PASSED -``` - -Ruff lint check on uipath (main package) — includes tracing files updated in Tasks 4–5. - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run ruff check . && uv run ruff format --check . && echo 'uipath lint: PASSED' 2>&1 -``` - -```output -All checks passed! -290 files already formatted -uipath lint: PASSED -``` - -mypy type check on uipath-platform — verifies StrEnum field types in UiPathSpan and otel_span_to_uipath_span(). - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run mypy src tests 2>&1 | tail -5 -``` - -```output -Success: no issues found in 187 source files -``` - -mypy type check on uipath — verifies SpanStatus import refactor in _otel_exporters.py and _live_tracking_processor.py. - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run mypy src tests 2>&1 | tail -5 -``` - -```output -Success: no issues found in 286 source files -``` - -Full test suite for uipath-platform — 1212 tests covering span utils, enum serialization, and all service tests. - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath-platform && uv run pytest --tb=short -q 2>&1 | tail -10 -``` - -```output --------------------------------------------------------------------------------------------------------------- -TOTAL 9187 1091 88.12% -=========================== short test summary info ============================ -SKIPPED [1] tests/services/test_llm_integration.py:59: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_llm_integration.py:77: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_llm_integration.py:104: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_uipath_llm_integration.py:42: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_uipath_llm_integration.py:66: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_uipath_llm_integration.py:121: Failed to get access token. Check your credentials. -SKIPPED [1] tests/services/test_uipath_llm_integration.py:177: Failed to get access token. Check your credentials. -``` - -Full test suite for uipath (main package) — includes TestV3EndToEnd integration test verifying string enums reach v3 URL. - -```bash -cd /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath && uv run pytest --no-cov -q 2>&1 | tail -3 -``` - -```output - model_fields = getattr(data[0], "model_fields", None) - --- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html -``` - -Integration test confirming the v3 contract: string enums in payload, v3/spans URL. - -```bash -uv run pytest tests/tracing/test_otel_exporters.py::TestV3EndToEnd -v --no-cov 2>&1 -``` - -```output -============================= test session starts ============================== -platform darwin -- Python 3.11.14, pytest-9.0.2, pluggy-1.6.0 -rootdir: /Users/sakshar.thakkar/repos/u-trace-v3-migration/packages/uipath -configfile: pyproject.toml -plugins: anyio-4.12.1, mock-3.15.1, httpx-0.36.0, timeout-2.4.0, trio-0.8.0, asyncio-1.3.0, cov-7.0.0 -asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function -collected 1 item - -tests/tracing/test_otel_exporters.py . [100%] - -============================== 1 passed in 0.02s =============================== -``` diff --git a/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md b/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md deleted file mode 100644 index 5990a19d8..000000000 --- a/docs/superpowers/plans/2026-05-26-trace-v3-ingestion.md +++ /dev/null @@ -1,875 +0,0 @@ -# Trace V3 Ingestion Migration Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Migrate span ingest from the v2 endpoint (integer enums, `/api/Traces/spans`) to the v3 endpoint (string enums, `/api/Traces/v3/spans`). - -**Architecture:** Replace scattered integer constants and `IntEnum` types with `StrEnum` classes whose values match the C# server enum names exactly. `UiPathSpan.to_dict()` then serializes correctly without any custom JSON logic. The URL change is a one-liner in `_build_url()`. - -**Tech Stack:** Python 3.11+ `StrEnum`, `pytest`, `pytest-httpx`, `opentelemetry-sdk` - ---- - -## File Map - -| File | Change | -|------|--------| -| `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` | Add `SpanStatus`, `SpanSource`, `ExecutionType` StrEnums; change `VerbosityLevel` IntEnum→StrEnum; add int→enum mapping dicts; update `UiPathSpan` field types; update `otel_span_to_uipath_span()` | -| `packages/uipath-platform/src/uipath/platform/common/__init__.py` | Export `SpanStatus`, `SpanSource`, `ExecutionType`, `VerbosityLevel` | -| `packages/uipath/src/uipath/tracing/_otel_exporters.py` | Remove `SpanStatus` int class and inner `Status` class; import `SpanStatus` from `_span_utils`; update `_build_url()`, `_determine_status()`, `upsert_span()` | -| `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` | Update `SpanStatus` import; tighten `status_override` type annotation | -| `packages/uipath/src/uipath/tracing/__init__.py` | Re-export `SpanStatus` from new location | -| `packages/uipath-platform/tests/services/test_span_utils.py` | Update integer enum assertions to string values | -| `packages/uipath/tests/tracing/test_otel_exporters.py` | Update `SpanStatus` import; update URL, status, source assertions to strings | - ---- - -## Task 1: Add StrEnum types to `_span_utils.py` - -**Files:** -- Modify: `packages/uipath-platform/src/uipath/platform/common/_span_utils.py:7-39` -- Test: `packages/uipath-platform/tests/services/test_span_utils.py` - -- [ ] **Step 1: Write the failing tests** - -Add to `packages/uipath-platform/tests/services/test_span_utils.py` after the existing imports: - -```python -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) -``` - -- [ ] **Step 2: Run to verify tests fail** - -```bash -cd packages/uipath-platform && pytest tests/services/test_span_utils.py::TestStrEnums -v -``` -Expected: `ImportError` — `SpanStatus`, `SpanSource`, `ExecutionType` not defined yet; `VerbosityLevel` is still `IntEnum`. - -- [ ] **Step 3: Replace enum definitions in `_span_utils.py`** - -In `packages/uipath-platform/src/uipath/platform/common/_span_utils.py`, make these changes: - -Replace line 7: -```python -from enum import IntEnum -``` -with: -```python -from enum import IntEnum -from enum import StrEnum -``` - -Replace lines 18-39 (the `DEFAULT_SOURCE` constant and the three IntEnum classes): -```python -# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents) -DEFAULT_SOURCE = 10 - - -class AttachmentProvider(IntEnum): - ORCHESTRATOR = 0 - - -class AttachmentDirection(IntEnum): - NONE = 0 - IN = 1 - OUT = 2 - - -class VerbosityLevel(IntEnum): - VERBOSE = 0 - TRACE = 1 - INFORMATION = 2 - WARNING = 3 - ERROR = 4 - CRITICAL = 5 - OFF = 6 -``` -with: -```python -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): - ORCHESTRATOR = 0 - - -class AttachmentDirection(IntEnum): - NONE = 0 - IN = 1 - OUT = 2 -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cd packages/uipath-platform && pytest tests/services/test_span_utils.py::TestStrEnums -v -``` -Expected: All 5 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/uipath-platform/src/uipath/platform/common/_span_utils.py \ - packages/uipath-platform/tests/services/test_span_utils.py -git commit -m "feat(tracing): add SpanStatus, SpanSource, ExecutionType, VerbosityLevel StrEnums" -``` - ---- - -## Task 2: Update `UiPathSpan` dataclass and `otel_span_to_uipath_span()` - -**Files:** -- Modify: `packages/uipath-platform/src/uipath/platform/common/_span_utils.py:58-360` -- Test: `packages/uipath-platform/tests/services/test_span_utils.py` - -- [ ] **Step 1: Write the failing tests** - -Add to `packages/uipath-platform/tests/services/test_span_utils.py`: - -```python -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" -``` - -Also update the existing `ATTRIBUTE_FIELD_MAP` in `TestOTelToUiPathSpan` — replace the `executionType` and `verbosityLevel` entries: - -```python -ATTRIBUTE_FIELD_MAP = [ - ("executionType", "execution_type", "ExecutionType", ExecutionType.RUNTIME), # was 1 - ("agentVersion", "agent_version", "AgentVersion", "1.2.3"), - ("agentId", "reference_id", "ReferenceId", "ref-abc"), - ("verbosityLevel", "verbosity_level", "VerbosityLevel", VerbosityLevel.OFF), # was 6 -] -``` - -And update the attribute values passed in `test_attributes_map_to_top_level_fields` — the mock attributes dict must pass integers that get converted (since OTEL attributes are ints). The test helper sets `attrs = {otel_attr: value for otel_attr, _, _, value in self.ATTRIBUTE_FIELD_MAP}` so it passes `{"executionType": ExecutionType.RUNTIME}`. But OTEL sends ints — update the map to pass the int that maps to each enum: - -```python -ATTRIBUTE_FIELD_MAP = [ - # (otel_attr, span_field, top_level_key, otel_int_or_str, expected_enum_or_str) - ("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), -] -``` - -And update `test_attributes_map_to_top_level_fields` to use the new 5-tuple: - -```python -@patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) -def test_attributes_map_to_top_level_fields(self) -> None: - attrs = { - otel_attr: otel_val for otel_attr, _, _, otel_val, _ in self.ATTRIBUTE_FIELD_MAP - } - - # ... (same mock setup) ... - - uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) - span_dict = uipath_span.to_dict() - - for _, span_field, top_level_key, _, expected in self.ATTRIBUTE_FIELD_MAP: - assert getattr(uipath_span, span_field) == expected, span_field - assert span_dict[top_level_key] == expected, top_level_key -``` - -- [ ] **Step 2: Run to verify tests fail** - -```bash -cd packages/uipath-platform && pytest tests/services/test_span_utils.py -v 2>&1 | tail -20 -``` -Expected: Multiple failures — `UiPathSpan.status` defaults to `1` (int) not `"Ok"`, `executionType` and `verbosityLevel` still passed through as raw ints. - -- [ ] **Step 3: Update `UiPathSpan` field types** - -In `packages/uipath-platform/src/uipath/platform/common/_span_utils.py`, update the `UiPathSpan` dataclass. Search for each field by its current content: - -Replace: -```python - status: int = 1 -``` -with: -```python - status: SpanStatus = SpanStatus.OK -``` - -Replace: -```python - source: int = DEFAULT_SOURCE -``` -with: -```python - source: SpanSource = SpanSource.CODED_AGENTS -``` - -Replace: -```python - execution_type: Optional[int] = None -``` -with: -```python - execution_type: Optional[ExecutionType] = None -``` - -Replace: -```python - verbosity_level: Optional[int] = None -``` -with: -```python - verbosity_level: Optional[VerbosityLevel] = None -``` - -- [ ] **Step 4: Update `otel_span_to_uipath_span()` to use enum members** - -In `_span_utils.py`, find the status mapping block (around line 230-234 after insertions): - -Replace: -```python - # Map status - status = 1 # Default to OK - if otel_span.status.status_code == StatusCode.ERROR: - status = 2 # Error - attributes_dict["error"] = otel_span.status.description -``` -with: -```python - # Map status - status = SpanStatus.OK - if otel_span.status.status_code == StatusCode.ERROR: - status = SpanStatus.ERROR - attributes_dict["error"] = otel_span.status.description -``` - -Find the source/execution_type/verbosity_level block (around line 297-309 after insertions): - -Replace: -```python - # Top-level fields for internal tracing schema - execution_type = attributes_dict.get("executionType") - 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") - - # 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 -``` -with: -```python - # Top-level fields for internal tracing schema - 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_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 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 - ) -``` - -- [ ] **Step 5: Run tests to verify they pass** - -```bash -cd packages/uipath-platform && pytest tests/services/test_span_utils.py -v -``` -Expected: All tests PASS. - -- [ ] **Step 6: Commit** - -```bash -git add packages/uipath-platform/src/uipath/platform/common/_span_utils.py \ - packages/uipath-platform/tests/services/test_span_utils.py -git commit -m "feat(tracing): update UiPathSpan fields and otel conversion to use StrEnum types" -``` - ---- - -## Task 3: Export new enum types from `uipath.platform.common` - -**Files:** -- Modify: `packages/uipath-platform/src/uipath/platform/common/__init__.py` - -- [ ] **Step 1: Update the import line in `__init__.py`** - -In `packages/uipath-platform/src/uipath/platform/common/__init__.py`, find: -```python -from ._span_utils import UiPathSpan, _SpanUtils -``` -Replace with: -```python -from ._span_utils import ( - ExecutionType, - SpanSource, - SpanStatus, - UiPathSpan, - VerbosityLevel, - _SpanUtils, -) -``` - -Then add the new names to `__all__`: -```python - "ExecutionType", - "SpanSource", - "SpanStatus", - "VerbosityLevel", -``` - -- [ ] **Step 2: Verify import works** - -```bash -cd packages/uipath-platform && python -c "from uipath.platform.common import SpanStatus, SpanSource, ExecutionType, VerbosityLevel; print(SpanStatus.OK)" -``` -Expected output: `Ok` - -- [ ] **Step 3: Commit** - -```bash -git add packages/uipath-platform/src/uipath/platform/common/__init__.py -git commit -m "feat(tracing): export SpanStatus, SpanSource, ExecutionType, VerbosityLevel from platform.common" -``` - ---- - -## Task 4: Update `LlmOpsHttpExporter` — remove int class, fix URL, fix types - -**Files:** -- Modify: `packages/uipath/src/uipath/tracing/_otel_exporters.py` -- Test: `packages/uipath/tests/tracing/test_otel_exporters.py` - -- [ ] **Step 1: Write the failing tests** - -In `packages/uipath/tests/tracing/test_otel_exporters.py`, update the import at the top of the file: - -```python -from uipath.platform.common._span_utils import SpanStatus # new location -from uipath.tracing._otel_exporters import LlmOpsHttpExporter # SpanStatus removed from here -``` - -Add these new test cases after the existing `test_send_with_retries_success` test: - -```python -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 - assert "/api/Traces/spans" not in url.replace("/v3/", "/") - - -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 -``` - -Also update the existing `exporter` fixture mock URL to use `v3/spans`: - -```python -@pytest.fixture -def exporter(mock_env_vars): - """Create an exporter instance for testing.""" - with patch("uipath.tracing._otel_exporters.httpx.Client"): - exporter = LlmOpsHttpExporter() - exporter._build_url = MagicMock( - return_value="https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents" - ) - yield exporter -``` - -And update `test_export_success` to assert the v3 URL: -```python - exporter.http_client.post.assert_called_once_with( - "https://test.uipath.com/org/tenant/llmopstenant_/api/Traces/v3/spans?traceId=test-trace-id&source=CodedAgents", - json=[{"span": "data", "TraceId": "test-trace-id"}], - ) -``` - -- [ ] **Step 2: Run to verify tests fail** - -```bash -cd packages/uipath && pytest tests/tracing/test_otel_exporters.py::test_build_url_uses_v3_endpoint tests/tracing/test_otel_exporters.py::test_determine_status_ok_returns_string tests/tracing/test_otel_exporters.py::test_determine_status_error_returns_string tests/tracing/test_otel_exporters.py::test_determine_status_graph_interrupt_returns_cancelled -v -``` -Expected: `ImportError` (SpanStatus no longer in `_otel_exporters`) and assertion failures. - -- [ ] **Step 3: Update `_otel_exporters.py`** - -In `packages/uipath/src/uipath/tracing/_otel_exporters.py`: - -Add to the imports block at the top: -```python -from uipath.platform.common._span_utils import SpanStatus -``` - -Delete the entire `SpanStatus` class (lines 27-35): -```python -class SpanStatus: - """Span status values matching LLMOps StatusEnum.""" - - UNSET = 0 - OK = 1 - ERROR = 2 - RUNNING = 3 - RESTRICTED = 4 - CANCELLED = 5 -``` - -Delete the inner `Status` class inside `LlmOpsHttpExporter` (lines 109-112): -```python - class Status: - SUCCESS = 1 - ERROR = 2 - INTERRUPTED = 3 -``` - -Update `_determine_status` return type and body: -```python - def _determine_status(self, error: Optional[Any]) -> SpanStatus: - if error: - if isinstance(error, str) and error.startswith("GraphInterrupt("): - return SpanStatus.CANCELLED - return SpanStatus.ERROR - return SpanStatus.OK -``` - -Update `_build_url`: -```python - 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/v3/spans?traceId={trace_id}&source=CodedAgents" -``` - -Update `upsert_span` signature: -```python - def upsert_span( - self, - span: ReadableSpan, - status_override: Optional[SpanStatus] = None, - ) -> SpanExportResult: -``` - -Also update the debug log message in `export()`: -```python - logger.debug( - f"Exporting {len(spans)} spans to {self.base_url}/api/Traces/v3/spans" - ) -``` - -- [ ] **Step 4: Run all exporter tests** - -```bash -cd packages/uipath && pytest tests/tracing/test_otel_exporters.py -v -``` -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/uipath/src/uipath/tracing/_otel_exporters.py \ - packages/uipath/tests/tracing/test_otel_exporters.py -git commit -m "feat(tracing): migrate LlmOpsHttpExporter to v3 ingest endpoint with string enums" -``` - ---- - -## Task 5: Update `LiveTrackingSpanProcessor` and `uipath.tracing` re-exports - -**Files:** -- Modify: `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` -- Modify: `packages/uipath/src/uipath/tracing/__init__.py` -- Test: `packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py` - -- [ ] **Step 1: Update `_live_tracking_processor.py`** - -In `packages/uipath/src/uipath/tracing/_live_tracking_processor.py`, replace: -```python -from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus -``` -with: -```python -from uipath.platform.common._span_utils import SpanStatus -from uipath.tracing._otel_exporters import LlmOpsHttpExporter -``` - -Update `_upsert_span_async` type annotation: -```python - def _upsert_span_async( - self, span: Span | ReadableSpan, status_override: SpanStatus | None = None - ) -> None: -``` - -- [ ] **Step 2: Update `uipath.tracing.__init__.py` re-export** - -In `packages/uipath/src/uipath/tracing/__init__.py`, `SpanStatus` is currently imported from `._otel_exporters`. Move it to the existing `_span_utils` import block. - -Replace: -```python -from uipath.platform.common._span_utils import ( - AttachmentDirection, - AttachmentProvider, - SpanAttachment, - VerbosityLevel, -) - -from ._live_tracking_processor import LiveTrackingSpanProcessor -from ._otel_exporters import ( # noqa: D104 - JsonLinesFileExporter, - LlmOpsHttpExporter, - SpanStatus, -) -``` -with: -```python -from uipath.platform.common._span_utils import ( - AttachmentDirection, - AttachmentProvider, - SpanAttachment, - SpanStatus, - VerbosityLevel, -) - -from ._live_tracking_processor import LiveTrackingSpanProcessor -from ._otel_exporters import ( # noqa: D104 - JsonLinesFileExporter, - LlmOpsHttpExporter, -) -``` - -`SpanStatus` stays in `__all__` — no change needed there. - -- [ ] **Step 3: Run live tracking tests** - -```bash -cd packages/uipath && pytest tests/cli/eval/test_live_tracking_span_processor.py -v -``` -Expected: All tests PASS (they import `SpanStatus` from `uipath.tracing` which still re-exports it). - -- [ ] **Step 4: Run full test suite for both packages** - -```bash -cd packages/uipath-platform && pytest -x -q -cd packages/uipath && pytest -x -q -``` -Expected: All tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/uipath/src/uipath/tracing/_live_tracking_processor.py \ - packages/uipath/src/uipath/tracing/__init__.py -git commit -m "feat(tracing): update LiveTrackingSpanProcessor to use SpanStatus from platform.common" -``` - ---- - -## Task 6: Final verification - -- [ ] **Step 1: Run linter and type checker** - -```bash -cd packages/uipath-platform && ruff check . && ruff format --check . && mypy src tests -cd packages/uipath && ruff check . && ruff format --check . && mypy src tests -``` -Expected: No errors. If ruff flags the unused `IntEnum` import after removing `VerbosityLevel(IntEnum)`, remove it. - -- [ ] **Step 2: Verify enum values in full export path with an integration-style test** - -Add this one-time verification test to `packages/uipath/tests/tracing/test_otel_exporters.py` (run it, then you can keep or delete it): - -```python -def test_full_export_sends_string_enums_to_v3_url(mock_env_vars): - """Integration-style: verify the full export pipeline sends string enums to v3 URL.""" - import json - from unittest.mock import MagicMock, patch - from opentelemetry.sdk.trace.export import SpanExportResult - - 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() - - # Create a minimal real OTel span - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - provider = TracerProvider() - tracer = provider.get_tracer("test") - with tracer.start_as_current_span("test-span") as span: - readable_spans = [] - - # Use mock span instead for simplicity - mock_uipath_span_dict = { - "TraceId": "ab" * 16, - "Id": "cd" * 8, - "Status": "Ok", - "Source": "CodedAgents", - "Attributes": "{}", - } - mock_uipath_span = MagicMock() - mock_uipath_span.to_dict.return_value = mock_uipath_span_dict - mock_readable = MagicMock() - - with patch("uipath.tracing._otel_exporters._SpanUtils.otel_span_to_uipath_span", return_value=mock_uipath_span): - result = exporter.export([mock_readable]) - - assert result == SpanExportResult.SUCCESS - call_args = mock_client.post.call_args - url = call_args.args[0] - payload = call_args.kwargs["json"] - - assert "/api/Traces/v3/spans" in url - assert payload[0]["Status"] == "Ok" - assert payload[0]["Source"] == "CodedAgents" -``` - -Run: -```bash -cd packages/uipath && pytest tests/tracing/test_otel_exporters.py::test_full_export_sends_string_enums_to_v3_url -v -``` -Expected: PASS. - -- [ ] **Step 3: Final commit** - -```bash -git add -p # stage any remaining changes -git commit -m "feat(tracing): complete v3 ingest migration — string enums, /api/Traces/v3/spans" -``` diff --git a/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md b/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md deleted file mode 100644 index 90f0def4a..000000000 --- a/docs/superpowers/specs/2026-05-26-trace-v3-ingestion-design.md +++ /dev/null @@ -1,151 +0,0 @@ -# Trace V3 Ingestion Migration Design - -**Date:** 2026-05-26 -**Branch:** feat/trace-v3-migration -**Scope:** Ingest only (`POST /api/Traces/v3/spans`). Read-side migration is independent and deferred. - ---- - -## Context - -The UiPath LLM Observability backend is introducing V3 span APIs with insert-only (immutable) ingestion semantics. Duplicate records for the same span are merged on read using a fixed precedence rule: terminal status wins, then latest `UpdatedAt`. This eliminates write contention from the old mutable upsert model. - -V3 ingest enforces two breaking changes vs V2: -1. **Enum fields must be strings** — `"Ok"` not `1`. Affects: `Status`, `Source`, `VerbosityLevel`, `ExecutionType`. -2. **TraceId/SpanId must be OTEL hex** — 32-char and 16-char respectively. The SDK already produces OTEL hex IDs, so no change needed here. - -The Confluence migration guide confirms ingest and read can be migrated independently. V2 read endpoints (`GET /v2/spans`, `GET /v2/spans/otel`) already handle V3-written spans correctly at the storage layer. - ---- - -## What's Not Changing - -- ID format: SDK already emits 32-char hex traceIds and 16-char hex spanIds. No change. -- Live tracking: `LiveTrackingSpanProcessor` sends `RUNNING` on span start and `OK`/`ERROR` on span end. With V3 insert-only, each call creates a new record; the server merges on read (terminal status wins). Wire behavior is unchanged. -- Batch strategy: continue grouping spans by `traceId` and posting to the single-trace endpoint. The `/v3/spans/batch` endpoint is not used. -- `AttachmentProvider` / `AttachmentDirection`: server uses flexible enum converters for attachments — integers remain valid. No change. - ---- - -## Architecture - -### New Enum Types (`uipath-platform`) - -**File:** `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` - -Replace `IntEnum`-based types with `StrEnum` (Python 3.11+). Values match C# enum names exactly so they serialize correctly without any custom JSON logic. - -```python -class SpanStatus(StrEnum): - UNSET = "Unset" - OK = "Ok" - ERROR = "Error" - RUNNING = "Running" - RESTRICTED = "Restricted" - CANCELLED = "Cancelled" - -class SpanSource(StrEnum): - CODED_AGENTS = "CodedAgents" - AGENTS = "Agents" - PROCESS_ORCHESTRATION = "ProcessOrchestration" - API_WORKFLOWS = "ApiWorkflows" - ROBOTS = "Robots" - # extend as needed from server SourceEnum - -class VerbosityLevel(StrEnum): # replaces VerbosityLevel(IntEnum) - VERBOSE = "Verbose" - TRACE = "Trace" - INFORMATION = "Information" - WARNING = "Warning" - ERROR = "Error" - CRITICAL = "Critical" - OFF = "Off" - -class ExecutionType(StrEnum): - DEBUG = "Debug" - RUNTIME = "Runtime" -``` - -`DEFAULT_SOURCE = 10` constant is removed; `SpanSource.CODED_AGENTS` replaces all usages. - -### `UiPathSpan` Dataclass - -Field types change from `int`/`Optional[int]` to the new enums. `to_dict()` requires no changes — `StrEnum` values are plain strings and serialize correctly when placed in a dict. - -```python -@dataclass -class UiPathSpan: - # changed fields: - status: SpanStatus = SpanStatus.OK - source: SpanSource = SpanSource.CODED_AGENTS - execution_type: Optional[ExecutionType] = None - verbosity_level: Optional[VerbosityLevel] = None - # all other fields unchanged -``` - -`otel_span_to_uipath_span()` replaces integer literals (`status = 1`, `status = 2`) with `SpanStatus.OK` and `SpanStatus.ERROR`. The `uipath.source` attribute override path changes from `isinstance(uipath_source, int)` to accepting a `str` that maps to a `SpanSource` member. - -### `LlmOpsHttpExporter` (`uipath` package) - -**File:** `packages/uipath/src/uipath/tracing/_otel_exporters.py` - -Changes: -- Remove the `SpanStatus` integer class entirely. -- Import `SpanStatus` from `uipath.platform.common._span_utils`. -- `_build_url()`: `api/Traces/spans` → `api/Traces/v3/spans`. -- `upsert_span(status_override: Optional[SpanStatus] = None)` — type tightens from `Optional[int]`. -- `_determine_status()` return type changes from `int` to `SpanStatus`. -- Inner `Status` class (used for `INTERRUPTED`, `ERROR`, `SUCCESS`) is removed; map `INTERRUPTED` → `SpanStatus.CANCELLED`, `ERROR` → `SpanStatus.ERROR`, `SUCCESS` → `SpanStatus.OK`. - -### `LiveTrackingSpanProcessor` - -**File:** `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` - -Update import: `SpanStatus` comes from `uipath.platform.common._span_utils` instead of `_otel_exporters`. Usage (`SpanStatus.RUNNING`) is unchanged. - ---- - -## Data Flow - -``` -OTel span (StatusCode.OK / ERROR) - │ - ▼ -otel_span_to_uipath_span() - status = SpanStatus.OK / SpanStatus.ERROR ← was int 1/2 - source = SpanSource.CODED_AGENTS ← was int 10 - verbosity_level = VerbosityLevel.INFORMATION ← was int 2 - │ - ▼ -UiPathSpan.to_dict() - {"Status": "Ok", "Source": "CodedAgents", ...} ← strings, not ints - │ - ▼ -POST {base_url}/api/Traces/v3/spans?traceId=...&source=CodedAgents - (was /api/Traces/spans) -``` - ---- - -## Files Changed - -| File | Change | -|------|--------| -| `packages/uipath-platform/src/uipath/platform/common/_span_utils.py` | Replace `IntEnum` types; add `SpanStatus`, `SpanSource`, `ExecutionType` as `StrEnum`; update `UiPathSpan` field types; update `otel_span_to_uipath_span()` | -| `packages/uipath/src/uipath/tracing/_otel_exporters.py` | Remove `SpanStatus` int class; import from `_span_utils`; update `_build_url()`, `upsert_span()`, `_determine_status()` | -| `packages/uipath/src/uipath/tracing/_live_tracking_processor.py` | Update `SpanStatus` import | -| `packages/uipath/tests/tracing/test_otel_exporters.py` | Update status/source/verbosity assertions from ints to strings; update URL assertions to `v3/spans` | - ---- - -## Error Handling - -No new error handling needed. The V3 endpoint returns `400` for malformed IDs or integer enums — these are programming errors (wrong enum values sent), not runtime conditions. Existing retry logic (4 attempts, exponential backoff) handles transient `5xx` responses unchanged. - ---- - -## Testing - -- Existing unit tests in `test_otel_exporters.py` updated to assert string enum values and `v3/spans` URL. -- No new test scenarios needed: the V3 format change is purely serialization; logic paths are the same. -- Live tracking test (`upsert_span` with `RUNNING`) updated to assert `"Status": "Running"`. From 3f2f1675cfe37b8fe93043c18223021ad5698183 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Wed, 24 Jun 2026 16:17:38 -0700 Subject: [PATCH 13/17] fix(tracing): align v3 span payload with backend SpanV3Req; bump versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Emit ReferenceVersion (was AgentVersion) — SpanV3Req has no AgentVersion field; agent version is carried by ReferenceVersion (pairs with ReferenceId) - Default org/tenant/folder Guid fields to None instead of "" so unset env vars omit cleanly rather than 400 the v3 ingest serializer - Bump uipath 2.11.12->2.11.13, uipath-platform 0.1.76->0.1.77 (versions already on PyPI) - ruff format test_otel_exporters.py Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016228TgPXqjJTxmZDY4Jtmd --- packages/uipath-platform/pyproject.toml | 2 +- .../src/uipath/platform/common/_span_utils.py | 13 ++++-- .../tests/services/test_span_utils.py | 43 ++++++++++++++++--- packages/uipath-platform/uv.lock | 4 +- packages/uipath/pyproject.toml | 2 +- .../tests/tracing/test_otel_exporters.py | 4 +- packages/uipath/uv.lock | 6 +-- 7 files changed, 57 insertions(+), 17 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index cfe85a61e..e4da0a0ce 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" 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 370a180ad..bd06319ba 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -153,15 +153,18 @@ class UiPathSpan: 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") + # Emit None (omitted on the wire) rather than "" when unset: the v3 ingest + # endpoint binds these to Guid fields and a "" value crashes the serializer + # (400) instead of failing cleanly. organization_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", "") + default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID") or None ) tenant_id: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_TENANT_ID", "") + default_factory=lambda: env.get("UIPATH_TENANT_ID") or None ) expiry_time_utc: Optional[str] = None folder_key: Optional[str] = field( - default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "") + default_factory=lambda: env.get("UIPATH_FOLDER_KEY") or None ) source: SpanSource = SpanSource.CODED_AGENTS span_type: str = "Coded Agents" @@ -226,7 +229,9 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: "JobKey": self.job_key, "ReferenceId": self.reference_id, "ExecutionType": self.execution_type, - "AgentVersion": self.agent_version, + # v3 ingest (SpanV3Req) has no AgentVersion field; the agent version + # is carried by ReferenceVersion (pairs with ReferenceId above). + "ReferenceVersion": self.agent_version, "Attachments": attachments_out, } if self.verbosity_level is not None: diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index 28b4e4097..93c9b4c6a 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -67,6 +67,39 @@ def test_enums_are_strings(self): assert isinstance(ExecutionType.RUNTIME, str) +class TestGuidFieldDefaults: + """Guid-typed env-derived fields must default to None, not "". + + v3 ingest (SpanV3Req) binds OrganizationId/FolderKey/TenantId to Guid + fields; an empty string crashes the serializer (400). When the env vars + are unset the span must omit these (None) rather than send "". + """ + + def test_guid_fields_none_when_env_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.delenv(var, raising=False) + + span = UiPathSpan(id="s", trace_id="t", name="n", attributes={}) + + assert span.organization_id is None + assert span.tenant_id is None + assert span.folder_key is None + + def test_empty_string_env_coerced_to_none( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.setenv(var, "") + + span = UiPathSpan(id="s", trace_id="t", name="n", attributes={}) + + assert span.organization_id is None + assert span.tenant_id is None + assert span.folder_key is None + + class TestOTelToUiPathSpan: """OTEL attribute -> top-level UiPathSpan field mapping. @@ -80,7 +113,7 @@ class TestOTelToUiPathSpan: ATTRIBUTE_FIELD_MAP = [ # (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"), + ("agentVersion", "agent_version", "ReferenceVersion", "1.2.3", "1.2.3"), ("agentId", "reference_id", "ReferenceId", "ref-abc", "ref-abc"), ("verbosityLevel", "verbosity_level", "VerbosityLevel", 6, VerbosityLevel.OFF), ] @@ -619,7 +652,7 @@ def test_uipath_span_includes_execution_type(self): @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_includes_agent_version(self): - """Test that agentVersion from attributes becomes top-level AgentVersion.""" + """Test that agentVersion from attributes becomes top-level ReferenceVersion.""" mock_span = Mock(spec=OTelSpan) trace_id = 0x123456789ABCDEF0123456789ABCDEF0 @@ -641,7 +674,7 @@ def test_uipath_span_includes_agent_version(self): uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) span_dict = uipath_span.to_dict() - assert span_dict["AgentVersion"] == "2.0.0" + assert span_dict["ReferenceVersion"] == "2.0.0" assert uipath_span.agent_version == "2.0.0" @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) @@ -669,7 +702,7 @@ def test_uipath_span_execution_type_and_agent_version_both(self): span_dict = uipath_span.to_dict() assert span_dict["ExecutionType"] == ExecutionType.RUNTIME - assert span_dict["AgentVersion"] == "1.0.0" + assert span_dict["ReferenceVersion"] == "1.0.0" @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_missing_execution_type_and_agent_version(self): @@ -696,7 +729,7 @@ def test_uipath_span_missing_execution_type_and_agent_version(self): span_dict = uipath_span.to_dict() assert span_dict["ExecutionType"] is None - assert span_dict["AgentVersion"] is None + assert span_dict["ReferenceVersion"] is None @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) def test_uipath_span_source_defaults_to_coded_agents(self): diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2c3e5d025..94925ec54 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:55:56.0776194Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..48a175c5a 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.13" 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" diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 73445cf7b..4a9b15ecf 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -915,7 +915,9 @@ def test_export_posts_to_v3_url_with_string_enums(self, mock_env_vars): 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] + payload: list[dict[str, object]] = ( + call_args.kwargs.get("json") or call_args.args[1] + ) assert len(payload) == 1 span_payload = payload[0] diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..64fee39bc 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-22T13:56:19.8527915Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2691,7 +2691,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.1.76" +version = "0.1.77" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" }, From cf8eba78ba591f583b1e158d0e0212bf6905501c Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Wed, 24 Jun 2026 16:19:34 -0700 Subject: [PATCH 14/17] fix(deps): bump uipath min pin on uipath-platform to >=0.1.77 uipath's tracing code now imports SpanStatus from uipath-platform, so a standalone uipath install must require the new 0.1.77. Satisfies the check-dependency-bumps CI gate for co-changed internal packages. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016228TgPXqjJTxmZDY4Jtmd --- packages/uipath/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 48a175c5a..5aac460fc 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "uipath-core>=0.5.21, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", - "uipath-platform>=0.1.76, <0.2.0", + "uipath-platform>=0.1.77, <0.2.0", "click>=8.3.1", "httpx>=0.28.1", "pyjwt>=2.10.1", From 863914700a3c5c11078a3655a1e174c2a341cd70 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Thu, 25 Jun 2026 14:40:04 -0700 Subject: [PATCH 15/17] fix(tracing): map GraphInterrupt to Running and mirror full SourceEnum - _determine_status: GraphInterrupt is a HITL pause, map to SpanStatus.RUNNING (restores v2 int-3==Running semantics; StatusEnum has no Interrupted member) - SpanSource / _SOURCE_BY_INT: mirror the server's full 17-member SourceEnum (0-16) so every server-known source round-trips; previously only {1,2,3,4,10} were mapped and other ints were silently relabeled CodedAgents (data-fidelity regression, since v3 rejects raw int forwarding) - Unknown uipath.source int now logs a warning instead of silent relabel - Tests: graph_interrupt expects Running; parametrized full int->source map; unknown-int warning test Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016228TgPXqjJTxmZDY4Jtmd --- .../src/uipath/platform/common/_span_utils.py | 50 ++++++++++++++-- .../tests/services/test_span_utils.py | 58 +++++++++++++++++++ .../src/uipath/tracing/_otel_exporters.py | 7 ++- .../tests/tracing/test_otel_exporters.py | 7 ++- 4 files changed, 112 insertions(+), 10 deletions(-) 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 bd06319ba..b512f15f1 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -28,11 +28,28 @@ class SpanStatus(StrEnum): class SpanSource(StrEnum): + # Mirrors the server's SourceEnum + # (llm-observability: UiPath.LLMOps.DataAccess/Models/SourceEnum.cs). + # Member name = exact wire string (no naming policy on the v3 API). + # Keep complete: an unknown int is relabeled CodedAgents (see + # otel_span_to_uipath_span), and v3 rejects raw integers. + TESTING = "Testing" AGENTS = "Agents" PROCESS_ORCHESTRATION = "ProcessOrchestration" API_WORKFLOWS = "ApiWorkflows" ROBOTS = "Robots" + CONVERSATIONAL_AGENTS_SERVICE = "ConversationalAgentsService" + INTEGRATION_SERVICE_TRIGGER = "IntegrationServiceTrigger" + PLAYGROUND = "Playground" + GOVERNANCE = "Governance" + IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS = "IXPUnstructuredAndComplexDocuments" CODED_AGENTS = "CodedAgents" + IXP_COMMUNICATIONS_MINING = "IXPCommunicationsMining" + ENTERPRISE_CONTEXT_SERVICE = "EnterpriseContextService" + MCP = "MCP" + A2A = "A2A" + SERVERLESS = "Serverless" + DOCUMENT_UNDERSTANDING = "DocumentUnderstanding" class VerbosityLevel(StrEnum): @@ -67,11 +84,23 @@ class ExecutionType(StrEnum): } _SOURCE_BY_INT: dict[int, SpanSource] = { + 0: SpanSource.TESTING, 1: SpanSource.AGENTS, 2: SpanSource.PROCESS_ORCHESTRATION, 3: SpanSource.API_WORKFLOWS, 4: SpanSource.ROBOTS, + 5: SpanSource.CONVERSATIONAL_AGENTS_SERVICE, + 6: SpanSource.INTEGRATION_SERVICE_TRIGGER, + 7: SpanSource.PLAYGROUND, + 8: SpanSource.GOVERNANCE, + 9: SpanSource.IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS, 10: SpanSource.CODED_AGENTS, + 11: SpanSource.IXP_COMMUNICATIONS_MINING, + 12: SpanSource.ENTERPRISE_CONTEXT_SERVICE, + 13: SpanSource.MCP, + 14: SpanSource.A2A, + 15: SpanSource.SERVERLESS, + 16: SpanSource.DOCUMENT_UNDERSTANDING, } @@ -399,13 +428,22 @@ def otel_span_to_uipath_span( else None ) - # Source: override via uipath.source attribute, else CodedAgents + # Source: override via uipath.source attribute, else CodedAgents. + # An unknown int is relabeled CodedAgents but logged — v3 ingest rejects + # raw integers, so an unmapped value cannot be forwarded verbatim. 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 - ) + source: SpanSource = SpanSource.CODED_AGENTS + if isinstance(uipath_source_raw, int) and not isinstance( + uipath_source_raw, bool + ): + mapped_source = _SOURCE_BY_INT.get(uipath_source_raw) + if mapped_source is None: + logger.warning( + "Unknown uipath.source int %s; defaulting to CodedAgents", + uipath_source_raw, + ) + else: + source = mapped_source 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 93c9b4c6a..8dbdf97e2 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -1,4 +1,5 @@ import json +import logging import os from datetime import datetime from unittest.mock import Mock, patch @@ -9,6 +10,7 @@ from uipath.platform.common import UiPathSpan, _SpanUtils from uipath.platform.common._span_utils import ( + _SOURCE_BY_INT, ExecutionType, SpanSource, SpanStatus, @@ -797,6 +799,62 @@ def test_uipath_span_source_override_with_uipath_source(self): attrs = json.loads(span_dict["Attributes"]) assert attrs["source"] == "runtime" + @pytest.mark.parametrize(("source_int", "expected"), list(_SOURCE_BY_INT.items())) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_uipath_source_int_maps_to_full_source_enum( + self, source_int: int, expected: SpanSource + ) -> None: + """Every server-known SourceEnum int round-trips (no silent relabeling).""" + mock_span = Mock(spec=OTelSpan) + 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 = StatusCode.OK + mock_span.attributes = {"uipath.source": source_int} + 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 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.source == expected + assert uipath_span.to_dict()["Source"] == expected.value + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_unknown_uipath_source_int_warns_and_defaults( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """An unmapped uipath.source int is relabeled CodedAgents and logged.""" + mock_span = Mock(spec=OTelSpan) + 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 = StatusCode.OK + mock_span.attributes = {"uipath.source": 999} + 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 + + with caplog.at_level(logging.WARNING): + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.source == SpanSource.CODED_AGENTS + assert any("999" in record.message for record in caplog.records) + class TestUiPathSpanDictUsesStrings: def test_default_status_is_ok_string(self): diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index 6137c9e60..7b705f3be 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -300,7 +300,12 @@ def _map_tool_call_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any def _determine_status(self, error: Optional[Any]) -> SpanStatus: if error: if isinstance(error, str) and error.startswith("GraphInterrupt("): - return SpanStatus.CANCELLED + # HITL pause — agent is suspended awaiting human input, not aborted. + # Preserves prior wire behavior (int 3 == Running) and matches the + # Agent Builder runtime, which keeps interrupted runs Running + # (ConversationalEngineWorkflow waits via WaitConditionAsync; no + # terminal status). StatusEnum has no Interrupted member. + return SpanStatus.RUNNING return SpanStatus.ERROR return SpanStatus.OK diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index 4a9b15ecf..2ecf930fc 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -300,11 +300,12 @@ def test_determine_status_error_returns_string(mock_env_vars): assert exporter._determine_status("some error") == SpanStatus.ERROR -def test_determine_status_graph_interrupt_returns_cancelled(mock_env_vars): +def test_determine_status_graph_interrupt_returns_running(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 + # GraphInterrupt is a HITL pause (still in-progress), not a terminal abort. + assert exporter._determine_status("GraphInterrupt()") == "Running" + assert exporter._determine_status("GraphInterrupt()") == SpanStatus.RUNNING class TestLangchainExporter(unittest.TestCase): From 8d7c0c0e405e1232caf60b9d66641ab345644f5e Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Thu, 25 Jun 2026 15:26:25 -0700 Subject: [PATCH 16/17] fix(tracing): apply bool guard to all int lookups; omit unset Guid keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _enum_from_int helper now backs executionType/verbosityLevel/source so all three ignore bool (int subclass) identically — previously only source was guarded, so executionType=True/verbosityLevel=True still mapped to the value-1 member (Runtime/Trace) - to_dict now omits OrganizationId/TenantId/FolderKey when None instead of emitting null; corrected the stale comment that claimed they were omitted - Tests: bool-attributes-do-not-map; None Guid keys omitted from to_dict; set Guid keys still present Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016228TgPXqjJTxmZDY4Jtmd --- .../src/uipath/platform/common/_span_utils.py | 63 +++++++++++-------- .../tests/services/test_span_utils.py | 57 +++++++++++++++++ 2 files changed, 95 insertions(+), 25 deletions(-) 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 b512f15f1..3db31faed 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -8,7 +8,7 @@ from enum import IntEnum, StrEnum from functools import lru_cache from os import environ as env -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, TypeVar from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.trace import StatusCode @@ -103,6 +103,20 @@ class ExecutionType(StrEnum): 16: SpanSource.DOCUMENT_UNDERSTANDING, } +_IntEnumT = TypeVar("_IntEnumT") + + +def _enum_from_int(table: dict[int, _IntEnumT], raw: Any) -> Optional[_IntEnumT]: + """Map a raw OTEL int attribute to its enum member, or None. + + Returns None for missing / non-int input — and explicitly for ``bool``, + since ``bool`` is an ``int`` subclass (``True == 1``) and would otherwise + be coerced to the value-1 member. + """ + if isinstance(raw, bool) or not isinstance(raw, int): + return None + return table.get(raw) + @lru_cache(maxsize=1) def _read_config_id() -> str | None: @@ -182,9 +196,10 @@ class UiPathSpan: 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") - # Emit None (omitted on the wire) rather than "" when unset: the v3 ingest - # endpoint binds these to Guid fields and a "" value crashes the serializer - # (400) instead of failing cleanly. + # Default to None (not "") when unset; to_dict() then omits these keys + # entirely. The v3 ingest endpoint binds them to Guid fields: "" crashes the + # serializer, and even null fails for the required OrganizationId/FolderKey. + # In the platform runtime these are always set to real GUIDs. organization_id: Optional[str] = field( default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID") or None ) @@ -263,6 +278,11 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]: "ReferenceVersion": self.agent_version, "Attachments": attachments_out, } + # Omit Guid-typed keys when unset — v3 binds them to Guid columns and + # rejects null/"". When present (platform runtime) they hold real GUIDs. + for guid_key in ("OrganizationId", "TenantId", "FolderKey"): + if result[guid_key] is None: + del result[guid_key] if self.verbosity_level is not None: result["VerbosityLevel"] = self.verbosity_level return result @@ -410,40 +430,33 @@ def otel_span_to_uipath_span( span_type_value = attributes_dict.get("span_type", "OpenTelemetry") span_type = str(span_type_value) - # Top-level fields for internal tracing schema - 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 + # Top-level fields for internal tracing schema. The int->enum lookups go + # through _enum_from_int so they all ignore bools/non-ints identically. + execution_type = _enum_from_int( + _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" ) - 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 + verbosity_level = _enum_from_int( + _VERBOSITY_LEVEL_BY_INT, attributes_dict.get("verbosityLevel") ) # Source: override via uipath.source attribute, else CodedAgents. - # An unknown int is relabeled CodedAgents but logged — v3 ingest rejects - # raw integers, so an unmapped value cannot be forwarded verbatim. + # A real int that isn't a known source is relabeled CodedAgents but + # logged — v3 ingest rejects raw integers, so it can't be forwarded. uipath_source_raw = attributes_dict.get("uipath.source") - source: SpanSource = SpanSource.CODED_AGENTS - if isinstance(uipath_source_raw, int) and not isinstance( - uipath_source_raw, bool - ): - mapped_source = _SOURCE_BY_INT.get(uipath_source_raw) - if mapped_source is None: + source = _enum_from_int(_SOURCE_BY_INT, uipath_source_raw) + if source is None: + if isinstance(uipath_source_raw, int) and not isinstance( + uipath_source_raw, bool + ): logger.warning( "Unknown uipath.source int %s; defaulting to CodedAgents", uipath_source_raw, ) - else: - source = mapped_source + source = 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 8dbdf97e2..9bd53b43b 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -101,6 +101,33 @@ def test_empty_string_env_coerced_to_none( assert span.tenant_id is None assert span.folder_key is None + def test_none_guid_fields_omitted_from_to_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Unset Guid fields are omitted from the wire payload, not sent as null.""" + for var in ("UIPATH_ORGANIZATION_ID", "UIPATH_TENANT_ID", "UIPATH_FOLDER_KEY"): + monkeypatch.delenv(var, raising=False) + + d = UiPathSpan(id="s", trace_id="t", name="n", attributes={}).to_dict() + + assert "OrganizationId" not in d + assert "TenantId" not in d + assert "FolderKey" not in d + + def test_set_guid_fields_present_in_to_dict( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Guid fields that are set are still emitted.""" + monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "org-1") + monkeypatch.setenv("UIPATH_FOLDER_KEY", "folder-1") + monkeypatch.delenv("UIPATH_TENANT_ID", raising=False) + + d = UiPathSpan(id="s", trace_id="t", name="n", attributes={}).to_dict() + + assert d["OrganizationId"] == "org-1" + assert d["FolderKey"] == "folder-1" + assert "TenantId" not in d + class TestOTelToUiPathSpan: """OTEL attribute -> top-level UiPathSpan field mapping. @@ -855,6 +882,36 @@ def test_unknown_uipath_source_int_warns_and_defaults( assert uipath_span.source == SpanSource.CODED_AGENTS assert any("999" in record.message for record in caplog.records) + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_bool_attributes_do_not_map_as_ints(self) -> None: + """bool is an int subclass; True must not map to the value-1 enum member.""" + mock_span = Mock(spec=OTelSpan) + 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 = StatusCode.OK + mock_span.attributes = { + "executionType": True, + "verbosityLevel": True, + "uipath.source": 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 + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.execution_type is None + assert uipath_span.verbosity_level is None + assert uipath_span.source == SpanSource.CODED_AGENTS + class TestUiPathSpanDictUsesStrings: def test_default_status_is_ok_string(self): From 3bcb72d80be18d76c0c1374007e9915294f7b892 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Thu, 25 Jun 2026 15:40:43 -0700 Subject: [PATCH 17/17] fix(tracing): use explicit is-not-None check for status_override status_override is SpanStatus | None; branch on 'is not None' to match the type contract rather than truthiness (Copilot review). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016228TgPXqjJTxmZDY4Jtmd --- packages/uipath/src/uipath/tracing/_live_tracking_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py index 203504611..4b0aa0b92 100644 --- a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py +++ b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py @@ -58,7 +58,7 @@ def _upsert_span_async( def _upsert(): try: - if status_override: + if status_override is not None: self.exporter.upsert_span(span, status_override=status_override) else: self.exporter.upsert_span(span)