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/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 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" },