22import logging
33import os
44import time
5- from typing import Any , Dict , List , Optional , Sequence
5+ from typing import Any , Callable , Dict , List , Optional , Sequence
66
77import httpx
88from 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
0 commit comments