fix(tracing): stop fabricating EndTime for live spans#1806
Conversation
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes span conversion/serialization so in-progress (live) OpenTelemetry spans are not treated as ended spans by downstream UiPath tracing consumers. It preserves end_time=None for live spans so "EndTime": null is emitted, avoiding fabricated durations and preventing duplicate exports in pipelines that rely on EndTime == null to detect unclosed spans.
Changes:
- Update
UiPathSpan.end_timeto beOptional[str] = Noneso “not ended” is representable and serializes as JSONnull. - Update
otel_span_to_uipath_spanto preserveend_time=Nonewhen the OTEL span has not ended (instead of stampingdatetime.now()). - Add tests covering live-span (
EndTime is None) and ended-span (real end time preserved) conversion behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
packages/uipath-platform/src/uipath/platform/common/_span_utils.py |
Stops fabricating end times for live spans and makes UiPathSpan.end_time nullable so EndTime: null can be emitted. |
packages/uipath-platform/tests/services/test_span_utils.py |
Adds regression tests ensuring live spans convert with end_time=None and ended spans retain their actual end time. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Required by check-versions since 0.2.5 is already published on PyPI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🚨 Heads up:
|
|



Summary
otel_span_to_uipath_spanstampsEndTime = datetime.now()when converting a span that hasn't ended (otel_span.end_time is None), and theUiPathSpandataclass defaultsend_timetonow()too. A live OTel span legitimately hasend_time = None— the fallback is a local invention, not a framework requirement.Why it matters: the fabricated EndTime makes in-progress upserts (the
upsert_spanRUNNING → OKlifecycle) indistinguishable from ended spans everywhere downstream:EndTime == nullspans) never fires, so every v3-ingested span reaches customer OTLP endpoints twice — once as an open snapshot with a ~0–1ms duration and no status, once as the real terminal span.Verified against LLMOps alpha Table Storage: open writes arrive with
EndTime == UpdatedAt ≈ StartTime(e.g. an agentRun open write withStart .456 / End .457 / Status Unsetfollowed 33s later by the terminalOkwrite).Change
end_time = None, serialized as"EndTime": null. The LLMOps v3 ingest contract accepts null (SpanV3Req.EndTimeisDateTime?), and storage/UI already handle unclosed spans on the v2 path.UiPathSpan.end_timebecomesOptional[str] = None(the eval runtime already passesdata.get("end_time")— i.e. possiblyNone— into this field today).Not addressed here (follow-up worth considering)
Some open-write call sites don't pass
status_override=SpanStatus.RUNNING, so open snapshots land withUnsetstatus (observed on Agents-source spans) instead ofRunning. Marking all open upsertsRUNNINGwould make in-progress state explicit rather than inferred. Insights-side interim handling: UiPath/Insights-monitoring#3041.Tests
packages/uipath-platform: full suite 1434 passed, 7 skipped (pre-existing credential-gated skips). Two new tests pin the contract: live span →end_time is None/to_dict()["EndTime"] is None; ended span keeps its real end time.🤖 Generated with Claude Code