Skip to content

Commit 92fcfe1

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

File tree

2 files changed

+166
-2
lines changed

2 files changed

+166
-2
lines changed

src/uipath/tracing/_otel_exporters.py

Lines changed: 155 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,138 @@ 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+
logger.info("[Filter] UIPATH_PARENT_SPAN_ID not set, skipping filtering")
191+
return span_list
192+
193+
logger.info(
194+
f"[Filter] Starting filter with {len(span_list)} spans, "
195+
f"UIPATH_PARENT_SPAN_ID={new_parent_id}, "
196+
f"span_filter={'set' if self.span_filter else 'not set'}"
197+
)
198+
199+
# First pass: identify spans to filter and build reparent mapping
200+
logger.info("[Filter] === FIRST PASS: Identifying spans to filter ===")
201+
for span in span_list:
202+
span_id = span.get("Id")
203+
span_name = span.get("Name")
204+
span_parent_id = span.get("ParentId")
205+
attributes = span.get("Attributes", {})
206+
207+
logger.info(
208+
f"[Filter] Checking span: Id={span_id}, Name={span_name}, "
209+
f"ParentId={span_parent_id}, attributes_type={type(attributes).__name__}"
210+
)
211+
212+
if not isinstance(attributes, dict):
213+
logger.info("[Filter] -> Skipping (attributes not a dict)")
214+
continue
215+
216+
is_root = attributes.get("uipath.is_root", False)
217+
original_parent_id = attributes.get("uipath.original_parent_id")
218+
219+
logger.info(
220+
f"[Filter] -> is_root={is_root}, original_parent_id={original_parent_id}"
221+
)
222+
223+
# Rule 1: Root spans are dropped, children go to UIPATH_PARENT_SPAN_ID
224+
if is_root:
225+
self._reparent_mapping[span_id] = new_parent_id
226+
logger.info(
227+
f"[Filter] Root span marked for filtering: "
228+
f"Id={span_id}, Name={span.get('Name')}, "
229+
f"children will be reparented to {new_parent_id}"
230+
)
231+
continue
232+
233+
# Rule 2: Check custom filter function
234+
if not self.span_filter:
235+
logger.info("[Filter] -> KEEP (no custom filter set)")
236+
continue
237+
238+
filter_result = self.span_filter(span)
239+
logger.info(f"[Filter] -> Custom filter result: {filter_result}")
240+
241+
if not filter_result:
242+
logger.info("[Filter] -> KEEP (custom filter returned False)")
243+
continue
244+
245+
# Filtered span's children go to this span's parent
246+
# Use original_parent_id if available, otherwise use current ParentId
247+
parent = original_parent_id or span.get("ParentId")
248+
if parent:
249+
# Check if parent itself was filtered (transitive reparenting)
250+
while parent in self._reparent_mapping:
251+
parent = self._reparent_mapping[parent]
252+
self._reparent_mapping[span_id] = parent
253+
else:
254+
self._reparent_mapping[span_id] = new_parent_id
255+
logger.info(
256+
f"[Filter] -> WILL FILTER (custom filter matched), "
257+
f"children will be reparented to {self._reparent_mapping[span_id]}"
258+
)
259+
260+
logger.info(
261+
f"[Filter] After first pass, reparent_mapping has {len(self._reparent_mapping)} entries: "
262+
f"{self._reparent_mapping}"
263+
)
264+
265+
# Second pass: filter spans and reparent children
266+
logger.info("[Filter] === SECOND PASS: Filtering and reparenting ===")
267+
filtered_spans = []
268+
for span in span_list:
269+
span_id = span.get("Id")
270+
span_name = span.get("Name")
271+
parent_id = span.get("ParentId")
272+
273+
# Skip filtered spans
274+
if span_id in self._reparent_mapping:
275+
logger.info(
276+
f"[Filter] DROPPING span: Id={span_id}, Name={span_name}"
277+
)
278+
continue
279+
280+
# Reparent if parent was filtered
281+
if parent_id and parent_id in self._reparent_mapping:
282+
old_parent = parent_id
283+
# Follow the chain for transitive reparenting
284+
while parent_id in self._reparent_mapping:
285+
parent_id = self._reparent_mapping[parent_id]
286+
span["ParentId"] = parent_id
287+
logger.info(
288+
f"[Filter] REPARENTING span: Id={span_id}, Name={span_name}, "
289+
f"ParentId: {old_parent} -> {parent_id}"
290+
)
291+
else:
292+
logger.info(
293+
f"[Filter] KEEPING span unchanged: Id={span_id}, Name={span_name}, "
294+
f"ParentId={parent_id}"
295+
)
296+
297+
filtered_spans.append(span)
298+
299+
logger.info(
300+
f"[Filter] Complete: {len(span_list)} input -> {len(filtered_spans)} output spans, "
301+
f"mapping size: {len(self._reparent_mapping)}"
302+
)
303+
return filtered_spans
304+
152305
def force_flush(self, timeout_millis: int = 30000) -> bool:
153306
"""Force flush the exporter."""
154307
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)