feat: add ReferenceContext for span reference hierarchy propagation#1708
feat: add ReferenceContext for span reference hierarchy propagation#1708jepadil23 wants to merge 3 commits into
Conversation
Introduces an immutable, copy-on-write ReferenceContext (modelled after service-common BaggageContext) that propagates the reference hierarchy through all spans emitted to LLMOps/Traceview. - `_reference_context.py`: ReferenceEntry, ReferenceContext (add / to_wire_list / from_baggage_header / to_baggage_header_value), ReferenceContextAccessor (ContextVar-backed, async-safe) - `_span_utils.py`: UiPathSpan gains `context` field; to_dict() emits `Context.referenceHierarchy` when set; otel_span_to_uipath_span() reads ReferenceContextAccessor and builds the wire payload - `uipath.tracing`: re-exports ReferenceEntry, ReferenceContext, ReferenceContextAccessor for downstream consumers - Tests: 30 new tests covering immutability, wire format, baggage header round-trip, accessor lifecycle, and span wiring Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…hSpan
UIPATH_FOLDER_KEY, UIPATH_ORGANIZATION_ID, and UIPATH_TENANT_ID default
to "" when unset. SpanReq.FolderKey is Guid? on the server — sending ""
causes a JSON deserialization exception ("could not convert to
System.Nullable[Guid]") which cascades into "The spans field is required"
from ASP.NET's model binding. Normalize all three to None so unset vars
serialize as null instead of an unparseable empty string.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…Processor thread boundary ContextVar values are not propagated to background threads. The BatchSpanProcessor exports spans in a worker thread where ReferenceContextAccessor.get() always returns None, so Context.referenceHierarchy was never populated in the wire payload. Fix: register a span-start hook (_inject_reference_hierarchy) that runs synchronously in on_start — same thread/context as the span creator — reads the ambient ReferenceContext and stamps it as uipath.reference_hierarchy on the span. The attribute travels with the span to the export thread. otel_span_to_uipath_span now reads from the attribute instead of the ContextVar, and pops it from attributes_dict so it does not appear in the wire Attributes field. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
There was a problem hiding this comment.
Pull request overview
Adds a new reference-hierarchy propagation mechanism for spans by introducing an immutable ReferenceContext carried via contextvars, stamping the hierarchy onto spans at start-time to survive BatchSpanProcessor thread boundaries, and wiring that stamped hierarchy into the UiPath span wire payload (Context.referenceHierarchy).
Changes:
- Introduces
ReferenceContext/ReferenceEntryplus aReferenceContextAccessorbacked byContextVar. - Adds a span-start hook mechanism in core tracing processors and uses it in
_span_utils.pyto stampuipath.reference_hierarchyat span creation time. - Extends
UiPathSpanserialization to emit a top-levelContextfield and normalizes empty-string org/tenant/folder env vars toNone.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/uipath/src/uipath/tracing/init.py | Re-exports ReferenceContext* types from platform common tracing surface. |
| packages/uipath-platform/src/uipath/platform/common/_reference_context.py | Adds the new immutable reference hierarchy model + ContextVar accessor + header (de)serialization. |
| packages/uipath-platform/src/uipath/platform/common/_span_utils.py | Registers a span-start hook to stamp hierarchy and wires stamped hierarchy into UiPathSpan.Context. |
| packages/uipath-platform/src/uipath/platform/common/init.py | Exposes ReferenceContext* from uipath.platform.common. |
| packages/uipath-core/src/uipath/core/tracing/processors.py | Adds global span-start hook registration and invokes hooks in on_start. |
| packages/uipath-platform/tests/services/test_reference_context.py | Adds unit tests for the new reference context model + span wiring behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for hook in _span_start_hooks: | ||
| hook(span) |
| ReferenceContextAccessor.reset(token) | ||
| """ | ||
|
|
||
| __slots__ = ("_entries",) |
| if isinstance(reference_id, uuid.UUID): | ||
| id_str = str(reference_id) | ||
| elif isinstance(reference_id, str): | ||
| id_str = reference_id | ||
| else: | ||
| raise TypeError("reference_id must be a UUID or string.") |
| def to_wire_list(self) -> List[dict]: | ||
| """Serialize to the ``referenceHierarchy`` wire format. | ||
|
|
||
| Returns: | ||
| A list of dicts suitable for JSON serialization as | ||
| ``Context.referenceHierarchy`` in the span payload. | ||
| """ | ||
| result = [] | ||
| for e in self._entries: | ||
| item: dict = { | ||
| "serviceType": e.service_type, | ||
| "referenceId": e.reference_id, | ||
| } | ||
| if e.version: | ||
| item["version"] = e.version | ||
| result.append(item) | ||
| return result |
| return cls._current.get() | ||
|
|
||
| @classmethod | ||
| def set(cls, value: Optional[ReferenceContext]) -> contextvars.Token: |
| return cls._current.set(value) | ||
|
|
||
| @classmethod | ||
| def reset(cls, token: contextvars.Token) -> None: |
| import json | ||
| import os | ||
| from datetime import datetime | ||
| from unittest.mock import Mock | ||
|
|
||
| import pytest | ||
| from opentelemetry.sdk.trace import Span as OTelSpan | ||
| from opentelemetry.trace import SpanContext, StatusCode | ||
|
|
||
| from uipath.platform.common import _SpanUtils | ||
| from uipath.platform.common._reference_context import ( | ||
| ReferenceContext, | ||
| ReferenceContextAccessor, | ||
| ReferenceEntry, | ||
| ) |
| # Helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| def _make_mock_span(attributes: dict | None = None) -> Mock: |
| class ReferenceContextAccessor: | ||
| """Ambient accessor for the current :class:`ReferenceContext`. | ||
|
|
||
| Backed by :mod:`contextvars` so the value propagates across ``await`` | ||
| boundaries without being threaded through every call signature. |
| _span_start_hooks: list[Callable[[Span], None]] = [] | ||
|
|
||
|
|
||
| def register_span_start_hook(hook: Callable[[Span], None]) -> None: |
There was a problem hiding this comment.
Could we make this a SpanProcessor subclass registered via trace_manager.add_span_processor() (same pattern as LiveTrackingSpanProcessor) instead of the new register_span_start_hook list? Its on_start already fires for every span in the creating thread, so it covers auto-instrumented spans too — and it avoids adding a new public API + the import-time side effect (just register it before LiveTrackingSpanProcessor since that one upserts on start).
|
You'll need to bump up the version here and then use it in other repos. |
|
Please make sure to address the review comments by Copilot and address these CI requirements |



Summary
ReferenceContext— an immutable, copy-on-write ordered list ofReferenceEntryobjects (serviceType,referenceId,version) that flows viacontextvars.ContextVaracross await boundaries_span_utils.pyreads fromReferenceContextAccessorto populateUiPathSpan.context.referenceHierarchy— wire-format compatible with service-commonx-uipath-tracebaggageheaderBatchSpanProcessorthread-boundary issue by stamping the hierarchy as a span attribute at span startUIPATH_ORGANIZATION_ID,UIPATH_TENANT_ID,UIPATH_FOLDER_KEY) toNonefor Guid fields inUiPathSpanTest plan
tests/services/test_reference_context.py— unit tests forReferenceContext,ReferenceEntry, andReferenceContextAccessor(push/pop/reset, async propagation, wire serialization)UiPathSpan.context.referenceHierarchyis populated whenReferenceContextAccessorhas entries set upstreamNonein span fields🤖 Generated with Claude Code