Skip to content
This repository was archived by the owner on Mar 4, 2026. It is now read-only.

Commit 71386e2

Browse files
committed
feat(tracing): add current span ancestors provider
1 parent 6a36690 commit 71386e2

2 files changed

Lines changed: 573 additions & 18 deletions

File tree

src/uipath/core/tracing/manager.py

Lines changed: 278 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,319 @@
11
"""Tracing manager for handling tracer implementations and function registry."""
22

33
import logging
4-
from typing import Any, Callable, Optional
4+
from typing import Any, Callable, Dict, List, Optional
55

66
from opentelemetry import context, trace
7+
from opentelemetry.sdk.trace import ReadableSpan
78
from opentelemetry.trace import set_span_in_context
89

910
logger = logging.getLogger(__name__)
1011

1112

13+
class SpanRegistry:
14+
"""Registry to track all spans and their parent relationships."""
15+
16+
MAX_HIERARCHY_DEPTH = 1000 # Hard limit for hierarchy traversal
17+
18+
def __init__(self):
19+
self._spans: Dict[int, ReadableSpan] = {} # span_id -> span
20+
self._parent_map: Dict[int, Optional[int]] = {} # span_id -> parent_id
21+
22+
def register_span(self, span: ReadableSpan) -> None:
23+
"""Register a span and its parent relationship."""
24+
span_id = span.get_span_context().span_id
25+
parent_id = span.parent.span_id if span.parent else None
26+
27+
self._spans[span_id] = span
28+
self._parent_map[span_id] = parent_id
29+
30+
parent_str = f"{parent_id:016x}" if parent_id is not None else "None"
31+
logger.debug(
32+
f"SpanRegistry: registered span: {span.name} (id: {span_id:016x}, parent: {parent_str})"
33+
)
34+
35+
def get_span(self, span_id: int) -> Optional[ReadableSpan]:
36+
"""Get a span by ID."""
37+
return self._spans.get(span_id)
38+
39+
def get_parent_id(self, span_id: int) -> Optional[int]:
40+
"""Get the parent ID of a span."""
41+
return self._parent_map.get(span_id)
42+
43+
def calculate_depth(self, span_id: int) -> int:
44+
"""Calculate the depth of a span in the hierarchy.
45+
46+
Returns:
47+
The depth of the span, capped at MAX_HIERARCHY_DEPTH.
48+
"""
49+
depth = 0
50+
current_id = span_id
51+
visited = set()
52+
53+
while current_id is not None and current_id not in visited:
54+
visited.add(current_id)
55+
parent_id = self._parent_map.get(current_id)
56+
if parent_id is None:
57+
break
58+
depth += 1
59+
if depth >= self.MAX_HIERARCHY_DEPTH:
60+
logger.warning(
61+
f"Hit MAX_HIERARCHY_DEPTH ({self.MAX_HIERARCHY_DEPTH}) while calculating depth for span {span_id:016x}"
62+
)
63+
break
64+
current_id = parent_id
65+
66+
return depth
67+
68+
def is_ancestor(self, ancestor_id: int, descendant_id: int) -> bool:
69+
"""Check if ancestor_id is an ancestor of descendant_id.
70+
71+
Returns:
72+
True if ancestor_id is an ancestor of descendant_id, False otherwise.
73+
If MAX_HIERARCHY_DEPTH is reached, returns False.
74+
"""
75+
current_id = descendant_id
76+
visited = set()
77+
steps = 0
78+
79+
while current_id is not None and current_id not in visited:
80+
if current_id == ancestor_id:
81+
return True
82+
visited.add(current_id)
83+
current_id = self._parent_map.get(current_id)
84+
steps += 1
85+
if steps >= self.MAX_HIERARCHY_DEPTH:
86+
logger.warning(
87+
f"Hit MAX_HIERARCHY_DEPTH ({self.MAX_HIERARCHY_DEPTH}) while checking ancestry between {ancestor_id:016x} and {descendant_id:016x}"
88+
)
89+
return False
90+
91+
return False
92+
93+
def get_chain(self, span_id: int) -> List[int]:
94+
"""Get the complete ancestor chain for a span (bottom to top).
95+
96+
Returns:
97+
List of span IDs from the given span to the root, capped at MAX_HIERARCHY_DEPTH.
98+
"""
99+
chain = []
100+
current_id = span_id
101+
visited = set()
102+
103+
while current_id is not None and current_id not in visited:
104+
chain.append(current_id)
105+
visited.add(current_id)
106+
if len(chain) >= self.MAX_HIERARCHY_DEPTH:
107+
logger.warning(
108+
f"Hit MAX_HIERARCHY_DEPTH ({self.MAX_HIERARCHY_DEPTH}) while building chain for span {span_id:016x}"
109+
)
110+
break
111+
current_id = self._parent_map.get(current_id)
112+
113+
return chain
114+
115+
def clear(self) -> None:
116+
"""Clear all registered spans."""
117+
self._spans.clear()
118+
self._parent_map.clear()
119+
120+
121+
# Global span registry instance
122+
_span_registry = SpanRegistry()
123+
124+
12125
class UiPathTracingManager:
13126
"""Static utility class to manage tracing implementations and decorated functions."""
14127

15128
_current_span_provider: Optional[Callable[[], Any]] = None
129+
_current_span_ancestors_provider: Optional[Callable[[], List[Any]]] = None
16130

17-
@classmethod
131+
@staticmethod
18132
def register_current_span_provider(
19-
cls, current_span_provider: Optional[Callable[[], Any]]
133+
current_span_provider: Optional[Callable[[], Any]],
20134
):
21135
"""Register a custom current span provider function.
22136
23137
Args:
24138
current_span_provider: A function that returns the current span from an external
25139
tracing framework. If None, no custom span parenting will be used.
26140
"""
27-
cls._current_span_provider = current_span_provider
141+
UiPathTracingManager._current_span_provider = current_span_provider
28142

29143
@staticmethod
30-
def get_parent_context():
144+
def get_parent_context() -> context.Context:
31145
"""Get the parent context for span creation.
32146
33-
Prioritizes:
34-
1. Currently active OTel span (for recursion/children)
35-
2. External span provider (for top-level calls)
36-
3. Current context as fallback
147+
This method determines the correct parent context when creating a new traced span.
148+
It handles scenarios where spans may exist in both OpenTelemetry's context (current_span)
149+
and in an external tracing system (external_span), such as LangGraph.
150+
151+
The algorithm follows this priority:
152+
153+
1. **No spans available**: Returns the current OpenTelemetry context (empty context)
154+
155+
2. **Only current_span exists**: Returns a context with current_span set as parent
156+
- This is the standard OpenTelemetry behavior for nested traced functions
157+
158+
3. **Only external_span exists**: Returns a context with external_span set as parent
159+
- This occurs when an external tracing system (like LangGraph) has an active span
160+
but there's no OTel span in the current call stack
161+
162+
4. **Both spans exist**: Calls `_get_bottom_most_span()` to determine which is deeper
163+
- Uses the SpanRegistry to build parent-child relationships
164+
- Returns the span that is closer to the "bottom" (leaf) of the trace tree
165+
- This ensures new spans are always attached to the deepest/most specific parent
166+
167+
Context Sources:
168+
- **current_span**: Retrieved from OpenTelemetry's `trace.get_current_span()`
169+
- Represents the active OTel span in the current execution context
170+
- Created by `@traced` decorators or manual span creation
171+
172+
- **external_span**: Retrieved from the registered custom span provider
173+
- Set via `register_current_span_provider()`
174+
- Typically provided by external frameworks (LangGraph, LangChain, etc.)
175+
- Allows integration with tracing systems outside of OpenTelemetry
176+
177+
Returns:
178+
context.Context: An OpenTelemetry context containing the appropriate parent span,
179+
or the current empty context if no spans are available
180+
181+
Example:
182+
```python
183+
# Called by the @traced decorator when creating a new span:
184+
ctx = UiPathTracingManager.get_parent_context()
185+
with tracer.start_as_current_span("my_span", context=ctx) as span:
186+
# New span will have the correct parent based on the logic above
187+
pass
188+
```
189+
190+
See Also:
191+
- `_get_bottom_most_span()`: Logic for choosing between two available spans
192+
- `register_current_span_provider()`: Register external span provider
193+
- `get_external_current_span()`: Retrieve span from external provider
37194
"""
38-
# Always use the currently active OTel span if valid (recursion / children)
39195
current_span = trace.get_current_span()
196+
has_current_span = (
197+
current_span is not None and current_span.get_span_context().is_valid
198+
)
199+
200+
external_span = UiPathTracingManager.get_external_current_span()
201+
has_external_span = external_span is not None
40202

41-
if current_span is not None and current_span.get_span_context().is_valid:
203+
# Only one or no spans available
204+
if not has_current_span:
205+
return (
206+
set_span_in_context(external_span)
207+
if has_external_span
208+
else context.get_current()
209+
)
210+
if not has_external_span:
42211
return set_span_in_context(current_span)
43212

44-
# Only for the very top-level call, fallback to external span provider
213+
# Both spans exist - find the bottom-most one
214+
bottom_span = UiPathTracingManager._get_bottom_most_span(
215+
current_span, external_span
216+
)
217+
return set_span_in_context(bottom_span)
218+
219+
@staticmethod
220+
def _get_bottom_most_span(
221+
current_span: ReadableSpan, external_span: ReadableSpan
222+
) -> ReadableSpan:
223+
"""Determine which span is deeper in the ancestor tree.
224+
225+
Args:
226+
current_span: The OTel current span
227+
external_span: The external span from the provider
228+
229+
Returns:
230+
The span that is deeper (closer to the bottom) in the call hierarchy
231+
"""
232+
# Register both spans in the registry
233+
_span_registry.register_span(current_span)
234+
_span_registry.register_span(external_span)
235+
236+
# Also register external ancestors
237+
external_ancestors = UiPathTracingManager.get_ancestor_spans() or []
238+
for ancestor in external_ancestors:
239+
_span_registry.register_span(ancestor)
240+
241+
current_span_id = current_span.get_span_context().span_id
242+
external_span_id = external_span.get_span_context().span_id
243+
244+
# Check if one span is an ancestor of the other
245+
if _span_registry.is_ancestor(external_span_id, current_span_id):
246+
logger.debug(
247+
"Traced Context: current_span is a descendant of external_span -> returning current_span (deeper)"
248+
)
249+
return current_span
250+
elif _span_registry.is_ancestor(current_span_id, external_span_id):
251+
logger.debug(
252+
"Traced Context: external_span is a descendant of current_span -> returning external_span (deeper)"
253+
)
254+
return external_span
255+
256+
# Neither is an ancestor of the other - they're in different branches
257+
# Use depth as tiebreaker
258+
current_depth = _span_registry.calculate_depth(current_span_id)
259+
external_depth = _span_registry.calculate_depth(external_span_id)
260+
261+
if current_depth > external_depth:
262+
logger.debug(
263+
f"Traced Context: Different branches, current_span is deeper (depth {current_depth} > {external_depth}) -> returning current_span"
264+
)
265+
return current_span
266+
elif external_depth > current_depth:
267+
logger.debug(
268+
f"Traced Context: Different branches, external_span is deeper (depth {external_depth} > {current_depth}) -> returning external_span"
269+
)
270+
return external_span
271+
else:
272+
# Same depth, different branches - default to external
273+
logger.debug(
274+
f"Traced Context: Same depth ({current_depth}), different branches -> defaulting to external_span"
275+
)
276+
return external_span
277+
278+
@staticmethod
279+
def get_external_current_span():
280+
"""Get the current span from the external provider, if any."""
45281
if UiPathTracingManager._current_span_provider is not None:
46282
try:
47-
external_span = UiPathTracingManager._current_span_provider()
48-
if external_span is not None:
49-
return set_span_in_context(external_span)
283+
return UiPathTracingManager._current_span_provider()
50284
except Exception as e:
51285
logger.warning(f"Error getting current span from provider: {e}")
286+
return None
52287

53-
# Last fallback
54-
ctx = context.get_current()
288+
@staticmethod
289+
def get_ancestor_spans() -> List[Any]:
290+
"""Get the ancestor spans from the registered provider, if any."""
291+
if UiPathTracingManager._current_span_ancestors_provider is not None:
292+
try:
293+
return UiPathTracingManager._current_span_ancestors_provider()
294+
except Exception as e:
295+
logger.warning(f"Error getting ancestor spans from provider: {e}")
296+
return []
55297

56-
return ctx
298+
@staticmethod
299+
def register_current_span_ancestors_provider(
300+
current_span_ancestors_provider: Optional[Callable[[], List[Any]]],
301+
):
302+
"""Register a custom current span ancestors provider function.
303+
304+
Args:
305+
current_span_ancestors_provider: A function that returns a list of ancestor spans
306+
from an external tracing framework. If None, no custom
307+
span ancestor information will be used.
308+
"""
309+
UiPathTracingManager._current_span_ancestors_provider = (
310+
current_span_ancestors_provider
311+
)
312+
313+
@staticmethod
314+
def get_current_span_ancestors_provider():
315+
"""Get the currently set custom span ancestors provider."""
316+
return UiPathTracingManager._current_span_ancestors_provider
57317

58318

59319
__all__ = ["UiPathTracingManager"]

0 commit comments

Comments
 (0)