Skip to content

Commit 38464bf

Browse files
committed
feat(trace): add span filtering
1 parent 5f47a90 commit 38464bf

File tree

2 files changed

+116
-2
lines changed

2 files changed

+116
-2
lines changed

src/uipath/tracing/_otel_exporters.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import time
5-
from typing import Any, Dict, List, Optional, Sequence
5+
from typing import Any, Callable, Dict, List, Optional, Sequence
66

77
import httpx
88
from opentelemetry.sdk.trace import ReadableSpan
@@ -97,9 +97,17 @@ class Status:
9797
def __init__(
9898
self,
9999
trace_id: Optional[str] = None,
100+
span_filter: Optional[Callable[[Dict[str, Any]], bool]] = None,
100101
**kwargs,
101102
):
102-
"""Initialize the exporter with the base URL and authentication token."""
103+
"""Initialize the exporter with the base URL and authentication token.
104+
105+
Args:
106+
trace_id: Optional custom trace ID to use for all spans
107+
span_filter: Optional filter function that takes a span dict and returns True
108+
if the span should be filtered out (dropped). Children of filtered
109+
spans will be reparented to the filtered span's parent.
110+
"""
103111
super().__init__(**kwargs)
104112
self.base_url = self._get_base_url()
105113
self.auth_token = os.environ.get("UIPATH_ACCESS_TOKEN")
@@ -112,6 +120,10 @@ def __init__(
112120

113121
self.http_client = httpx.Client(**client_kwargs, headers=self.headers)
114122
self.trace_id = trace_id
123+
self.span_filter = span_filter
124+
125+
# Track filtered span IDs across batches: filtered_id -> new_parent_id
126+
self._reparent_mapping: Dict[str, str] = {}
115127

116128
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
117129
"""Export spans to UiPath LLM Ops."""
@@ -132,6 +144,15 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
132144
for span in spans
133145
]
134146

147+
# Apply filtering and reparenting if filter is configured
148+
filter_enabled = os.environ.get("UIPATH_FILTER_PARENT_SPAN")
149+
if filter_enabled:
150+
span_list = self._filter_and_reparent_spans(span_list)
151+
152+
if len(span_list) == 0:
153+
logger.debug("No spans to export after filtering")
154+
return SpanExportResult.SUCCESS
155+
135156
url = self._build_url(span_list)
136157

137158
# Process spans in-place - work directly with dict
@@ -149,6 +170,88 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
149170

150171
return self._send_with_retries(url, span_list)
151172

173+
def _filter_and_reparent_spans(
174+
self, span_list: List[Dict[str, Any]]
175+
) -> List[Dict[str, Any]]:
176+
"""Filter out spans and reparent their children.
177+
178+
Rules:
179+
1. Root spans (uipath.is_root=True) are DROPPED, children reparented to UIPATH_PARENT_SPAN_ID
180+
2. Spans matching span_filter are DROPPED, children reparented to filtered span's parent
181+
182+
Args:
183+
span_list: List of span dicts to filter
184+
185+
Returns:
186+
Filtered list of spans with updated ParentIds
187+
"""
188+
new_parent_id = os.environ.get("UIPATH_PARENT_SPAN_ID")
189+
if not new_parent_id:
190+
return span_list
191+
192+
# First pass: identify spans to filter and build reparent mapping
193+
for span in span_list:
194+
span_id = span.get("Id")
195+
attributes = span.get("Attributes", {})
196+
if not isinstance(attributes, dict):
197+
continue
198+
199+
is_root = attributes.get("uipath.is_root", False)
200+
original_parent_id = attributes.get("uipath.original_parent_id")
201+
202+
# Rule 1: Root spans are dropped, children go to UIPATH_PARENT_SPAN_ID
203+
if is_root:
204+
self._reparent_mapping[span_id] = new_parent_id
205+
logger.debug(f"Marking root span for filtering: {span_id}")
206+
continue
207+
208+
# Rule 2: Check custom filter function
209+
if self.span_filter and self.span_filter(span):
210+
# Filtered span's children go to this span's parent
211+
# Use original_parent_id if available, otherwise use current ParentId
212+
parent = original_parent_id or span.get("ParentId")
213+
if parent:
214+
# Check if parent itself was filtered (transitive reparenting)
215+
while parent in self._reparent_mapping:
216+
parent = self._reparent_mapping[parent]
217+
self._reparent_mapping[span_id] = parent
218+
else:
219+
self._reparent_mapping[span_id] = new_parent_id
220+
logger.debug(
221+
f"Marking span for filtering: {span_id}, "
222+
f"children will be reparented to {self._reparent_mapping[span_id]}"
223+
)
224+
225+
# Second pass: filter spans and reparent children
226+
filtered_spans = []
227+
for span in span_list:
228+
span_id = span.get("Id")
229+
230+
# Skip filtered spans
231+
if span_id in self._reparent_mapping:
232+
logger.debug(f"Filtering out span: {span_id}, Name={span.get('Name')}")
233+
continue
234+
235+
# Reparent if parent was filtered
236+
parent_id = span.get("ParentId")
237+
if parent_id and parent_id in self._reparent_mapping:
238+
old_parent = parent_id
239+
# Follow the chain for transitive reparenting
240+
while parent_id in self._reparent_mapping:
241+
parent_id = self._reparent_mapping[parent_id]
242+
span["ParentId"] = parent_id
243+
logger.debug(
244+
f"Reparented span: {span_id}, Name={span.get('Name')}, "
245+
f"{old_parent} -> {parent_id}"
246+
)
247+
248+
filtered_spans.append(span)
249+
250+
logger.debug(
251+
f"Filtering complete: {len(span_list)} -> {len(filtered_spans)} spans"
252+
)
253+
return filtered_spans
254+
152255
def force_flush(self, timeout_millis: int = 30000) -> bool:
153256
"""Force flush the exporter."""
154257
return True

src/uipath/tracing/_utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,8 +212,13 @@ def otel_span_to_uipath_span(
212212

213213
# Get parent span ID if it exists
214214
parent_id = None
215+
is_root = otel_span.parent is None
216+
original_parent_id: Optional[str] = None
217+
215218
if otel_span.parent is not None:
216219
parent_id = _SpanUtils.span_id_to_uuid4(otel_span.parent.span_id)
220+
# Store original parent ID for potential reparenting later
221+
original_parent_id = str(parent_id)
217222
else:
218223
# Only set UIPATH_PARENT_SPAN_ID for root spans (spans without a parent)
219224
parent_span_id_str = env.get("UIPATH_PARENT_SPAN_ID")
@@ -226,6 +231,12 @@ def otel_span_to_uipath_span(
226231
# Only copy if we need to modify - we'll build attributes_dict lazily
227232
attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {}
228233

234+
# Add markers for filtering/reparenting in the exporter
235+
if is_root:
236+
attributes_dict["uipath.is_root"] = True
237+
if original_parent_id:
238+
attributes_dict["uipath.original_parent_id"] = original_parent_id
239+
229240
# Map status
230241
status = 1 # Default to OK
231242
if otel_span.status.status_code == StatusCode.ERROR:

0 commit comments

Comments
 (0)