@@ -113,6 +113,10 @@ def __init__(
113113 self .http_client = httpx .Client (** client_kwargs , headers = self .headers )
114114 self .trace_id = trace_id
115115
116+ # Track filtered root span IDs across export batches for reparenting
117+ # Maps original parent ID -> new parent ID for reparenting
118+ self ._parent_id_mapping : dict [str , str ] = {}
119+
116120 def export (self , spans : Sequence [ReadableSpan ]) -> SpanExportResult :
117121 """Export spans to UiPath LLM Ops."""
118122 if len (spans ) == 0 :
@@ -132,6 +136,16 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
132136 for span in spans
133137 ]
134138
139+ # Filter out root span and reparent children if UIPATH_FILTER_PARENT_SPAN is set
140+ filter_parent_span = os .environ .get ("UIPATH_FILTER_PARENT_SPAN" )
141+ parent_span_id = os .environ .get ("UIPATH_PARENT_SPAN_ID" )
142+ if filter_parent_span and parent_span_id :
143+ span_list = self ._filter_root_and_reparent (span_list , parent_span_id )
144+
145+ if len (span_list ) == 0 :
146+ logger .debug ("No spans to export after filtering" )
147+ return SpanExportResult .SUCCESS
148+
135149 url = self ._build_url (span_list )
136150
137151 # Process spans in-place - work directly with dict
@@ -309,6 +323,89 @@ def _process_span_attributes(self, span_data: Dict[str, Any]) -> None:
309323 status = self ._determine_status (error )
310324 span_data ["Status" ] = status
311325
326+ def _filter_root_and_reparent (
327+ self , span_list : List [Dict [str , Any ]], new_parent_id : str
328+ ) -> List [Dict [str , Any ]]:
329+ """Filter out root spans and reparent their children to the new parent ID.
330+
331+ Maintains a persistent mapping of filtered span IDs to their replacement parent IDs
332+ to handle cases where child spans arrive in later batches than their parents.
333+
334+ Args:
335+ span_list: List of span dictionaries
336+ new_parent_id: The new parent span ID for orphaned children
337+
338+ Returns:
339+ Filtered list of spans with updated parent IDs
340+ """
341+ logger .info (
342+ f"_filter_root_and_reparent called with { len (span_list )} spans, "
343+ f"new_parent_id={ new_parent_id } , "
344+ f"existing mapping keys: { list (self ._parent_id_mapping .keys ())} "
345+ )
346+
347+ # First pass: Find all root span IDs in this batch and build the mapping
348+ for span in span_list :
349+ attributes = span .get ("Attributes" , {})
350+ is_root = isinstance (attributes , dict ) and attributes .get ("uipath.root_span" )
351+ logger .info (
352+ f"Pass 1 - Checking span: Id={ span .get ('Id' )} , Name={ span .get ('Name' )} , "
353+ f"ParentId={ span .get ('ParentId' )} , is_root={ is_root } , "
354+ f"attributes type={ type (attributes ).__name__ } "
355+ )
356+ if is_root :
357+ self ._parent_id_mapping [span ["Id" ]] = new_parent_id
358+ logger .info (
359+ f"Added root span to mapping: { span ['Id' ]} -> { new_parent_id } "
360+ )
361+
362+ logger .info (f"After pass 1, mapping: { self ._parent_id_mapping } " )
363+
364+ # Build set of span IDs in this batch
365+ batch_span_ids = {span ["Id" ] for span in span_list }
366+
367+ # Second pass: Filter out root spans and reparent children
368+ filtered_spans = []
369+ for span in span_list :
370+ span_id = span ["Id" ]
371+ parent_id = span .get ("ParentId" )
372+
373+ # Skip root spans (they are in the mapping)
374+ if span_id in self ._parent_id_mapping :
375+ logger .info (f"Pass 2 - Filtering out root span: Id={ span_id } , Name={ span .get ('Name' )} " )
376+ continue
377+
378+ # Reparent spans whose parent was filtered (is in the mapping)
379+ if parent_id and parent_id in self ._parent_id_mapping :
380+ old_parent = parent_id
381+ span ["ParentId" ] = self ._parent_id_mapping [parent_id ]
382+ logger .info (
383+ f"Pass 2 - Reparented span: Id={ span_id } , Name={ span .get ('Name' )} , "
384+ f"old ParentId={ old_parent } -> new ParentId={ span ['ParentId' ]} "
385+ )
386+ # Reparent orphan spans whose parent is not in batch, not in mapping, and not the new_parent_id
387+ elif parent_id and parent_id not in batch_span_ids and parent_id != new_parent_id :
388+ old_parent = parent_id
389+ span ["ParentId" ] = new_parent_id
390+ # Add to mapping so future spans with same parent get reparented
391+ self ._parent_id_mapping [parent_id ] = new_parent_id
392+ logger .info (
393+ f"Pass 2 - Reparented orphan span: Id={ span_id } , Name={ span .get ('Name' )} , "
394+ f"old ParentId={ old_parent } -> new ParentId={ new_parent_id } (parent not in batch)"
395+ )
396+ else :
397+ logger .info (
398+ f"Pass 2 - Keeping span unchanged: Id={ span_id } , Name={ span .get ('Name' )} , "
399+ f"ParentId={ parent_id } , in_mapping={ parent_id in self ._parent_id_mapping if parent_id else 'N/A' } "
400+ )
401+
402+ filtered_spans .append (span )
403+
404+ logger .info (
405+ f"Filtering complete: { len (span_list )} -> { len (filtered_spans )} spans"
406+ )
407+ return filtered_spans
408+
312409 def _build_url (self , span_list : list [Dict [str , Any ]]) -> str :
313410 """Construct the URL for the API request."""
314411 trace_id = str (span_list [0 ]["TraceId" ])
0 commit comments