diff --git a/pyproject.toml b/pyproject.toml index 79f021f..aac5c6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] name = "uipath-core" -version = "0.1.10" +version = "0.2.0" description = "UiPath Core abstractions" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "opentelemetry-sdk>=1.39.0, <2.0.0", - "opentelemetry-instrumentation>=0.60b0, <1.0.0", + "opentelemetry-sdk>=1.39.1, <2.0.0", + "opentelemetry-instrumentation>=0.60b1, <1.0.0", "pydantic>=2.12.5, <3.0.0", ] classifiers = [ diff --git a/src/uipath/core/tracing/__init__.py b/src/uipath/core/tracing/__init__.py index 09c0790..af6d3c2 100644 --- a/src/uipath/core/tracing/__init__.py +++ b/src/uipath/core/tracing/__init__.py @@ -7,9 +7,11 @@ 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 __all__ = [ "traced", "UiPathSpanUtils", "UiPathTraceManager", + "UiPathTraceSettings", ] diff --git a/src/uipath/core/tracing/processors.py b/src/uipath/core/tracing/processors.py index 8f0509e..2fd10f6 100644 --- a/src/uipath/core/tracing/processors.py +++ b/src/uipath/core/tracing/processors.py @@ -1,22 +1,27 @@ """Custom span processors for UiPath execution tracing.""" -from typing import Optional, cast +from typing import cast from opentelemetry import context as context_api from opentelemetry import trace -from opentelemetry.sdk.trace import Span +from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor from opentelemetry.sdk.trace.export import ( BatchSpanProcessor, SimpleSpanProcessor, + SpanExporter, ) +from uipath.core.tracing.types import UiPathTraceSettings + class UiPathExecutionTraceProcessorMixin: - def on_start( - self, span: Span, parent_context: Optional[context_api.Context] = None - ): + """Mixin that propagates execution.id and optionally filters spans.""" + + _settings: UiPathTraceSettings | None = None + + def on_start(self, span: Span, parent_context: context_api.Context | None = None): """Called when a span is started.""" - parent_span: Optional[Span] + parent_span: Span | None if parent_context: parent_span = cast(Span, trace.get_current_span(parent_context)) else: @@ -27,17 +32,42 @@ def on_start( if execution_id: span.set_attribute("execution.id", execution_id) + def on_end(self, span: ReadableSpan): + """Called when a span ends. Filters before delegating to parent.""" + span_filter = self._settings.span_filter if self._settings else None + if span_filter is None or span_filter(span): + parent = cast(SpanProcessor, super()) + parent.on_end(span) + class UiPathExecutionBatchTraceProcessor( UiPathExecutionTraceProcessorMixin, BatchSpanProcessor ): - """Batch span processor that propagates execution.id.""" + """Batch span processor that propagates execution.id and optionally filters.""" + + def __init__( + self, + span_exporter: SpanExporter, + settings: UiPathTraceSettings | None = None, + ): + """Initialize the batch trace processor.""" + super().__init__(span_exporter) + self._settings = settings class UiPathExecutionSimpleTraceProcessor( UiPathExecutionTraceProcessorMixin, SimpleSpanProcessor ): - """Simple span processor that propagates execution.id.""" + """Simple span processor that propagates execution.id and optionally filters.""" + + def __init__( + self, + span_exporter: SpanExporter, + settings: UiPathTraceSettings | None = None, + ): + """Initialize the simple trace processor.""" + super().__init__(span_exporter) + self._settings = settings __all__ = [ diff --git a/src/uipath/core/tracing/trace_manager.py b/src/uipath/core/tracing/trace_manager.py index 7972d42..481953c 100644 --- a/src/uipath/core/tracing/trace_manager.py +++ b/src/uipath/core/tracing/trace_manager.py @@ -15,6 +15,7 @@ UiPathExecutionBatchTraceProcessor, UiPathExecutionSimpleTraceProcessor, ) +from uipath.core.tracing.types import UiPathTraceSettings class UiPathTraceManager: @@ -40,13 +41,22 @@ def add_span_exporter( self, span_exporter: SpanExporter, batch: bool = True, + settings: UiPathTraceSettings | None = None, ) -> UiPathTraceManager: - """Add a span processor to the tracer provider.""" + """Add a span exporter to the tracer provider. + + Args: + span_exporter: The exporter to add. + batch: Whether to use batch processing (default: True). + settings: Optional trace settings for filtering, etc. + """ span_processor: SpanProcessor if batch: - span_processor = UiPathExecutionBatchTraceProcessor(span_exporter) + span_processor = UiPathExecutionBatchTraceProcessor(span_exporter, settings) else: - span_processor = UiPathExecutionSimpleTraceProcessor(span_exporter) + span_processor = UiPathExecutionSimpleTraceProcessor( + span_exporter, settings + ) self.tracer_span_processors.append(span_processor) self.tracer_provider.add_span_processor(span_processor) return self diff --git a/src/uipath/core/tracing/types.py b/src/uipath/core/tracing/types.py new file mode 100644 index 0000000..7130718 --- /dev/null +++ b/src/uipath/core/tracing/types.py @@ -0,0 +1,21 @@ +"""Tracing types for UiPath SDK.""" + +from typing import Callable + +from opentelemetry.sdk.trace import ReadableSpan +from pydantic import BaseModel, Field + + +class UiPathTraceSettings(BaseModel): + """Trace settings for UiPath SDK.""" + + model_config = {"arbitrary_types_allowed": True} # Needed for Callable + + span_filter: Callable[[ReadableSpan], bool] | None = Field( + default=None, + description=( + "Optional filter to decide whether a span should be exported. " + "Called when a span ends with a ReadableSpan argument. " + "Return True to export, False to skip." + ), + ) diff --git a/tests/tracing/test_span_filtering.py b/tests/tracing/test_span_filtering.py new file mode 100644 index 0000000..5a6da25 --- /dev/null +++ b/tests/tracing/test_span_filtering.py @@ -0,0 +1,245 @@ +"""Tests for span filtering in trace manager and processors.""" + +from opentelemetry import trace + +from uipath.core.tracing.trace_manager import UiPathTraceManager +from uipath.core.tracing.types import UiPathTraceSettings + + +class TestSpanFiltering: + """Tests for span filtering functionality.""" + + def test_no_filter_exports_all_spans(self): + """Test that without a filter, all spans are exported.""" + trace_manager = UiPathTraceManager() + + tracer = trace.get_tracer("test") + with trace_manager.start_execution_span("root", "exec-1"): + with tracer.start_as_current_span("child-1"): + pass + with tracer.start_as_current_span("child-2"): + pass + + spans = trace_manager.get_execution_spans("exec-1") + assert len(spans) == 3 + span_names = {s.name for s in spans} + assert span_names == {"root", "child-1", "child-2"} + + def test_filter_drops_non_matching_spans(self): + """Test that filter drops spans that don't match the predicate.""" + 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=lambda span: span.attributes is not None + and span.attributes.get("keep") is True + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("kept", attributes={"keep": True}): + pass + with tracer.start_as_current_span("dropped", attributes={"keep": False}): + pass + with tracer.start_as_current_span("also-dropped"): + 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 "kept" in exported_names + assert "dropped" not in exported_names + assert "also-dropped" not in exported_names + + def test_filter_by_span_name(self): + """Test filtering spans by name pattern.""" + 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=lambda span: span.name.startswith("uipath.") + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("uipath.action"): + pass + with tracer.start_as_current_span("uipath.tool"): + pass + with tracer.start_as_current_span("http.request"): + 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 "uipath.action" in exported_names + assert "uipath.tool" in exported_names + assert "http.request" not in exported_names + + def test_filter_custom_instrumentation_attribute(self): + """Test filtering by custom instrumentation attribute (low-code scenario).""" + 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=lambda span: bool( + span.attributes and span.attributes.get("uipath.custom_instrumentation") + ) + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span( + "custom-span", + attributes={"uipath.custom_instrumentation": True}, + ): + pass + with tracer.start_as_current_span( + "auto-instrumented", + attributes={"http.method": "GET"}, + ): + 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 "custom-span" in exported_names + assert "auto-instrumented" not in exported_names + + def test_none_filter_same_as_no_filter(self): + """Test that explicit None filter behaves same as no filter.""" + 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) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("span-1"): + pass + with tracer.start_as_current_span("span-2"): + pass + + trace_manager.flush_spans() + + exported_spans = [] + for call in mock_exporter.export.call_args_list: + exported_spans.extend(call[0][0]) + + assert len(exported_spans) == 2 + + def test_filter_with_empty_attributes(self): + """Test that filter handles spans with no attributes gracefully.""" + 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=lambda span: ( + span.attributes is not None and span.attributes.get("required") is True + ) + ) + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter(mock_exporter, batch=False, settings=settings) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("no-attrs"): + pass + with tracer.start_as_current_span( + "has-required", attributes={"required": True} + ): + 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 "has-required" in exported_names + assert "no-attrs" not in exported_names + + def test_different_filters_per_exporter(self): + """Test that different exporters can have different filters.""" + from unittest.mock import MagicMock + + from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + + mock_exporter_a = MagicMock(spec=SpanExporter) + mock_exporter_a.export.return_value = SpanExportResult.SUCCESS + + mock_exporter_b = MagicMock(spec=SpanExporter) + mock_exporter_b.export.return_value = SpanExportResult.SUCCESS + + settings_a = UiPathTraceSettings( + span_filter=lambda span: span.attributes is not None + and span.attributes.get("dest") == "a" + ) + settings_b = UiPathTraceSettings( + span_filter=lambda span: span.attributes is not None + and span.attributes.get("dest") == "b" + ) + + trace_manager = UiPathTraceManager() + trace_manager.add_span_exporter( + mock_exporter_a, batch=False, settings=settings_a + ) + trace_manager.add_span_exporter( + mock_exporter_b, batch=False, settings=settings_b + ) + + tracer = trace.get_tracer("test") + with tracer.start_as_current_span("to-a", attributes={"dest": "a"}): + pass + with tracer.start_as_current_span("to-b", attributes={"dest": "b"}): + pass + with tracer.start_as_current_span("to-neither", attributes={"dest": "c"}): + pass + + trace_manager.flush_spans() + + exported_a = [] + for call in mock_exporter_a.export.call_args_list: + exported_a.extend(call[0][0]) + assert {s.name for s in exported_a} == {"to-a"} + + exported_b = [] + for call in mock_exporter_b.export.call_args_list: + exported_b.extend(call[0][0]) + assert {s.name for s in exported_b} == {"to-b"} diff --git a/uv.lock b/uv.lock index a369277..35bafbb 100644 --- a/uv.lock +++ b/uv.lock @@ -430,20 +430,20 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.39.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/0b/e5428c009d4d9af0515b0a8371a8aaae695371af291f45e702f7969dce6b/opentelemetry_api-1.39.0.tar.gz", hash = "sha256:6130644268c5ac6bdffaf660ce878f10906b3e789f7e2daa5e169b047a2933b9", size = 65763, upload-time = "2025-12-03T13:19:56.378Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/85/d831a9bc0a9e0e1a304ff3d12c1489a5fbc9bf6690a15dcbdae372bbca45/opentelemetry_api-1.39.0-py3-none-any.whl", hash = "sha256:3c3b3ca5c5687b1b5b37e5c5027ff68eacea8675241b29f13110a8ffbb8f0459", size = 66357, upload-time = "2025-12-03T13:19:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.60b0" +version = "0.60b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -451,36 +451,36 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/3c/bd53dbb42eff93d18e3047c7be11224aa9966ce98ac4cc5bfb860a32c95a/opentelemetry_instrumentation-0.60b0.tar.gz", hash = "sha256:4e9fec930f283a2677a2217754b40aaf9ef76edae40499c165bc7f1d15366a74", size = 31707, upload-time = "2025-12-03T13:22:00.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/7b/5b5b9f8cfe727a28553acf9cd287b1d7f706f5c0a00d6e482df55b169483/opentelemetry_instrumentation-0.60b0-py3-none-any.whl", hash = "sha256:aaafa1483543a402819f1bdfb06af721c87d60dd109501f9997332862a35c76a", size = 33096, upload-time = "2025-12-03T13:20:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, ] [[package]] name = "opentelemetry-sdk" -version = "1.39.0" +version = "1.39.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/e3/7cd989003e7cde72e0becfe830abff0df55c69d237ee7961a541e0167833/opentelemetry_sdk-1.39.0.tar.gz", hash = "sha256:c22204f12a0529e07aa4d985f1bca9d6b0e7b29fe7f03e923548ae52e0e15dde", size = 171322, upload-time = "2025-12-03T13:20:09.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/b4/2adc8bc83eb1055ecb592708efb6f0c520cc2eb68970b02b0f6ecda149cf/opentelemetry_sdk-1.39.0-py3-none-any.whl", hash = "sha256:90cfb07600dfc0d2de26120cebc0c8f27e69bf77cd80ef96645232372709a514", size = 132413, upload-time = "2025-12-03T13:19:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.60b0" +version = "0.60b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/0e/176a7844fe4e3cb5de604212094dffaed4e18b32f1c56b5258bcbcba85c2/opentelemetry_semantic_conventions-0.60b0.tar.gz", hash = "sha256:227d7aa73cbb8a2e418029d6b6465553aa01cf7e78ec9d0bc3255c7b3ac5bf8f", size = 137935, upload-time = "2025-12-03T13:20:12.395Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/56/af0306666f91bae47db14d620775604688361f0f76a872e0005277311131/opentelemetry_semantic_conventions-0.60b0-py3-none-any.whl", hash = "sha256:069530852691136018087b52688857d97bba61cd641d0f8628d2d92788c4f78a", size = 219981, upload-time = "2025-12-03T13:19:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] [[package]] @@ -991,7 +991,7 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.1.10" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "opentelemetry-instrumentation" }, @@ -1016,8 +1016,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "opentelemetry-instrumentation", specifier = ">=0.60b0,<1.0.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.39.0,<2.0.0" }, + { name = "opentelemetry-instrumentation", specifier = ">=0.60b1,<1.0.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.39.1,<2.0.0" }, { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, ]