Skip to content

Commit 1652241

Browse files
feat(tracing): derive LLM Ops URL source param from span Source field (#1783)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a5a5090 commit 1652241

4 files changed

Lines changed: 83 additions & 8 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.12.4"
3+
version = "2.12.5"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/tracing/_otel_exporters.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from uipath._utils._ssl_context import get_httpx_client_kwargs
1515
from uipath.platform.common import _SpanUtils
16-
from uipath.platform.common._span_utils import SpanStatus
16+
from uipath.platform.common._span_utils import SpanSource, SpanStatus
1717
from uipath.platform.common.retry import NON_RETRYABLE_STATUS_CODES
1818
from uipath.platform.constants import (
1919
ENV_BASE_URL,
@@ -381,11 +381,15 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None:
381381
span_data["Status"] = status
382382

383383
def _build_url(self, span_list: list[Dict[str, Any]]) -> str:
384-
"""Construct the URL for the API request."""
384+
"""Construct the URL for the API request.
385+
386+
The `source` query param is what the server persists as Trace.Source
387+
(the span-body Source is ignored on ingest), so derive it from the
388+
span's resolved SpanSource. Falls back to CodedAgents when absent.
389+
"""
385390
trace_id = str(span_list[0]["TraceId"])
386-
return (
387-
f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source=CodedAgents"
388-
)
391+
source = str(span_list[0].get("Source") or SpanSource.CODED_AGENTS)
392+
return f"{self.base_url}/api/Traces/v3/spans?traceId={trace_id}&source={source}"
389393

390394
def _send_with_retries(
391395
self, url: str, payload: list[Dict[str, Any]], max_retries: int = 4

packages/uipath/tests/tracing/test_otel_exporters.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from opentelemetry.sdk.trace import ReadableSpan
88
from opentelemetry.sdk.trace.export import SpanExportResult
99

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

292292

293+
def test_build_url_uses_span_source_agents(mock_env_vars):
294+
"""_build_url must render the span's Source (Agents), not the hardcoded CodedAgents."""
295+
with patch("uipath.tracing._otel_exporters.httpx.Client"):
296+
exporter = LlmOpsHttpExporter()
297+
span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.AGENTS}]
298+
url = exporter._build_url(span_list)
299+
assert "&source=Agents" in url
300+
assert "&source=CodedAgents" not in url
301+
assert "/api/Traces/v3/spans" in url
302+
303+
304+
def test_build_url_uses_span_source_coded_agents(mock_env_vars):
305+
"""An explicit CodedAgents Source still renders CodedAgents."""
306+
with patch("uipath.tracing._otel_exporters.httpx.Client"):
307+
exporter = LlmOpsHttpExporter()
308+
span_list = [{"TraceId": "ab" * 16, "Source": SpanSource.CODED_AGENTS}]
309+
url = exporter._build_url(span_list)
310+
assert "&source=CodedAgents" in url
311+
312+
313+
def test_build_url_defaults_to_coded_agents_when_source_missing(mock_env_vars):
314+
"""When the span dict has no Source key, default to CodedAgents (back-compat)."""
315+
with patch("uipath.tracing._otel_exporters.httpx.Client"):
316+
exporter = LlmOpsHttpExporter()
317+
span_list = [{"TraceId": "ab" * 16}]
318+
url = exporter._build_url(span_list)
319+
assert "&source=CodedAgents" in url
320+
321+
322+
def test_agent_builder_span_yields_source_agents(mock_env_vars):
323+
"""A span with uipath.source=1 must flow through to &source=Agents in the URL.
324+
325+
Drives a real span dict through otel_span_to_uipath_span().to_dict() rather
326+
than hand-building it, guarding the whole attribute->Source->URL path.
327+
"""
328+
from opentelemetry.trace import SpanContext, StatusCode
329+
330+
from uipath.platform.common import _SpanUtils
331+
332+
# otel_span_to_uipath_span reads the context via get_span_context() and
333+
# formats trace_id/span_id as hex, so provide a real SpanContext.
334+
span = MagicMock(spec=ReadableSpan)
335+
span.get_span_context.return_value = SpanContext(
336+
trace_id=0xABCDEF1234567890ABCDEF1234567890,
337+
span_id=0x1234567890ABCDEF,
338+
is_remote=False,
339+
)
340+
span.parent = None
341+
span.name = "agent-span"
342+
span.status.status_code = StatusCode.OK
343+
span.status.description = None
344+
span.attributes = {
345+
"uipath.custom_instrumentation": True,
346+
"uipath.source": 1, # SourceEnum.Agents
347+
}
348+
span.events = []
349+
span.links = []
350+
span.start_time = 0
351+
span.end_time = 1
352+
353+
span_dict = _SpanUtils.otel_span_to_uipath_span(
354+
span, serialize_attributes=False
355+
).to_dict(serialize_attributes=False)
356+
assert str(span_dict["Source"]) == "Agents"
357+
358+
with patch("uipath.tracing._otel_exporters.httpx.Client"):
359+
exporter = LlmOpsHttpExporter()
360+
url = exporter._build_url([span_dict])
361+
assert "&source=Agents" in url
362+
363+
293364
def test_determine_status_ok_returns_string(mock_env_vars):
294365
with patch("uipath.tracing._otel_exporters.httpx.Client"):
295366
exporter = LlmOpsHttpExporter()

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)