Skip to content

Commit 1072167

Browse files
PopescuTudorclaude
andcommitted
fix(tracing): exclude a2a-sdk instrumentation scope from LLMOps export
The a2a-sdk auto-instruments its JSON-RPC transport (instrumentation scope "a2a-python-sdk"), emitting one span per transport method. These surface as noisy, unparented nodes in the execution trace and are not meaningful execution spans. Low-code agents already drop them via the custom_instrumentation span filter, but coded agents run with no span filter (settings=None), so the noise reaches the trace. Drop spans from excluded third-party instrumentation scopes at the span-processor layer, before and independent of the optional UiPathTraceSettings span_filter, so the exclusion applies to every agent (coded and low-code): - uipath-core: add EXCLUDED_INSTRUMENTATION_SCOPES + is_excluded_instrumentation_scope and apply it in UiPathExecutionTraceProcessorMixin.on_end. - uipath: apply it in LiveTrackingSpanProcessor.on_start/on_end. Gated behind the UIPATH_FEATURE_ExcludeThirdPartyTraceScopes feature flag (default off); the default flips on in a later release once validated in the field, per the safe-rollout convention. Bumps uipath-core 0.5.22->0.5.23 and uipath 2.11.12->2.11.13, and raises uipath's minimum on uipath-core to 0.5.23. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELDu3m5eaURJrMVarc8VfY
1 parent ab8b772 commit 1072167

11 files changed

Lines changed: 176 additions & 12 deletions

File tree

packages/uipath-core/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-core"
3-
version = "0.5.22"
3+
version = "0.5.23"
44
description = "UiPath Core abstractions"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@
77
from uipath.core.tracing.decorators import traced
88
from uipath.core.tracing.span_utils import UiPathSpanUtils
99
from uipath.core.tracing.trace_manager import UiPathTraceManager
10-
from uipath.core.tracing.types import UiPathTraceSettings
10+
from uipath.core.tracing.types import (
11+
EXCLUDE_SCOPES_FEATURE_FLAG,
12+
EXCLUDED_INSTRUMENTATION_SCOPES,
13+
UiPathTraceSettings,
14+
is_excluded_instrumentation_scope,
15+
)
1116

1217
__all__ = [
1318
"traced",
1419
"UiPathSpanUtils",
1520
"UiPathTraceManager",
1621
"UiPathTraceSettings",
22+
"EXCLUDED_INSTRUMENTATION_SCOPES",
23+
"EXCLUDE_SCOPES_FEATURE_FLAG",
24+
"is_excluded_instrumentation_scope",
1725
]

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
SpanExporter,
1212
)
1313

14-
from uipath.core.tracing.types import UiPathTraceSettings
14+
from uipath.core.tracing.types import (
15+
UiPathTraceSettings,
16+
is_excluded_instrumentation_scope,
17+
)
1518

1619

1720
class UiPathExecutionTraceProcessorMixin:
@@ -34,6 +37,10 @@ def on_start(self, span: Span, parent_context: context_api.Context | None = None
3437

3538
def on_end(self, span: ReadableSpan):
3639
"""Called when a span ends. Filters before delegating to parent."""
40+
# Always drop third-party instrumentation noise, then apply the
41+
# optional span filter.
42+
if is_excluded_instrumentation_scope(span):
43+
return
3744
span_filter = self._settings.span_filter if self._settings else None
3845
if span_filter is None or span_filter(span):
3946
parent = cast(SpanProcessor, super())

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,36 @@
55
from opentelemetry.sdk.trace import ReadableSpan
66
from pydantic import BaseModel, Field
77

8+
from uipath.core.feature_flags import FeatureFlags
9+
10+
# Instrumentation scopes whose spans are third-party internal noise and should
11+
# not be exported to LLM Observability. The a2a-sdk auto-instruments its
12+
# JSON-RPC transport under the "a2a-python-sdk" scope (one span per transport
13+
# method), which surfaces as unparented, low-value nodes in the execution trace;
14+
# meaningful A2A activity is emitted as the caller's own span instead. Dropped at
15+
# the span-processor layer so the exclusion applies to every agent (coded and
16+
# low-code), independent of any optional UiPathTraceSettings.span_filter.
17+
EXCLUDED_INSTRUMENTATION_SCOPES: frozenset[str] = frozenset({"a2a-python-sdk"})
18+
19+
# Feature flag gating the exclusion. Ships default-off (set
20+
# ``UIPATH_FEATURE_ExcludeThirdPartyTraceScopes=true`` to opt in); the default is
21+
# flipped on in a later release once the noise filtering is validated in the field.
22+
EXCLUDE_SCOPES_FEATURE_FLAG = "ExcludeThirdPartyTraceScopes"
23+
24+
25+
def is_excluded_instrumentation_scope(span: ReadableSpan) -> bool:
26+
"""Return True when the span comes from an excluded instrumentation scope.
27+
28+
Gated behind the ``UIPATH_FEATURE_ExcludeThirdPartyTraceScopes`` feature flag
29+
(default off). Used by the UiPath span processors to drop third-party
30+
instrumentation noise (see ``EXCLUDED_INSTRUMENTATION_SCOPES``) before export,
31+
independent of any configured ``span_filter``.
32+
"""
33+
if not FeatureFlags.is_flag_enabled(EXCLUDE_SCOPES_FEATURE_FLAG):
34+
return False
35+
scope = getattr(span, "instrumentation_scope", None)
36+
return scope is not None and scope.name in EXCLUDED_INSTRUMENTATION_SCOPES
37+
838

939
class UiPathTraceSettings(BaseModel):
1040
"""Trace settings for UiPath SDK."""

packages/uipath-core/tests/tracing/test_span_filtering.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,90 @@ def test_filter_with_empty_attributes(self):
196196
assert "has-required" in exported_names
197197
assert "no-attrs" not in exported_names
198198

199+
def test_excluded_instrumentation_scope_dropped(self, monkeypatch):
200+
"""With the feature flag on, excluded third-party scopes (a2a-sdk) are
201+
dropped even with no span_filter — the coded-agent path."""
202+
from unittest.mock import MagicMock
203+
204+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
205+
206+
monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true")
207+
208+
mock_exporter = MagicMock(spec=SpanExporter)
209+
mock_exporter.export.return_value = SpanExportResult.SUCCESS
210+
211+
settings = UiPathTraceSettings(span_filter=None)
212+
trace_manager = UiPathTraceManager()
213+
trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings)
214+
215+
a2a_tracer = trace.get_tracer("a2a-python-sdk")
216+
normal_tracer = trace.get_tracer("uipath.test.agent")
217+
with a2a_tracer.start_as_current_span("JsonRpcTransport.send_message"):
218+
pass
219+
with normal_tracer.start_as_current_span("finance-agent"):
220+
pass
221+
222+
trace_manager.flush_spans()
223+
224+
exported_spans = []
225+
for call in mock_exporter.export.call_args_list:
226+
exported_spans.extend(call[0][0])
227+
228+
exported_names = {s.name for s in exported_spans}
229+
assert "finance-agent" in exported_names
230+
assert "JsonRpcTransport.send_message" not in exported_names
231+
232+
def test_excluded_scope_kept_when_flag_disabled(self):
233+
"""With the feature flag off (default), excluded-scope spans are still
234+
exported — the change ships dark."""
235+
from unittest.mock import MagicMock
236+
237+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
238+
239+
mock_exporter = MagicMock(spec=SpanExporter)
240+
mock_exporter.export.return_value = SpanExportResult.SUCCESS
241+
242+
settings = UiPathTraceSettings(span_filter=None)
243+
trace_manager = UiPathTraceManager()
244+
trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings)
245+
246+
a2a_tracer = trace.get_tracer("a2a-python-sdk")
247+
with a2a_tracer.start_as_current_span("JsonRpcTransport.send_message"):
248+
pass
249+
250+
trace_manager.flush_spans()
251+
252+
exported_names = {
253+
s.name for call in mock_exporter.export.call_args_list for s in call[0][0]
254+
}
255+
assert "JsonRpcTransport.send_message" in exported_names
256+
257+
def test_is_excluded_instrumentation_scope_helper(self, monkeypatch):
258+
"""The scope helper is a no-op when the flag is off, and (flag on) matches
259+
a2a-sdk spans while ignoring normal spans."""
260+
from typing import cast
261+
262+
from opentelemetry.sdk.trace import ReadableSpan, TracerProvider
263+
264+
from uipath.core.tracing.types import is_excluded_instrumentation_scope
265+
266+
provider = TracerProvider()
267+
a2a = provider.get_tracer("a2a-python-sdk").start_span("x")
268+
a2a.end()
269+
normal = provider.get_tracer("uipath.agent").start_span("y")
270+
normal.end()
271+
# start_span is typed as the API Span; the runtime object is a ReadableSpan.
272+
a2a_span = cast(ReadableSpan, a2a)
273+
normal_span = cast(ReadableSpan, normal)
274+
275+
# Flag off (default): no exclusion.
276+
assert is_excluded_instrumentation_scope(a2a_span) is False
277+
278+
# Flag on: a2a-sdk excluded, normal scope kept.
279+
monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true")
280+
assert is_excluded_instrumentation_scope(a2a_span) is True
281+
assert is_excluded_instrumentation_scope(normal_span) is False
282+
199283
def test_different_filters_per_exporter(self):
200284
"""Test that different exporters can have different filters."""
201285
from unittest.mock import MagicMock

packages/uipath-core/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath-platform/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/uipath/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "uipath"
3-
version = "2.11.12"
3+
version = "2.11.13"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath-core>=0.5.21, <0.6.0",
8+
"uipath-core>=0.5.23, <0.6.0",
99
"uipath-runtime>=0.11.0, <0.12.0",
1010
"uipath-platform>=0.1.76, <0.2.0",
1111
"click>=8.3.1",

packages/uipath/src/uipath/tracing/_live_tracking_processor.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from opentelemetry import context as context_api
55
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
66

7-
from uipath.core.tracing import UiPathTraceSettings
7+
from uipath.core.tracing import (
8+
UiPathTraceSettings,
9+
is_excluded_instrumentation_scope,
10+
)
811
from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus
912

1013
logger = logging.getLogger(__name__)
@@ -72,13 +75,17 @@ def on_start(
7275
self, span: Span, parent_context: context_api.Context | None = None
7376
) -> None:
7477
"""Called when span starts - upsert with RUNNING status (non-blocking)."""
75-
# Apply factory span filter if configured
78+
# Drop third-party instrumentation noise, then apply the optional filter.
79+
if is_excluded_instrumentation_scope(span):
80+
return
7681
if self.span_filter is None or self.span_filter(span):
7782
self._upsert_span_async(span, status_override=SpanStatus.RUNNING)
7883

7984
def on_end(self, span: ReadableSpan) -> None:
8085
"""Called when span ends - upsert with final status (non-blocking)."""
81-
# Apply factory span filter if configured
86+
# Drop third-party instrumentation noise, then apply the optional filter.
87+
if is_excluded_instrumentation_scope(span):
88+
return
8289
if self.span_filter is None or self.span_filter(span):
8390
self._upsert_span_async(span)
8491

packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,34 @@ def test_on_end_no_filter_accepts_all(self, processor_no_filter, mock_exporter):
106106

107107
mock_exporter.upsert_span.assert_called_once_with(span)
108108

109+
# Tests for excluded third-party instrumentation scopes (a2a-sdk)
110+
111+
def test_on_start_skips_excluded_instrumentation_scope(
112+
self, processor_no_filter, mock_exporter, monkeypatch
113+
):
114+
"""With the flag on, on_start drops excluded-scope spans even with no filter."""
115+
monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true")
116+
span = self.create_mock_span({"span_type": "agent"})
117+
span.instrumentation_scope = Mock()
118+
span.instrumentation_scope.name = "a2a-python-sdk"
119+
120+
processor_no_filter.on_start(span, None)
121+
122+
mock_exporter.upsert_span.assert_not_called()
123+
124+
def test_on_end_skips_excluded_instrumentation_scope(
125+
self, processor_no_filter, mock_exporter, monkeypatch
126+
):
127+
"""With the flag on, on_end drops excluded-scope spans even with no filter."""
128+
monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true")
129+
span = self.create_mock_readable_span({"span_type": "agent"})
130+
span.instrumentation_scope = Mock()
131+
span.instrumentation_scope.name = "a2a-python-sdk"
132+
133+
processor_no_filter.on_end(span)
134+
135+
mock_exporter.upsert_span.assert_not_called()
136+
109137
# Tests for custom instrumentation filter
110138

111139
def test_on_start_with_filter_accepts_matching(

0 commit comments

Comments
 (0)