From 126770315185dd1e60c7962032b7e93437443168 Mon Sep 17 00:00:00 2001 From: Clement Fauchere Date: Thu, 2 Apr 2026 07:58:52 -0500 Subject: [PATCH 1/5] fix(core): prefer OTEL trace ID over external span when trace IDs differ When both an OTEL current span (set by the agent runtime) and an external span (from OpenInference LangChain instrumentor) exist with different trace IDs, get_parent_context() now returns the OTEL span. Previously, the depth-based tiebreaker would pick the external span, causing the agent's trace ID to be lost. Downstream services (ECS) received a different trace ID than the agent trace, making correlation impossible. Validated locally: the x-uipath-traceparent-id header now carries the same trace ID as the agent trace. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-core/pyproject.toml | 2 +- .../src/uipath/core/tracing/span_utils.py | 12 +++++++++++- packages/uipath-core/uv.lock | 2 +- packages/uipath-platform/uv.lock | 2 +- packages/uipath/uv.lock | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 5604e3938..1a5c34ab7 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/packages/uipath-core/src/uipath/core/tracing/span_utils.py b/packages/uipath-core/src/uipath/core/tracing/span_utils.py index 0e9e9fb22..e4874854d 100644 --- a/packages/uipath-core/src/uipath/core/tracing/span_utils.py +++ b/packages/uipath-core/src/uipath/core/tracing/span_utils.py @@ -264,7 +264,17 @@ def _get_bottom_most_span( return external_span # Neither is an ancestor of the other - they're in different branches - # Use depth as tiebreaker + # If trace IDs differ, the spans belong to different tracing systems. + # Prefer the OTEL current_span which carries the agent's trace ID. + current_trace_id = current_span.get_span_context().trace_id + external_trace_id = external_span.get_span_context().trace_id + if current_trace_id != external_trace_id: + logger.debug( + "Traced Context: Different trace IDs -> returning current_span (agent trace)", + ) + return current_span + + # Same trace ID, use depth as tiebreaker current_depth = _span_registry.calculate_depth(current_span_id) external_depth = _span_registry.calculate_depth(external_span_id) diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index 2544216df..e17802fc7 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1007,7 +1007,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 0ea4a2f63..a8cbc6db3 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1056,7 +1056,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 77434aaa8..a4bf97525 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2650,7 +2650,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.10" +version = "0.5.11" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, From f016f85dd72c5d320bdc83659a36bb4011f7e517 Mon Sep 17 00:00:00 2001 From: Clement Fauchere Date: Thu, 2 Apr 2026 08:02:43 -0500 Subject: [PATCH 2/5] fix: remove debug log from trace ID check Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-core/src/uipath/core/tracing/span_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/uipath-core/src/uipath/core/tracing/span_utils.py b/packages/uipath-core/src/uipath/core/tracing/span_utils.py index e4874854d..af79f0f68 100644 --- a/packages/uipath-core/src/uipath/core/tracing/span_utils.py +++ b/packages/uipath-core/src/uipath/core/tracing/span_utils.py @@ -269,9 +269,6 @@ def _get_bottom_most_span( current_trace_id = current_span.get_span_context().trace_id external_trace_id = external_span.get_span_context().trace_id if current_trace_id != external_trace_id: - logger.debug( - "Traced Context: Different trace IDs -> returning current_span (agent trace)", - ) return current_span # Same trace ID, use depth as tiebreaker From 5fad87a69dc879dc37749342358f7eabfb9bcc1a Mon Sep 17 00:00:00 2001 From: Clement Fauchere Date: Thu, 2 Apr 2026 08:08:25 -0500 Subject: [PATCH 3/5] test: add test for trace ID preference when OTEL and external spans differ Verifies that when the OTEL current span (agent runtime) and the external span (OpenInference) have different trace IDs, the @traced decorator inherits the OTEL trace ID. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/test_external_integration.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/uipath-core/tests/tracing/test_external_integration.py b/packages/uipath-core/tests/tracing/test_external_integration.py index 2c27cf28d..99d254b61 100644 --- a/packages/uipath-core/tests/tracing/test_external_integration.py +++ b/packages/uipath-core/tests/tracing/test_external_integration.py @@ -1,6 +1,9 @@ """Test span nesting behavior for traced decorators.""" +import random + from opentelemetry import trace +from opentelemetry.trace import SpanContext, TraceFlags from tests.conftest import SpanCapture @@ -65,6 +68,53 @@ def test_function(): assert len(spans) == 1 +def test_different_trace_ids_prefers_otel_current_span(span_capture: SpanCapture): + """When OTEL current span and external span have different trace IDs, + get_parent_context should prefer the OTEL current span (agent trace).""" + from uipath.core.tracing.decorators import traced + from uipath.core.tracing.span_utils import UiPathSpanUtils + + # Create an OTEL span with a known trace ID (simulates agent runtime) + agent_trace_id = random.getrandbits(128) + agent_span_context = SpanContext( + trace_id=agent_trace_id, + span_id=random.getrandbits(64), + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + agent_span = trace.NonRecordingSpan(agent_span_context) + + # Create an external span with a DIFFERENT trace ID (simulates OpenInference) + external_tracer = trace.get_tracer("openinference") + external_span = external_tracer.start_span("external_span") + assert external_span.get_span_context().trace_id != agent_trace_id + + # Register external provider that returns the external span + UiPathSpanUtils.register_current_span_provider(lambda: external_span) + + # Set the agent span as OTEL current span and call a traced function + with trace.use_span(agent_span, end_on_exit=False): + + @traced(name="sdk_call") + def sdk_call(): + return "result" + + result = sdk_call() + assert result == "result" + + external_span.end() + + # Clean up + UiPathSpanUtils.register_current_span_provider(None) + + spans = span_capture.get_spans() + sdk_span = next((s for s in spans if s.name == "sdk_call"), None) + assert sdk_span is not None + + # The sdk_call span should inherit the agent's trace ID, not the external one + assert sdk_span.context.trace_id == agent_trace_id + + def test_external_span_provider_raises_exception(span_capture: SpanCapture): """Test that exceptions from external span provider are caught.""" from uipath.core.tracing.decorators import traced From df2f16284f2e467bcde5d012dd0326f72c9ccf8b Mon Sep 17 00:00:00 2001 From: Clement Fauchere Date: Thu, 2 Apr 2026 08:12:32 -0500 Subject: [PATCH 4/5] test: update existing test for trace ID preference, remove redundant test Updated test_ctx_parameter_required_when_external_deeper_than_current to assert the new behavior: when OTEL and external spans have different trace IDs, OTEL span is preferred. Removed the duplicate test from test_external_integration.py. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tracing/test_external_integration.py | 50 ----------------- .../tests/tracing/test_span_nesting.py | 53 +++++++------------ 2 files changed, 18 insertions(+), 85 deletions(-) diff --git a/packages/uipath-core/tests/tracing/test_external_integration.py b/packages/uipath-core/tests/tracing/test_external_integration.py index 99d254b61..2c27cf28d 100644 --- a/packages/uipath-core/tests/tracing/test_external_integration.py +++ b/packages/uipath-core/tests/tracing/test_external_integration.py @@ -1,9 +1,6 @@ """Test span nesting behavior for traced decorators.""" -import random - from opentelemetry import trace -from opentelemetry.trace import SpanContext, TraceFlags from tests.conftest import SpanCapture @@ -68,53 +65,6 @@ def test_function(): assert len(spans) == 1 -def test_different_trace_ids_prefers_otel_current_span(span_capture: SpanCapture): - """When OTEL current span and external span have different trace IDs, - get_parent_context should prefer the OTEL current span (agent trace).""" - from uipath.core.tracing.decorators import traced - from uipath.core.tracing.span_utils import UiPathSpanUtils - - # Create an OTEL span with a known trace ID (simulates agent runtime) - agent_trace_id = random.getrandbits(128) - agent_span_context = SpanContext( - trace_id=agent_trace_id, - span_id=random.getrandbits(64), - is_remote=True, - trace_flags=TraceFlags(0x01), - ) - agent_span = trace.NonRecordingSpan(agent_span_context) - - # Create an external span with a DIFFERENT trace ID (simulates OpenInference) - external_tracer = trace.get_tracer("openinference") - external_span = external_tracer.start_span("external_span") - assert external_span.get_span_context().trace_id != agent_trace_id - - # Register external provider that returns the external span - UiPathSpanUtils.register_current_span_provider(lambda: external_span) - - # Set the agent span as OTEL current span and call a traced function - with trace.use_span(agent_span, end_on_exit=False): - - @traced(name="sdk_call") - def sdk_call(): - return "result" - - result = sdk_call() - assert result == "result" - - external_span.end() - - # Clean up - UiPathSpanUtils.register_current_span_provider(None) - - spans = span_capture.get_spans() - sdk_span = next((s for s in spans if s.name == "sdk_call"), None) - assert sdk_span is not None - - # The sdk_call span should inherit the agent's trace ID, not the external one - assert sdk_span.context.trace_id == agent_trace_id - - def test_external_span_provider_raises_exception(span_capture: SpanCapture): """Test that exceptions from external span provider are caught.""" from uipath.core.tracing.decorators import traced diff --git a/packages/uipath-core/tests/tracing/test_span_nesting.py b/packages/uipath-core/tests/tracing/test_span_nesting.py index 7245f648a..7886378b3 100644 --- a/packages/uipath-core/tests/tracing/test_span_nesting.py +++ b/packages/uipath-core/tests/tracing/test_span_nesting.py @@ -508,26 +508,19 @@ def non_recording_parent(): def test_ctx_parameter_required_when_external_deeper_than_current( span_capture: SpanCapture, ): - """Test that trace.get_current_span(ctx) is required when external span is deeper. + """Test that OTEL span is preferred when trace IDs differ from external span. Scenario: - 1. Create an external span (depth 0) - 2. Create a deeper nested external span (depth 1) - this becomes current external - 3. Create an OTel span INSIDE the deepest external context - 4. Register the deepest external span as the external provider - 5. Create a non-recording span + 1. Create an external span hierarchy (depth 0 and depth 1) + 2. Create an OTel span in a separate context + 3. Register the deeper external span as the external provider + 4. Create a non-recording span - Expected behavior with trace.get_current_span(ctx): + Expected behavior: - get_parent_context() compares: current OTel span vs external span - - External span is deeper (depth 1), so it's chosen - - trace.get_current_span(ctx) gets the external span from ctx - - Non-recording span is parented to external - - Bug with trace.get_current_span() without ctx: - - trace.get_current_span() (no args) returns the OTel span (from thread-local) - - Non-recording span gets parented to OTel span (wrong!) - - This test PASSES with the fix (ctx parameter) and FAILS without it. + - They have different trace IDs (different tracing systems) + - OTEL current span is preferred to preserve the agent trace ID + - Non-recording span is parented to OTel span """ from opentelemetry import trace @@ -619,30 +612,20 @@ def recording_child_func(): "Recording child of non-recording parent should not be captured due to ParentBased sampler" ) - # Step 2: Verify the non-recording span was parented to external_deep (deeper) - # NOT to otel_span (which is current in thread-local context) - non_recording_id = None + # Step 2: When trace IDs differ between OTEL and external spans, + # the OTEL current span takes priority (agent trace ID preservation). + # The non-recording span should be parented to otel_span, not external_deep. + non_recording_parented_to_otel = False for span_id, parent_id in _span_registry._parent_map.items(): - stored_span = _span_registry.get_span(span_id) - if stored_span is not None and parent_id == external_deep_id: - non_recording_id = span_id + if parent_id == otel_span_id: + non_recording_parented_to_otel = True break - assert non_recording_id is not None, ( - "CRITICAL: Non-recording span should be parented to external_deep (deeper). " - "This requires using trace.get_current_span(ctx) NOT trace.get_current_span(). " - "With trace.get_current_span() alone, it would pick the OTel span " - "(which is current in thread-local context), not the external span." + assert non_recording_parented_to_otel, ( + "When OTEL and external spans have different trace IDs, " + "the OTEL current span should be preferred to preserve the agent trace ID." ) - # Verify it's NOT parented to the OTel span - for _span_id, parent_id in _span_registry._parent_map.items(): - if parent_id == otel_span_id: - raise AssertionError( - "Non-recording span should NOT be parented to otel_span. " - "This indicates trace.get_current_span() was used instead of trace.get_current_span(ctx)." - ) - _span_registry.clear() finally: From c1f71b32e6c9af534fb3fe7705f61fb4065e3dfc Mon Sep 17 00:00:00 2001 From: Clement Fauchere Date: Thu, 2 Apr 2026 08:18:16 -0500 Subject: [PATCH 5/5] fix: rename unused span_id to _span_id for ruff Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/uipath-core/tests/tracing/test_span_nesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath-core/tests/tracing/test_span_nesting.py b/packages/uipath-core/tests/tracing/test_span_nesting.py index 7886378b3..c35c133ae 100644 --- a/packages/uipath-core/tests/tracing/test_span_nesting.py +++ b/packages/uipath-core/tests/tracing/test_span_nesting.py @@ -616,7 +616,7 @@ def recording_child_func(): # the OTEL current span takes priority (agent trace ID preservation). # The non-recording span should be parented to otel_span, not external_deep. non_recording_parented_to_otel = False - for span_id, parent_id in _span_registry._parent_map.items(): + for _span_id, parent_id in _span_registry._parent_map.items(): if parent_id == otel_span_id: non_recording_parented_to_otel = True break