From 482e4c06f443205dfe3ff4dda4f0b073446d1d81 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:44:03 -0400 Subject: [PATCH 1/2] fix(governance): don't open the run span when the host already has one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The governance runtime opens a `uipath.governance.run` span around every agent invocation, between the host's span and the agent's own run span. Hosts filter spans on export — the Agents LLMOps exporter keeps only spans carrying `uipath.custom_instrumentation` and drops the rest without re-parenting their children. This span doesn't carry the marker, so it is dropped while the agent run span still references it, leaving the agent run span pointing at a parent id that never reaches the backend. Observed in production: conversational agent run spans stopped appearing under the CAS Exchange span across prod rings 1-5 and staging, and the BEFORE_AGENT guardrail rule spans surfaced at the trace root for the same reason. Skip the span when a valid span context is already current. The host's span supplies the trace_id that was this span's only purpose there, so governance events still correlate and the agent run span attaches directly to the host's span. With no ambient span the wrapper still opens a root span, so standalone runs keep one trace per agent run. Alternative to #168, which fixes the same break by marking the span for export instead. That keeps the span but surfaces it in customer-facing traces as a new level between Exchange and the agent run; this one keeps traces unchanged but gives up the span. Co-Authored-By: Claude Opus 5 --- src/uipath/runtime/governance/runtime.py | 16 ++-- tests/test_governance_runtime.py | 112 ++++++++++++++++++++++- 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index c9cf6996..da70bd10 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -79,9 +79,10 @@ def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]: Behavior matrix: - - **OTel installed + host opened a parent span**: this becomes a - child of the host's span and inherits its ``trace_id`` — the - host's outer correlation context is preserved end-to-end. + - **OTel installed + host opened a parent span**: no-op — the + host's span already supplies the ``trace_id``, and a span + inserted here is dropped by host-side export filters, orphaning + everything below it. - **OTel installed + no parent span**: this becomes the root span of a fresh trace; everything below it shares the new ``trace_id``. @@ -100,11 +101,12 @@ def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]: yield return + current = trace.get_current_span() + if current is not None and current.get_span_context().is_valid: + yield + return + tracer = trace.get_tracer("uipath.runtime.governance") - # No explicit ``context=`` → OTel picks up the ambient context. - # If the host wrapped this call in its own span, we become its - # child (same trace_id). Otherwise we open a root span (new - # trace_id). with tracer.start_as_current_span("uipath.governance.run") as span: # Span attributes for downstream consumers. ``agent_name`` # and ``runtime_id`` are the primary keys an operator diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index b2ca3e3d..1ab26793 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -10,7 +10,8 @@ from __future__ import annotations -from typing import Any +from contextlib import contextmanager +from typing import Any, Iterator import pytest from uipath.core.governance import EnforcementMode @@ -628,3 +629,112 @@ def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: assert result == "result" assert delegate.execute_calls == [({"x": 1}, None)] + + +# --------------------------------------------------------------------------- +# _governance_root_span — parent-span handling +# --------------------------------------------------------------------------- + + +@contextmanager +def _recording_tracer_provider() -> Iterator[Any]: + """Install an in-memory tracer provider globally and yield its exporter.""" + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + original = trace.get_tracer_provider() + trace._TRACER_PROVIDER = provider # type: ignore[attr-defined] + try: + yield exporter + finally: + trace._TRACER_PROVIDER = original # type: ignore[attr-defined] + + +def _exported_names(exporter: Any) -> set[str]: + """Return the names of every span the exporter received.""" + return {span.name for span in exporter.get_finished_spans()} + + +async def test_execute_opens_no_span_under_a_host_span() -> None: + """Under a host span the wrapper stays out of the tree entirely.""" + from opentelemetry import trace + + class _SpanOpeningDelegate(_StubDelegate): + """Records the parent the agent's own span is given.""" + + def __init__(self) -> None: + super().__init__() + self.agent_span_parent: Any = None + + async def execute(self, input: Any = None, options: Any = None) -> Any: + tracer = trace.get_tracer("test.agent") + with tracer.start_as_current_span("agent run") as agent_span: + self.agent_span_parent = agent_span.parent + return await super().execute(input, options) + + delegate = _SpanOpeningDelegate() + runtime = UiPathGovernedRuntime(delegate, PolicyIndex(), EnforcementMode.AUDIT) + + with _recording_tracer_provider() as exporter: + host_tracer = trace.get_tracer("test.host") + with host_tracer.start_as_current_span("host exchange") as host_span: + host_span_id = host_span.get_span_context().span_id + assert await runtime.execute({"x": 1}) == "result" + + exported = _exported_names(exporter) + + assert "uipath.governance.run" not in exported + assert delegate.agent_span_parent is not None + assert delegate.agent_span_parent.span_id == host_span_id + + +async def test_execute_opens_a_root_span_with_no_host_span() -> None: + """With no ambient span the wrapper still opens one to unify the trace.""" + runtime = UiPathGovernedRuntime( + _StubDelegate(), + PolicyIndex(), + EnforcementMode.AUDIT, + agent_name="HR Assistant", + runtime_id="rt-1", + ) + + with _recording_tracer_provider() as exporter: + assert await runtime.execute({"x": 1}) == "result" + spans = [ + s + for s in exporter.get_finished_spans() + if s.name == "uipath.governance.run" + ] + + assert len(spans) == 1 + assert spans[0].parent is None + attributes = spans[0].attributes or {} + assert attributes["uipath_governance.agent_name"] == "HR Assistant" + assert attributes["uipath_governance.runtime_id"] == "rt-1" + + +async def test_stream_opens_no_span_under_a_host_span() -> None: + """``stream`` takes the same no-op path as ``execute``.""" + from opentelemetry import trace + + runtime = UiPathGovernedRuntime( + _StubDelegate(), PolicyIndex(), EnforcementMode.AUDIT + ) + + with _recording_tracer_provider() as exporter: + host_tracer = trace.get_tracer("test.host") + with host_tracer.start_as_current_span("host exchange"): + events = [event async for event in runtime.stream({"x": 1})] + + exported = _exported_names(exporter) + + assert events == ["a", "b"] + assert "uipath.governance.run" not in exported From 66d55276389e9cea3bb421ec16de9eba146d841c Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:52:30 -0400 Subject: [PATCH 2/2] fix(governance): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the None check on get_current_span — OTel returns INVALID_SPAN, never None, so the span-context validity check is sufficient. - Reword the behavior-matrix bullet: the no-op triggers on any valid current span context, including a remotely propagated one, not only a host-opened span. - Snapshot and restore the same private tracer-provider global in the test helper rather than mixing it with get_tracer_provider(). - Assert the agent span's parent off the exported ReadableSpan; the live Span protocol has no parent attribute, which failed mypy in CI. Co-Authored-By: Claude Opus 5 --- src/uipath/runtime/governance/runtime.py | 12 ++++----- tests/test_governance_runtime.py | 33 ++++++++++++++---------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py index da70bd10..8ce8e23d 100644 --- a/src/uipath/runtime/governance/runtime.py +++ b/src/uipath/runtime/governance/runtime.py @@ -79,10 +79,11 @@ def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]: Behavior matrix: - - **OTel installed + host opened a parent span**: no-op — the - host's span already supplies the ``trace_id``, and a span - inserted here is dropped by host-side export filters, orphaning - everything below it. + - **OTel installed + a valid span context is already current** + (host-opened or remotely propagated): no-op — that context + already supplies the ``trace_id``, and a span inserted here is + dropped by host-side export filters, orphaning everything + below it. - **OTel installed + no parent span**: this becomes the root span of a fresh trace; everything below it shares the new ``trace_id``. @@ -101,8 +102,7 @@ def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]: yield return - current = trace.get_current_span() - if current is not None and current.get_span_context().is_valid: + if trace.get_current_span().get_span_context().is_valid: yield return diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py index 1ab26793..a9dae9d6 100644 --- a/tests/test_governance_runtime.py +++ b/tests/test_governance_runtime.py @@ -650,12 +650,12 @@ def _recording_tracer_provider() -> Iterator[Any]: provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) - original = trace.get_tracer_provider() - trace._TRACER_PROVIDER = provider # type: ignore[attr-defined] + original = trace._TRACER_PROVIDER + trace._TRACER_PROVIDER = provider try: yield exporter finally: - trace._TRACER_PROVIDER = original # type: ignore[attr-defined] + trace._TRACER_PROVIDER = original def _exported_names(exporter: Any) -> set[str]: @@ -663,25 +663,29 @@ def _exported_names(exporter: Any) -> set[str]: return {span.name for span in exporter.get_finished_spans()} +def _exported_span(exporter: Any, name: str) -> Any: + """Return the single exported span with ``name``.""" + spans = [span for span in exporter.get_finished_spans() if span.name == name] + assert len(spans) == 1 + return spans[0] + + async def test_execute_opens_no_span_under_a_host_span() -> None: """Under a host span the wrapper stays out of the tree entirely.""" from opentelemetry import trace class _SpanOpeningDelegate(_StubDelegate): - """Records the parent the agent's own span is given.""" - - def __init__(self) -> None: - super().__init__() - self.agent_span_parent: Any = None + """Opens a span the way a framework adapter would.""" async def execute(self, input: Any = None, options: Any = None) -> Any: tracer = trace.get_tracer("test.agent") - with tracer.start_as_current_span("agent run") as agent_span: - self.agent_span_parent = agent_span.parent + with tracer.start_as_current_span("agent run"): + pass return await super().execute(input, options) - delegate = _SpanOpeningDelegate() - runtime = UiPathGovernedRuntime(delegate, PolicyIndex(), EnforcementMode.AUDIT) + runtime = UiPathGovernedRuntime( + _SpanOpeningDelegate(), PolicyIndex(), EnforcementMode.AUDIT + ) with _recording_tracer_provider() as exporter: host_tracer = trace.get_tracer("test.host") @@ -690,10 +694,11 @@ async def execute(self, input: Any = None, options: Any = None) -> Any: assert await runtime.execute({"x": 1}) == "result" exported = _exported_names(exporter) + agent_span = _exported_span(exporter, "agent run") assert "uipath.governance.run" not in exported - assert delegate.agent_span_parent is not None - assert delegate.agent_span_parent.span_id == host_span_id + assert agent_span.parent is not None + assert agent_span.parent.span_id == host_span_id async def test_execute_opens_a_root_span_with_no_host_span() -> None: