diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index efcce771c..20bc31c4f 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -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" diff --git a/packages/uipath/src/uipath/tracing/_otel_exporters.py b/packages/uipath/src/uipath/tracing/_otel_exporters.py index fd7d01d5c..f8ec3aace 100644 --- a/packages/uipath/src/uipath/tracing/_otel_exporters.py +++ b/packages/uipath/src/uipath/tracing/_otel_exporters.py @@ -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, @@ -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 diff --git a/packages/uipath/tests/tracing/test_otel_exporters.py b/packages/uipath/tests/tracing/test_otel_exporters.py index bba826be5..939dbb8cd 100644 --- a/packages/uipath/tests/tracing/test_otel_exporters.py +++ b/packages/uipath/tests/tracing/test_otel_exporters.py @@ -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, @@ -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() diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 87ca9e781..8156765aa 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.12.4" +version = "2.12.5" source = { editable = "." } dependencies = [ { name = "applicationinsights" },