From d13f011a1410f08a153148e744ca7e0016489fb5 Mon Sep 17 00:00:00 2001 From: Sakshar Thakkar Date: Tue, 26 May 2026 12:01:13 -0700 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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"`.