Skip to content

Commit ce98977

Browse files
jepadil23claude
andcommitted
fix: stamp reference hierarchy as span attribute to survive BatchSpanProcessor 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>
1 parent 84045a2 commit ce98977

3 files changed

Lines changed: 122 additions & 41 deletions

File tree

packages/uipath-core/src/uipath/core/tracing/processors.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Custom span processors for UiPath execution tracing."""
22

3-
from typing import cast
3+
from typing import Callable, cast
44

55
from opentelemetry import context as context_api
66
from opentelemetry import trace
@@ -13,6 +13,21 @@
1313

1414
from uipath.core.tracing.types import UiPathTraceSettings
1515

16+
# Hooks called synchronously in on_start (same thread as span creation, where
17+
# ContextVar values are still live). Use register_span_start_hook to stamp
18+
# span attributes that must survive the BatchSpanProcessor thread boundary.
19+
_span_start_hooks: list[Callable[[Span], None]] = []
20+
21+
22+
def register_span_start_hook(hook: Callable[[Span], None]) -> None:
23+
"""Register a callable invoked for every span at creation time.
24+
25+
The hook receives the live, writable Span so it can call set_attribute.
26+
Runs in the same thread/context as the span creator — ContextVar values
27+
are available here but NOT in the BatchSpanProcessor export thread.
28+
"""
29+
_span_start_hooks.append(hook)
30+
1631

1732
class UiPathExecutionTraceProcessorMixin:
1833
"""Mixin that propagates execution.id and optionally filters spans."""
@@ -32,6 +47,9 @@ def on_start(self, span: Span, parent_context: context_api.Context | None = None
3247
if execution_id:
3348
span.set_attribute("execution.id", execution_id)
3449

50+
for hook in _span_start_hooks:
51+
hook(span)
52+
3553
def on_end(self, span: ReadableSpan):
3654
"""Called when a span ends. Filters before delegating to parent."""
3755
span_filter = self._settings.span_filter if self._settings else None
@@ -73,4 +91,5 @@ def __init__(
7391
__all__ = [
7492
"UiPathExecutionBatchTraceProcessor",
7593
"UiPathExecutionSimpleTraceProcessor",
94+
"register_span_start_hook",
7695
]

packages/uipath-platform/src/uipath/platform/common/_span_utils.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@
88
from os import environ as env
99
from typing import Any, Dict, List, Optional
1010

11-
from opentelemetry.sdk.trace import ReadableSpan
11+
from opentelemetry.sdk.trace import ReadableSpan, Span
1212
from opentelemetry.trace import StatusCode
1313
from pydantic import BaseModel, ConfigDict, Field
1414
from uipath.core.serialization import serialize_json
15+
from uipath.core.tracing.processors import register_span_start_hook
1516

1617
from ._reference_context import ReferenceContextAccessor
1718

19+
20+
def _inject_reference_hierarchy(span: Span) -> None:
21+
ref_ctx = ReferenceContextAccessor.get()
22+
if ref_ctx:
23+
wire = ref_ctx.to_wire_list()
24+
if wire:
25+
span.set_attribute("uipath.reference_hierarchy", json.dumps(wire))
26+
27+
28+
register_span_start_hook(_inject_reference_hierarchy)
29+
1830
logger = logging.getLogger(__name__)
1931

2032
# SourceEnum.CodedAgents = 10 (default for Python SDK / coded agents)
@@ -232,6 +244,11 @@ def otel_span_to_uipath_span(
232244
# Only copy if we need to modify - we'll build attributes_dict lazily
233245
attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {}
234246

247+
# Pull the reference hierarchy stamped by the span-start hook (runs in the
248+
# correct thread/context; BatchSpanProcessor exports in a background thread
249+
# where ContextVar values are not available).
250+
ref_hierarchy_json = attributes_dict.pop("uipath.reference_hierarchy", None)
251+
235252
# Map status
236253
status = 1 # Default to OK
237254
if otel_span.status.status_code == StatusCode.ERROR:
@@ -331,14 +348,12 @@ def otel_span_to_uipath_span(
331348
except Exception as e:
332349
logger.warning(f"Error processing attachments: {e}")
333350

334-
# Build Context.referenceHierarchy from the ambient ReferenceContext
335-
# (set by the agent runtime at run start via ReferenceContextAccessor).
336351
context: Optional[Dict[str, Any]] = None
337-
ref_ctx = ReferenceContextAccessor.get()
338-
if ref_ctx:
339-
wire_list = ref_ctx.to_wire_list()
340-
if wire_list:
341-
context = {"referenceHierarchy": wire_list}
352+
if ref_hierarchy_json:
353+
try:
354+
context = {"referenceHierarchy": json.loads(ref_hierarchy_json)}
355+
except (json.JSONDecodeError, TypeError):
356+
pass
342357

343358
# Create UiPathSpan from OpenTelemetry span
344359
start_time = datetime.fromtimestamp(

packages/uipath-platform/tests/services/test_reference_context.py

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for ReferenceContext, ReferenceContextAccessor, and span Context wiring."""
22

3+
import json
34
import os
45
from datetime import datetime
56
from unittest.mock import Mock
@@ -247,14 +248,9 @@ def test_context_absent_when_no_reference_context_set(
247248
self, monkeypatch: pytest.MonkeyPatch
248249
) -> None:
249250
monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org")
250-
# Ensure accessor is clear
251-
token = ReferenceContextAccessor.set(None)
252-
try:
253-
span = _SpanUtils.otel_span_to_uipath_span(_make_mock_span())
254-
assert span.context is None
255-
assert "Context" not in span.to_dict()
256-
finally:
257-
ReferenceContextAccessor.reset(token)
251+
span = _SpanUtils.otel_span_to_uipath_span(_make_mock_span())
252+
assert span.context is None
253+
assert "Context" not in span.to_dict()
258254

259255
def test_context_present_when_reference_context_set(
260256
self, monkeypatch: pytest.MonkeyPatch
@@ -263,23 +259,22 @@ def test_context_present_when_reference_context_set(
263259
ref_ctx = ReferenceContext.Empty.add(
264260
"agent", "550e8400-e29b-41d4-a716-446655440001", "1.0"
265261
)
266-
token = ReferenceContextAccessor.set(ref_ctx)
267-
try:
268-
span = _SpanUtils.otel_span_to_uipath_span(_make_mock_span())
269-
assert span.context == {
270-
"referenceHierarchy": [
271-
{
272-
"serviceType": "agent",
273-
"referenceId": "550e8400-e29b-41d4-a716-446655440001",
274-
"version": "1.0",
275-
}
276-
]
277-
}
278-
wire = span.to_dict()
279-
assert "Context" in wire
280-
assert wire["Context"]["referenceHierarchy"][0]["serviceType"] == "agent"
281-
finally:
282-
ReferenceContextAccessor.reset(token)
262+
mock = _make_mock_span(
263+
attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())}
264+
)
265+
span = _SpanUtils.otel_span_to_uipath_span(mock)
266+
assert span.context == {
267+
"referenceHierarchy": [
268+
{
269+
"serviceType": "agent",
270+
"referenceId": "550e8400-e29b-41d4-a716-446655440001",
271+
"version": "1.0",
272+
}
273+
]
274+
}
275+
wire = span.to_dict()
276+
assert "Context" in wire
277+
assert wire["Context"]["referenceHierarchy"][0]["serviceType"] == "agent"
283278

284279
def test_context_carries_full_hierarchy(
285280
self, monkeypatch: pytest.MonkeyPatch
@@ -290,14 +285,66 @@ def test_context_carries_full_hierarchy(
290285
.add("maestro", "550e8400-e29b-41d4-a716-446655440010", "2.0")
291286
.add("agent", "550e8400-e29b-41d4-a716-446655440011")
292287
)
288+
mock = _make_mock_span(
289+
attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())}
290+
)
291+
wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict()
292+
hierarchy = wire["Context"]["referenceHierarchy"]
293+
assert len(hierarchy) == 2
294+
assert hierarchy[0]["serviceType"] == "maestro"
295+
assert hierarchy[0]["version"] == "2.0"
296+
assert hierarchy[1]["serviceType"] == "agent"
297+
assert "version" not in hierarchy[1]
298+
299+
def test_reference_hierarchy_not_in_attributes_field(
300+
self, monkeypatch: pytest.MonkeyPatch
301+
) -> None:
302+
monkeypatch.setenv("UIPATH_ORGANIZATION_ID", "test-org")
303+
ref_ctx = ReferenceContext.Empty.add("agent", "550e8400-e29b-41d4-a716-446655440001")
304+
mock = _make_mock_span(
305+
attributes={"uipath.reference_hierarchy": json.dumps(ref_ctx.to_wire_list())}
306+
)
307+
wire = _SpanUtils.otel_span_to_uipath_span(mock).to_dict()
308+
attributes = json.loads(wire["Attributes"])
309+
assert "uipath.reference_hierarchy" not in attributes
310+
311+
312+
# ---------------------------------------------------------------------------
313+
# _inject_reference_hierarchy hook
314+
# ---------------------------------------------------------------------------
315+
316+
class TestReferenceHierarchyHook:
317+
def setup_method(self) -> None:
318+
token = ReferenceContextAccessor.set(None)
319+
ReferenceContextAccessor.reset(token)
320+
321+
def test_hook_stamps_attribute_when_context_set(self) -> None:
322+
from uipath.platform.common._span_utils import _inject_reference_hierarchy
323+
324+
ref_ctx = ReferenceContext.Empty.add(
325+
"agent", "550e8400-e29b-41d4-a716-446655440001"
326+
)
293327
token = ReferenceContextAccessor.set(ref_ctx)
294328
try:
295-
wire = _SpanUtils.otel_span_to_uipath_span(_make_mock_span()).to_dict()
296-
hierarchy = wire["Context"]["referenceHierarchy"]
297-
assert len(hierarchy) == 2
298-
assert hierarchy[0]["serviceType"] == "maestro"
299-
assert hierarchy[0]["version"] == "2.0"
300-
assert hierarchy[1]["serviceType"] == "agent"
301-
assert "version" not in hierarchy[1]
329+
mock_span = Mock()
330+
_inject_reference_hierarchy(mock_span)
331+
mock_span.set_attribute.assert_called_once()
332+
key, value = mock_span.set_attribute.call_args[0]
333+
assert key == "uipath.reference_hierarchy"
334+
hierarchy = json.loads(value)
335+
assert len(hierarchy) == 1
336+
assert hierarchy[0]["serviceType"] == "agent"
337+
assert hierarchy[0]["referenceId"] == "550e8400-e29b-41d4-a716-446655440001"
338+
finally:
339+
ReferenceContextAccessor.reset(token)
340+
341+
def test_hook_noop_when_context_not_set(self) -> None:
342+
from uipath.platform.common._span_utils import _inject_reference_hierarchy
343+
344+
token = ReferenceContextAccessor.set(None)
345+
try:
346+
mock_span = Mock()
347+
_inject_reference_hierarchy(mock_span)
348+
mock_span.set_attribute.assert_not_called()
302349
finally:
303350
ReferenceContextAccessor.reset(token)

0 commit comments

Comments
 (0)