diff --git a/packages/uipath-core/pyproject.toml b/packages/uipath-core/pyproject.toml index 473d485a3..93be7ebc0 100644 --- a/packages/uipath-core/pyproject.toml +++ b/packages/uipath-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" 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/__init__.py b/packages/uipath-core/src/uipath/core/tracing/__init__.py index af6d3c2e1..a1103c55a 100644 --- a/packages/uipath-core/src/uipath/core/tracing/__init__.py +++ b/packages/uipath-core/src/uipath/core/tracing/__init__.py @@ -7,11 +7,19 @@ from uipath.core.tracing.decorators import traced from uipath.core.tracing.span_utils import UiPathSpanUtils from uipath.core.tracing.trace_manager import UiPathTraceManager -from uipath.core.tracing.types import UiPathTraceSettings +from uipath.core.tracing.types import ( + EXCLUDE_SCOPES_FEATURE_FLAG, + EXCLUDED_INSTRUMENTATION_SCOPES, + UiPathTraceSettings, + is_excluded_instrumentation_scope, +) __all__ = [ "traced", "UiPathSpanUtils", "UiPathTraceManager", "UiPathTraceSettings", + "EXCLUDED_INSTRUMENTATION_SCOPES", + "EXCLUDE_SCOPES_FEATURE_FLAG", + "is_excluded_instrumentation_scope", ] diff --git a/packages/uipath-core/src/uipath/core/tracing/processors.py b/packages/uipath-core/src/uipath/core/tracing/processors.py index 2fd10f639..8023b8aa4 100644 --- a/packages/uipath-core/src/uipath/core/tracing/processors.py +++ b/packages/uipath-core/src/uipath/core/tracing/processors.py @@ -11,7 +11,10 @@ SpanExporter, ) -from uipath.core.tracing.types import UiPathTraceSettings +from uipath.core.tracing.types import ( + UiPathTraceSettings, + is_excluded_instrumentation_scope, +) class UiPathExecutionTraceProcessorMixin: @@ -34,6 +37,10 @@ def on_start(self, span: Span, parent_context: context_api.Context | None = None def on_end(self, span: ReadableSpan): """Called when a span ends. Filters before delegating to parent.""" + # Always drop third-party instrumentation noise, then apply the + # optional span filter. + if is_excluded_instrumentation_scope(span): + return span_filter = self._settings.span_filter if self._settings else None if span_filter is None or span_filter(span): parent = cast(SpanProcessor, super()) diff --git a/packages/uipath-core/src/uipath/core/tracing/types.py b/packages/uipath-core/src/uipath/core/tracing/types.py index 7130718f3..d1d170bf2 100644 --- a/packages/uipath-core/src/uipath/core/tracing/types.py +++ b/packages/uipath-core/src/uipath/core/tracing/types.py @@ -5,6 +5,36 @@ from opentelemetry.sdk.trace import ReadableSpan from pydantic import BaseModel, Field +from uipath.core.feature_flags import FeatureFlags + +# Instrumentation scopes whose spans are third-party internal noise and should +# not be exported to LLM Observability. The a2a-sdk auto-instruments its +# JSON-RPC transport under the "a2a-python-sdk" scope (one span per transport +# method), which surfaces as unparented, low-value nodes in the execution trace; +# meaningful A2A activity is emitted as the caller's own span instead. Dropped at +# the span-processor layer so the exclusion applies to every agent (coded and +# low-code), independent of any optional UiPathTraceSettings.span_filter. +EXCLUDED_INSTRUMENTATION_SCOPES: frozenset[str] = frozenset({"a2a-python-sdk"}) + +# Feature flag gating the exclusion. Ships default-off (set +# ``UIPATH_FEATURE_ExcludeThirdPartyTraceScopes=true`` to opt in); the default is +# flipped on in a later release once the noise filtering is validated in the field. +EXCLUDE_SCOPES_FEATURE_FLAG = "ExcludeThirdPartyTraceScopes" + + +def is_excluded_instrumentation_scope(span: ReadableSpan) -> bool: + """Return True when the span comes from an excluded instrumentation scope. + + Gated behind the ``UIPATH_FEATURE_ExcludeThirdPartyTraceScopes`` feature flag + (default off). Used by the UiPath span processors to drop third-party + instrumentation noise (see ``EXCLUDED_INSTRUMENTATION_SCOPES``) before export, + independent of any configured ``span_filter``. + """ + if not FeatureFlags.is_flag_enabled(EXCLUDE_SCOPES_FEATURE_FLAG): + return False + scope = getattr(span, "instrumentation_scope", None) + return scope is not None and scope.name in EXCLUDED_INSTRUMENTATION_SCOPES + class UiPathTraceSettings(BaseModel): """Trace settings for UiPath SDK.""" diff --git a/packages/uipath-core/tests/tracing/test_span_filtering.py b/packages/uipath-core/tests/tracing/test_span_filtering.py index 1c6efabf2..a376aa2ad 100644 --- a/packages/uipath-core/tests/tracing/test_span_filtering.py +++ b/packages/uipath-core/tests/tracing/test_span_filtering.py @@ -196,6 +196,90 @@ def test_filter_with_empty_attributes(self): assert "has-required" in exported_names assert "no-attrs" not in exported_names + def test_excluded_instrumentation_scope_dropped(self, monkeypatch): + """With the feature flag on, excluded third-party scopes (a2a-sdk) are + dropped even with no span_filter — the coded-agent path.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true") + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings(span_filter=None) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + a2a_tracer = trace.get_tracer("a2a-python-sdk") + normal_tracer = trace.get_tracer("uipath.test.agent") + with a2a_tracer.start_as_current_span("JsonRpcTransport.send_message"): + pass + with normal_tracer.start_as_current_span("finance-agent"): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + exported_names = {s.name for s in exported_spans} + assert "finance-agent" in exported_names + assert "JsonRpcTransport.send_message" not in exported_names + + def test_excluded_scope_kept_when_flag_disabled(self): + """With the feature flag off (default), excluded-scope spans are still + exported — the change ships dark.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter = MagicMock(spec=SpanExporter) + mock_exporter.export.return_value = SpanExportResult.SUCCESS + + settings = UiPathTraceSettings(span_filter=None) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + a2a_tracer = trace.get_tracer("a2a-python-sdk") + with a2a_tracer.start_as_current_span("JsonRpcTransport.send_message"): + pass + + trace_manager.flush_spans() + + exported_names = { + s.name for call in mock_exporter.export.call_args_list for s in call[0][0] + } + assert "JsonRpcTransport.send_message" in exported_names + + def test_is_excluded_instrumentation_scope_helper(self, monkeypatch): + """The scope helper is a no-op when the flag is off, and (flag on) matches + a2a-sdk spans while ignoring normal spans.""" + from typing import cast + + from opentelemetry.sdk.trace import ReadableSpan, TracerProvider + + from uipath.core.tracing.types import is_excluded_instrumentation_scope + + provider = TracerProvider() + a2a = provider.get_tracer("a2a-python-sdk").start_span("x") + a2a.end() + normal = provider.get_tracer("uipath.agent").start_span("y") + normal.end() + # start_span is typed as the API Span; the runtime object is a ReadableSpan. + a2a_span = cast(ReadableSpan, a2a) + normal_span = cast(ReadableSpan, normal) + + # Flag off (default): no exclusion. + assert is_excluded_instrumentation_scope(a2a_span) is False + + # Flag on: a2a-sdk excluded, normal scope kept. + monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true") + assert is_excluded_instrumentation_scope(a2a_span) is True + assert is_excluded_instrumentation_scope(normal_span) is False + def test_different_filters_per_exporter(self): """Test that different exporters can have different filters.""" from unittest.mock import MagicMock diff --git a/packages/uipath-core/uv.lock b/packages/uipath-core/uv.lock index cfb903454..67d937f3a 100644 --- a/packages/uipath-core/uv.lock +++ b/packages/uipath-core/uv.lock @@ -1011,7 +1011,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath-platform/uv.lock b/packages/uipath-platform/uv.lock index 2c3e5d025..845713834 100644 --- a/packages/uipath-platform/uv.lock +++ b/packages/uipath-platform/uv.lock @@ -1063,7 +1063,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" }, diff --git a/packages/uipath/pyproject.toml b/packages/uipath/pyproject.toml index 7acd8465d..962dd1122 100644 --- a/packages/uipath/pyproject.toml +++ b/packages/uipath/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath" -version = "2.11.12" +version = "2.11.13" description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools." readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.21, <0.6.0", + "uipath-core>=0.5.23, <0.6.0", "uipath-runtime>=0.11.0, <0.12.0", "uipath-platform>=0.1.76, <0.2.0", "click>=8.3.1", diff --git a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py index 85bcca1ba..0b66304f2 100644 --- a/packages/uipath/src/uipath/tracing/_live_tracking_processor.py +++ b/packages/uipath/src/uipath/tracing/_live_tracking_processor.py @@ -4,7 +4,10 @@ from opentelemetry import context as context_api from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor -from uipath.core.tracing import UiPathTraceSettings +from uipath.core.tracing import ( + UiPathTraceSettings, + is_excluded_instrumentation_scope, +) from uipath.tracing._otel_exporters import LlmOpsHttpExporter, SpanStatus logger = logging.getLogger(__name__) @@ -72,13 +75,17 @@ def on_start( self, span: Span, parent_context: context_api.Context | None = None ) -> None: """Called when span starts - upsert with RUNNING status (non-blocking).""" - # Apply factory span filter if configured + # Drop third-party instrumentation noise, then apply the optional filter. + if is_excluded_instrumentation_scope(span): + return if self.span_filter is None or self.span_filter(span): self._upsert_span_async(span, status_override=SpanStatus.RUNNING) def on_end(self, span: ReadableSpan) -> None: """Called when span ends - upsert with final status (non-blocking).""" - # Apply factory span filter if configured + # Drop third-party instrumentation noise, then apply the optional filter. + if is_excluded_instrumentation_scope(span): + return if self.span_filter is None or self.span_filter(span): self._upsert_span_async(span) diff --git a/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py b/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py index a20c18d9b..e84688a24 100644 --- a/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py +++ b/packages/uipath/tests/cli/eval/test_live_tracking_span_processor.py @@ -106,6 +106,34 @@ def test_on_end_no_filter_accepts_all(self, processor_no_filter, mock_exporter): mock_exporter.upsert_span.assert_called_once_with(span) + # Tests for excluded third-party instrumentation scopes (a2a-sdk) + + def test_on_start_skips_excluded_instrumentation_scope( + self, processor_no_filter, mock_exporter, monkeypatch + ): + """With the flag on, on_start drops excluded-scope spans even with no filter.""" + monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true") + span = self.create_mock_span({"span_type": "agent"}) + span.instrumentation_scope = Mock() + span.instrumentation_scope.name = "a2a-python-sdk" + + processor_no_filter.on_start(span, None) + + mock_exporter.upsert_span.assert_not_called() + + def test_on_end_skips_excluded_instrumentation_scope( + self, processor_no_filter, mock_exporter, monkeypatch + ): + """With the flag on, on_end drops excluded-scope spans even with no filter.""" + monkeypatch.setenv("UIPATH_FEATURE_ExcludeThirdPartyTraceScopes", "true") + span = self.create_mock_readable_span({"span_type": "agent"}) + span.instrumentation_scope = Mock() + span.instrumentation_scope.name = "a2a-python-sdk" + + processor_no_filter.on_end(span) + + mock_exporter.upsert_span.assert_not_called() + # Tests for custom instrumentation filter def test_on_start_with_filter_accepts_matching( diff --git a/packages/uipath/uv.lock b/packages/uipath/uv.lock index 989e4b5ea..c72958340 100644 --- a/packages/uipath/uv.lock +++ b/packages/uipath/uv.lock @@ -2552,7 +2552,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.12" +version = "2.11.13" source = { editable = "." } dependencies = [ { name = "applicationinsights" }, @@ -2659,7 +2659,7 @@ dev = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.23" source = { editable = "../uipath-core" } dependencies = [ { name = "opentelemetry-instrumentation" },