From 283999f75b462f2420fb0f2544732495e9bb4868 Mon Sep 17 00:00:00 2001 From: Joseph Mar Date: Thu, 9 Jul 2026 09:30:20 -0700 Subject: [PATCH 1/2] fix(tracing): stop fabricating EndTime for live spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit otel_span_to_uipath_span stamped EndTime=now() when converting a span that has not ended (otel_span.end_time is None), and the UiPathSpan dataclass defaulted end_time to now() as well. This makes in-progress upserts (the upsert_span RUNNING -> OK lifecycle) indistinguishable from ended spans downstream: the LLMOps traceview UI renders phantom sub-millisecond durations for open snapshots, and the Insights OTLP export's unclosed-span filter never fires — so every v3-ingested span reaches customer OTLP endpoints twice (open + terminal snapshot). Verified against LLMOps alpha storage: open writes arrive with EndTime == UpdatedAt ≈ StartTime. Live spans now convert with end_time=None, serialized as EndTime: null — the LLMOps v3 ingest contract (SpanV3Req.EndTime) is nullable, and OTel itself models unfinished spans with end_time=None; the fallback was a local invention, not a framework requirement. Ended spans are unchanged. Co-Authored-By: Claude Fable 5 --- .../src/uipath/platform/common/_span_utils.py | 11 +++++-- .../tests/services/test_span_utils.py | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 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 cd0d9d709..111dd59ec 100644 --- a/packages/uipath-platform/src/uipath/platform/common/_span_utils.py +++ b/packages/uipath-platform/src/uipath/platform/common/_span_utils.py @@ -237,7 +237,11 @@ class UiPathSpan: attributes: str | Dict[str, Any] # Support both str (legacy) and dict (optimized) 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()) + # None means the span has not ended yet. Serialized as null — the LLMOps v3 + # ingest contract (SpanV3Req.EndTime) is nullable, and downstream consumers + # (traceview UI, Insights OTLP export) rely on "EndTime set" meaning "span + # ended": fabricating a value here makes in-progress snapshots look terminal. + end_time: Optional[str] = None 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") @@ -542,13 +546,14 @@ def otel_span_to_uipath_span( (otel_span.start_time or 0) / 1e9 ).isoformat() + # A live (not-yet-ended) OTel span has end_time None — preserve that rather + # than stamping now(): a fabricated EndTime makes in-progress upserts + # (upsert_span RUNNING → OK lifecycle) indistinguishable from ended spans. end_time_str = None if otel_span.end_time is not None: end_time_str = datetime.fromtimestamp( (otel_span.end_time or 0) / 1e9 ).isoformat() - else: - end_time_str = datetime.now().isoformat() return UiPathSpan( id=span_id, diff --git a/packages/uipath-platform/tests/services/test_span_utils.py b/packages/uipath-platform/tests/services/test_span_utils.py index f45351864..b80d24e9d 100644 --- a/packages/uipath-platform/tests/services/test_span_utils.py +++ b/packages/uipath-platform/tests/services/test_span_utils.py @@ -1214,3 +1214,34 @@ def test_on_start_stamps_full_hierarchy(self) -> None: assert "version" not in hierarchy[1] finally: ReferenceContextAccessor.reset(token) + + +class TestLiveSpanEndTime: + """A live (not-yet-ended) OTEL span must convert with end_time=None. + + Fabricating EndTime=now() for in-progress upserts (the RUNNING -> OK + lifecycle used by upsert_span) makes open snapshots indistinguishable + from ended spans downstream: the traceview UI shows phantom sub-ms + durations and the Insights OTLP export's unclosed-span filter never + fires, so every span exports twice. + """ + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_live_span_converts_with_none_end_time(self) -> None: + mock_span = _make_otel_span({}) + mock_span.end_time = None + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + span_dict = uipath_span.to_dict() + + assert uipath_span.end_time is None + assert span_dict["EndTime"] is None + + @patch.dict(os.environ, {"UIPATH_ORGANIZATION_ID": "test-org"}) + def test_ended_span_keeps_real_end_time(self) -> None: + mock_span = _make_otel_span({}) + + uipath_span = _SpanUtils.otel_span_to_uipath_span(mock_span) + + assert uipath_span.end_time is not None + assert uipath_span.to_dict()["EndTime"] == uipath_span.end_time From aa26feb48b210151a11bc63db99ccd27c15528d2 Mon Sep 17 00:00:00 2001 From: Joseph Mar Date: Thu, 9 Jul 2026 11:33:00 -0700 Subject: [PATCH 2/2] chore(platform): bump uipath-platform to 0.2.6 Required by check-versions since 0.2.5 is already published on PyPI. Co-Authored-By: Claude Fable 5 --- packages/uipath-platform/pyproject.toml | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uipath-platform/pyproject.toml b/packages/uipath-platform/pyproject.toml index 72b0d0b3a..7fcf797e7 100644 --- a/packages/uipath-platform/pyproject.toml +++ b/packages/uipath-platform/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" description = "HTTP client library for programmatic access to UiPath Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2144530f3..cee6cf645 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1095,7 +1095,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 631a1a610..800e2b56a 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2741,7 +2741,7 @@ dev = [ [[package]] name = "uipath-platform" -version = "0.2.5" +version = "0.2.6" source = { editable = "../uipath-platform" } dependencies = [ { name = "httpx" },