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,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
0 commit comments