Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath"
version = "2.12.4"
version = "2.12.5"
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand Down
14 changes: 9 additions & 5 deletions packages/uipath/src/uipath/tracing/_otel_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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._span_utils import SpanSource, SpanStatus
from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES
from uipath.platform.constants import (
ENV_BASE_URL,
Expand Down Expand Up @@ -381,11 +381,15 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None:
span_data["Status"] = status

def _build_url(self, span_list: list[Dict[str, Any]]) -> str:
"""Construct the URL for the API request."""
"""Construct the URL for the API request.

The `source` query param is what the server persists as Trace.Source
(the span-body Source is ignored on ingest), so derive it from the
span's resolved SpanSource. Falls back to CodedAgents when absent.
"""
trace_id = str(span_list[0]["TraceId"])
return (
f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source=CodedAgents"
)
source = str(span_list[0].get("Source") or SpanSource.CODED_AGENTS)
return f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source={source}"

def _send_with_retries(
self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4
Expand Down
73 changes: 72 additions & 1 deletion packages/uipath/tests/tracing/test_otel_exporters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.trace.export import SpanExportResult

from uipath.platform.common._span_utils import SpanStatus
from uipath.platform.common._span_utils import SpanSource, SpanStatus
from uipath.platform.constants import (
HEADER_INTERNAL_ACCOUNT_ID,
HEADER_INTERNAL_TENANT_ID,
Expand Down Expand Up @@ -290,6 +290,77 @@ def test_build_url_uses_v3_endpoint(mock_env_vars):
assert "/api/Traces/spans" not in url.replace("/api/Traces/v3/spans", "")


def test_build_url_uses_span_source_agents(mock_env_vars):
"""_build_url must render the span's Source (Agents), not the hardcoded CodedAgents."""
with patch("uipath.tracing._otel_exporters.httpx.Client"):
exporter = LlmOpsHttpExporter()
span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.AGENTS}]
url = exporter._build_url(span_list)
assert "&source=Agents" in url
assert "&source=CodedAgents" not in url
assert "/api/Traces/v3/spans" in url


def test_build_url_uses_span_source_coded_agents(mock_env_vars):
"""An explicit CodedAgents Source still renders CodedAgents."""
with patch("uipath.tracing._otel_exporters.httpx.Client"):
exporter = LlmOpsHttpExporter()
span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.CODED_AGENTS}]
url = exporter._build_url(span_list)
assert "&source=CodedAgents" in url


def test_build_url_defaults_to_coded_agents_when_source_missing(mock_env_vars):
"""When the span dict has no Source key, default to CodedAgents (back-compat)."""
with patch("uipath.tracing._otel_exporters.httpx.Client"):
exporter = LlmOpsHttpExporter()
span_list = [{"TraceId": "ab" * 16}]
url = exporter._build_url(span_list)
assert "&source=CodedAgents" in url


def test_agent_builder_span_yields_source_agents(mock_env_vars):
"""A span with uipath.source=1 must flow through to &source=Agents in the URL.

Drives a real span dict through otel_span_to_uipath_span().to_dict() rather
than hand-building it, guarding the whole attribute->Source->URL path.
"""
from opentelemetry.trace import SpanContext, StatusCode

from uipath.platform.common import _SpanUtils

# otel_span_to_uipath_span reads the context via get_span_context() and
# formats trace_id/span_id as hex, so provide a real SpanContext.
span = MagicMock(spec=ReadableSpan)
span.get_span_context.return_value = SpanContext(
trace_id=0xABCDEF1234567890ABCDEF1234567890,
span_id=0x1234567890ABCDEF,
is_remote=False,
)
span.parent = None
span.name = "agent-span"
span.status.status_code = StatusCode.OK
span.status.description = None
span.attributes = {
"uipath.custom_instrumentation": True,
"uipath.source": 1, # SourceEnum.Agents
}
span.events = []
span.links = []
span.start_time = 0
span.end_time = 1

span_dict = _SpanUtils.otel_span_to_uipath_span(
span, serialize_attributes=False
).to_dict(serialize_attributes=False)
assert str(span_dict["Source"]) == "Agents"

with patch("uipath.tracing._otel_exporters.httpx.Client"):
exporter = LlmOpsHttpExporter()
url = exporter._build_url([span_dict])
assert "&source=Agents" in url


def test_determine_status_ok_returns_string(mock_env_vars):
with patch("uipath.tracing._otel_exporters.httpx.Client"):
exporter = LlmOpsHttpExporter()
Expand Down
Loading