Skip to content

Commit af5fb57

Browse files
Merge pull request #1039 from UiPath/feat/filter-agents-python-openinference-spans
feat: filter agents-python OpenInference spans from LLMOps export
2 parents f48bf22 + 5916782 commit af5fb57

File tree

4 files changed

+114
-4
lines changed

4 files changed

+114
-4
lines changed

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"
3-
version = "2.2.36"
3+
version = "2.2.37"
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"

src/uipath/tracing/_otel_exporters.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,15 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
119119
logger.warning("No spans to export")
120120
return SpanExportResult.SUCCESS
121121

122+
# Filter out spans marked for dropping
123+
filtered_spans = [s for s in spans if not self._should_drop_span(s)]
124+
125+
if len(filtered_spans) == 0:
126+
logger.debug("No spans to export after filtering dropped spans")
127+
return SpanExportResult.SUCCESS
128+
122129
logger.debug(
123-
f"Exporting {len(spans)} spans to {self.base_url}/llmopstenant_/api/Traces/spans"
130+
f"Exporting {len(filtered_spans)} spans to {self.base_url}/llmopstenant_/api/Traces/spans"
124131
)
125132

126133
# Use optimized path: keep attributes as dict for processing
@@ -129,7 +136,7 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
129136
_SpanUtils.otel_span_to_uipath_span(
130137
span, custom_trace_id=self.trace_id, serialize_attributes=False
131138
).to_dict(serialize_attributes=False)
132-
for span in spans
139+
for span in filtered_spans
133140
]
134141

135142
url = self._build_url(span_list)
@@ -345,6 +352,14 @@ def _get_base_url(self) -> str:
345352

346353
return uipath_url
347354

355+
def _should_drop_span(self, span: ReadableSpan) -> bool:
356+
"""Check if span is marked for dropping.
357+
358+
Spans with telemetry.filter="drop" are skipped by this exporter.
359+
"""
360+
attrs = span.attributes or {}
361+
return attrs.get("telemetry.filter") == "drop"
362+
348363

349364
class JsonLinesFileExporter(SpanExporter):
350365
def __init__(self, file_path: str):

tests/tracing/test_otel_exporters.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ def mock_env_vars():
3838
def mock_span():
3939
"""Create a mock ReadableSpan for testing."""
4040
span = MagicMock(spec=ReadableSpan)
41+
# Ensure span doesn't get filtered by _should_drop_span
42+
span.attributes = {}
4143
return span
4244

4345

@@ -555,5 +557,98 @@ def test_unknown_span_type_preserved(self):
555557
print(f"✓ Final attributes keys: {list(attributes.keys())}")
556558

557559

560+
class TestSpanFiltering:
561+
"""Tests for filtering spans marked with telemetry.filter=drop."""
562+
563+
@pytest.fixture
564+
def exporter_with_mocks(self, mock_env_vars):
565+
"""Create exporter with mocked HTTP client."""
566+
with patch("uipath.tracing._otel_exporters.httpx.Client"):
567+
exporter = LlmOpsHttpExporter()
568+
yield exporter
569+
570+
def _create_mock_span(
571+
self,
572+
should_drop: bool = False,
573+
) -> MagicMock:
574+
"""Helper to create mock span with span attributes.
575+
576+
Args:
577+
should_drop: If True, sets telemetry.filter="drop".
578+
"""
579+
span = MagicMock(spec=ReadableSpan)
580+
581+
if should_drop:
582+
span.attributes = {"telemetry.filter": "drop"}
583+
else:
584+
span.attributes = {}
585+
586+
return span
587+
588+
def test_filters_spans_marked_for_drop(self, exporter_with_mocks):
589+
"""Span with telemetry.filter=drop → filtered out."""
590+
span = self._create_mock_span(should_drop=True)
591+
assert exporter_with_mocks._should_drop_span(span) is True
592+
593+
def test_passes_unmarked_spans(self, exporter_with_mocks):
594+
"""Span without marker attribute → passes through."""
595+
span = self._create_mock_span(should_drop=False)
596+
assert exporter_with_mocks._should_drop_span(span) is False
597+
598+
def test_passes_spans_with_no_attributes(self, exporter_with_mocks):
599+
"""Span with None attributes → passes through."""
600+
span = MagicMock(spec=ReadableSpan)
601+
span.attributes = None
602+
assert exporter_with_mocks._should_drop_span(span) is False
603+
604+
def test_passes_spans_with_empty_attributes(self, exporter_with_mocks):
605+
"""Span with empty attributes dict → passes through."""
606+
span = MagicMock(spec=ReadableSpan)
607+
span.attributes = {}
608+
assert exporter_with_mocks._should_drop_span(span) is False
609+
610+
def test_passes_spans_with_other_filter_values(self, exporter_with_mocks):
611+
"""Span with telemetry.filter=keep → passes through."""
612+
span = MagicMock(spec=ReadableSpan)
613+
span.attributes = {"telemetry.filter": "keep"}
614+
assert exporter_with_mocks._should_drop_span(span) is False
615+
616+
def test_export_filters_marked_spans(self, exporter_with_mocks):
617+
"""export() should filter out spans marked for drop."""
618+
# Create mixed batch: 1 marked for drop, 1 unmarked
619+
drop_span = self._create_mock_span(should_drop=True)
620+
keep_span = self._create_mock_span(should_drop=False)
621+
622+
mock_uipath_span = MagicMock()
623+
mock_uipath_span.to_dict.return_value = {
624+
"TraceId": "test-trace-id",
625+
"Id": "span-id",
626+
}
627+
628+
with patch(
629+
"uipath.tracing._otel_exporters._SpanUtils.otel_span_to_uipath_span",
630+
return_value=mock_uipath_span,
631+
) as mock_convert:
632+
mock_response = MagicMock()
633+
mock_response.status_code = 200
634+
exporter_with_mocks.http_client.post.return_value = mock_response
635+
636+
result = exporter_with_mocks.export([drop_span, keep_span])
637+
638+
assert result == SpanExportResult.SUCCESS
639+
# Only keep_span should be converted (drop_span filtered)
640+
assert mock_convert.call_count == 1
641+
642+
def test_export_all_filtered_returns_success(self, exporter_with_mocks):
643+
"""When all spans filtered, export returns SUCCESS without HTTP call."""
644+
span = self._create_mock_span(should_drop=True)
645+
646+
result = exporter_with_mocks.export([span])
647+
648+
assert result == SpanExportResult.SUCCESS
649+
# No HTTP call should be made
650+
exporter_with_mocks.http_client.post.assert_not_called()
651+
652+
558653
if __name__ == "__main__":
559654
unittest.main()

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.

0 commit comments

Comments
 (0)