Skip to content

Commit 5929238

Browse files
committed
feat(traces): filter parent span
1 parent 5f47a90 commit 5929238

File tree

3 files changed

+138
-0
lines changed

3 files changed

+138
-0
lines changed

src/uipath/tracing/_otel_exporters.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"])

src/uipath/tracing/_utils.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ 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
215216
if otel_span.parent is not None:
216217
parent_id = _SpanUtils.span_id_to_uuid4(otel_span.parent.span_id)
217218
else:
@@ -226,6 +227,10 @@ def otel_span_to_uipath_span(
226227
# Only copy if we need to modify - we'll build attributes_dict lazily
227228
attributes_dict: dict[str, Any] = dict(otel_attrs) if otel_attrs else {}
228229

230+
# Mark root spans for potential filtering by the exporter
231+
if is_root:
232+
attributes_dict["uipath.root_span"] = True
233+
229234
# Map status
230235
status = 1 # Default to OK
231236
if otel_span.status.status_code == StatusCode.ERROR:

src/uipath/tracing/changes.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Filter Parent Span Changes
2+
3+
## Problem
4+
5+
When `UIPATH_FILTER_PARENT_SPAN` is set along with `UIPATH_PARENT_SPAN_ID`, we want to:
6+
1. **Filter out** the root span (e.g., LangGraph) from being exported
7+
2. **Reparent** its children to `UIPATH_PARENT_SPAN_ID` so they appear under the correct parent in the trace UI
8+
9+
## Changes Made
10+
11+
### _utils.py (lines 213-232)
12+
13+
- Added `is_root = otel_span.parent is None` check
14+
- For root spans: set `ParentId` to `UIPATH_PARENT_SPAN_ID`
15+
- Added `uipath.root_span = True` marker in attributes to identify root spans later
16+
17+
### _otel_exporters.py (lines 116-393)
18+
19+
- Added `_parent_id_mapping: dict[str, str]` to track filtered span IDs across batches
20+
- In `export()`: call `_filter_root_and_reparent()` when both env vars are set
21+
- `_filter_root_and_reparent()` does:
22+
- **Pass 1**: Find spans with `uipath.root_span=True`, add their ID to mapping
23+
- **Pass 2**: Filter out root spans, reparent children whose parent is in mapping
24+
- **Orphan handling**: If a span's parent is not in the batch and not the new parent, reparent it (handles case where root came in earlier batch)
25+
26+
## Environment Variables
27+
28+
- `UIPATH_PARENT_SPAN_ID`: The parent span ID to use for root spans
29+
- `UIPATH_FILTER_PARENT_SPAN`: When set (any truthy value), enables filtering of root spans
30+
31+
## Behavior
32+
33+
| Env Vars Set | Behavior |
34+
|--------------|----------|
35+
| `UIPATH_PARENT_SPAN_ID` only | Root spans get this as their parent ID |
36+
| Both `UIPATH_FILTER_PARENT_SPAN` + `UIPATH_PARENT_SPAN_ID` | Root spans are filtered out, their children are reparented to `UIPATH_PARENT_SPAN_ID` |

0 commit comments

Comments
 (0)