Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d13f011
docs: add trace v3 ingestion migration design spec
saksharthakkar May 26, 2026
1bd05ad
docs: add trace v3 ingestion implementation plan
saksharthakkar May 26, 2026
23e31a3
feat(tracing): add SpanStatus, SpanSource, ExecutionType, VerbosityLe…
saksharthakkar May 26, 2026
dcef460
fix(tracing): fix ruff E302, consolidate enum imports, add Task 2 TOD…
saksharthakkar May 26, 2026
a290d07
feat(tracing): update UiPathSpan fields and otel conversion to use St…
saksharthakkar May 26, 2026
ca0bf0a
feat(tracing): export SpanStatus, SpanSource, ExecutionType, Verbosit…
saksharthakkar May 26, 2026
2f61f08
feat(tracing): migrate LlmOpsHttpExporter to v3 ingest endpoint with …
saksharthakkar May 26, 2026
0e5526c
fix(tracing): update stale v2 URL in TestUpsertSpan fixture
saksharthakkar May 26, 2026
6451b5f
feat(tracing): update LiveTrackingSpanProcessor to use SpanStatus fro…
saksharthakkar May 26, 2026
aad6b3e
chore(tracing): final lint, type-check and integration test for v3 mi…
saksharthakkar May 26, 2026
d9222ed
chore(tracing): fix mypy type annotation in integration test, add sho…
saksharthakkar May 26, 2026
e2c0a55
chore: remove docs from branch (keep locally only)
saksharthakkar May 26, 2026
f642e7b
Merge origin/main into feat/trace-v3-migration; resolve _span_utils c…
saksharthakkar Jun 24, 2026
3f2f167
fix(tracing): align v3 span payload with backend SpanV3Req; bump vers…
saksharthakkar Jun 24, 2026
cf8eba7
fix(deps): bump uipath min pin on uipath-platform to >=0.1.77
saksharthakkar Jun 24, 2026
e2e3d73
Merge origin/main into feat/trace-v3-migration
saksharthakkar Jun 25, 2026
8639147
fix(tracing): map GraphInterrupt to Running and mirror full SourceEnum
saksharthakkar Jun 25, 2026
8d7c0c0
fix(tracing): apply bool guard to all int lookups; omit unset Guid keys
saksharthakkar Jun 25, 2026
3bcb72d
fix(tracing): use explicit is-not-None check for status_override
saksharthakkar Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/uipath-platform/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-platform"
version = "0.1.78"
version = "0.1.79"
description = "HTTP client library for programmatic access to UiPath Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
13 changes: 12 additions & 1 deletion packages/uipath-platform/src/uipath/platform/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,11 +112,15 @@
"jsonschema_to_pydantic",
"ConnectionResourceOverwrite",
"EntityResourceOverwrite",
"ExecutionType",
"GenericResourceOverwrite",
"ResourceOverwrite",
"ResourceOverwriteParser",
"ResourceOverwritesContext",
"SpanSource",
"SpanStatus",
"UiPathSpan",
"VerbosityLevel",
"_SpanUtils",
"resolve_service_url",
"inject_routing_headers",
Expand Down
174 changes: 144 additions & 30 deletions packages/uipath-platform/src/uipath/platform/common/_span_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import IntEnum
from enum import IntEnum, StrEnum
from functools import lru_cache
from os import environ as env
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, TypeVar

from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.trace import StatusCode
Expand All @@ -17,8 +17,105 @@

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):
# Mirrors the server's SourceEnum
# (llm-observability: UiPath.LLMOps.DataAccess/Models/SourceEnum.cs).
# Member name = exact wire string (no naming policy on the v3 API).
# Keep complete: an unknown int is relabeled CodedAgents (see
# otel_span_to_uipath_span), and v3 rejects raw integers.
TESTING = "Testing"
AGENTS = "Agents"
PROCESS_ORCHESTRATION = "ProcessOrchestration"
API_WORKFLOWS = "ApiWorkflows"
ROBOTS = "Robots"
CONVERSATIONAL_AGENTS_SERVICE = "ConversationalAgentsService"
INTEGRATION_SERVICE_TRIGGER = "IntegrationServiceTrigger"
PLAYGROUND = "Playground"
GOVERNANCE = "Governance"
IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS = "IXPUnstructuredAndComplexDocuments"
CODED_AGENTS = "CodedAgents"
IXP_COMMUNICATIONS_MINING = "IXPCommunicationsMining"
ENTERPRISE_CONTEXT_SERVICE = "EnterpriseContextService"
MCP = "MCP"
A2A = "A2A"
SERVERLESS = "Serverless"
DOCUMENT_UNDERSTANDING = "DocumentUnderstanding"


class VerbosityLevel(StrEnum):
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] = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: we should probably expose these enums as a package so there isn't a copy-paste dependency

0: SpanSource.TESTING,
1: SpanSource.AGENTS,
2: SpanSource.PROCESS_ORCHESTRATION,
3: SpanSource.API_WORKFLOWS,
4: SpanSource.ROBOTS,
5: SpanSource.CONVERSATIONAL_AGENTS_SERVICE,
6: SpanSource.INTEGRATION_SERVICE_TRIGGER,
7: SpanSource.PLAYGROUND,
8: SpanSource.GOVERNANCE,
9: SpanSource.IXP_UNSTRUCTURED_AND_COMPLEX_DOCUMENTS,
10: SpanSource.CODED_AGENTS,
11: SpanSource.IXP_COMMUNICATIONS_MINING,
12: SpanSource.ENTERPRISE_CONTEXT_SERVICE,
13: SpanSource.MCP,
14: SpanSource.A2A,
15: SpanSource.SERVERLESS,
16: SpanSource.DOCUMENT_UNDERSTANDING,
}

_IntEnumT = TypeVar("_IntEnumT")


def _enum_from_int(table: dict[int, _IntEnumT], raw: Any) -> Optional[_IntEnumT]:
"""Map a raw OTEL int attribute to its enum member, or None.

Returns None for missing / non-int input — and explicitly for ``bool``,
since ``bool`` is an ``int`` subclass (``True == 1``) and would otherwise
be coerced to the value-1 member.
"""
if isinstance(raw, bool) or not isinstance(raw, int):
return None
return table.get(raw)


@lru_cache(maxsize=1)
Expand Down Expand Up @@ -65,16 +162,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."""

Expand Down Expand Up @@ -106,20 +193,24 @@ 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")
# Default to None (not "") when unset; to_dict() then omits these keys
# entirely. The v3 ingest endpoint binds them to Guid fields: "" crashes the
# serializer, and even null fails for the required OrganizationId/FolderKey.
# In the platform runtime these are always set to real GUIDs.
organization_id: Optional[str] = field(
default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID", "")
default_factory=lambda: env.get("UIPATH_ORGANIZATION_ID") or None
)
tenant_id: Optional[str] = field(
default_factory=lambda: env.get("UIPATH_TENANT_ID", "")
default_factory=lambda: env.get("UIPATH_TENANT_ID") or None
)
expiry_time_utc: Optional[str] = None
folder_key: Optional[str] = field(
default_factory=lambda: env.get("UIPATH_FOLDER_KEY", "")
default_factory=lambda: env.get("UIPATH_FOLDER_KEY") or None
)
source: int = DEFAULT_SOURCE
source: SpanSource = SpanSource.CODED_AGENTS
span_type: str = "Coded Agents"
process_key: Optional[str] = field(
default_factory=lambda: env.get("UIPATH_PROCESS_UUID")
Expand All @@ -131,9 +222,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]:
Expand Down Expand Up @@ -182,9 +273,16 @@ def to_dict(self, serialize_attributes: bool = True) -> Dict[str, Any]:
"JobKey": self.job_key,
"ReferenceId": self.reference_id,
"ExecutionType": self.execution_type,
"AgentVersion": self.agent_version,
# v3 ingest (SpanV3Req) has no AgentVersion field; the agent version
# is carried by ReferenceVersion (pairs with ReferenceId above).
"ReferenceVersion": self.agent_version,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @jepadil23 are your referencehierarchy changes in yet?

"Attachments": attachments_out,
}
# Omit Guid-typed keys when unset — v3 binds them to Guid columns and
# rejects null/"". When present (platform runtime) they hold real GUIDs.
for guid_key in ("OrganizationId", "TenantId", "FolderKey"):
if result[guid_key] is None:
del result[guid_key]
if self.verbosity_level is not None:
result["VerbosityLevel"] = self.verbosity_level
return result
Expand Down Expand Up @@ -264,9 +362,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
Expand Down Expand Up @@ -332,17 +430,33 @@ def otel_span_to_uipath_span(
span_type_value = attributes_dict.get("span_type", "OpenTelemetry")
span_type = str(span_type_value)

# Top-level fields for internal tracing schema
execution_type = attributes_dict.get("executionType")
# Top-level fields for internal tracing schema. The int->enum lookups go
# through _enum_from_int so they all ignore bools/non-ints identically.
execution_type = _enum_from_int(
_EXECUTION_TYPE_BY_INT, attributes_dict.get("executionType")
)
agent_version = attributes_dict.get("agentVersion")
reference_id = attributes_dict.get("agentId") or attributes_dict.get(
"referenceId"
)
verbosity_level = attributes_dict.get("verbosityLevel")
verbosity_level = _enum_from_int(
_VERBOSITY_LEVEL_BY_INT, 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
# Source: override via uipath.source attribute, else CodedAgents.
# A real int that isn't a known source is relabeled CodedAgents but
# logged — v3 ingest rejects raw integers, so it can't be forwarded.
uipath_source_raw = attributes_dict.get("uipath.source")
source = _enum_from_int(_SOURCE_BY_INT, uipath_source_raw)
if source is None:
if isinstance(uipath_source_raw, int) and not isinstance(
uipath_source_raw, bool
):
logger.warning(
"Unknown uipath.source int %s; defaulting to CodedAgents",
uipath_source_raw,
)
source = SpanSource.CODED_AGENTS

attachments = None
attachments_data = attributes_dict.get("attachments")
Expand Down
Loading
Loading