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

Commit a101411

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

3 files changed

Lines changed: 556 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-core"
3-
version = "0.0.1"
3+
version = "0.0.2"
44
description = "UiPath Core abstractions"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/core/tracing/manager.py

Lines changed: 258 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,297 @@
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.trace import set_span_in_context
7+
from opentelemetry.trace import Span, set_span_in_context
88

99
logger = logging.getLogger(__name__)
1010

1111

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

15-
_current_span_provider: Optional[Callable[[], Any]] = None
105+
_current_span_provider: Optional[Callable[[], Optional[Span]]] = None
106+
_current_span_ancestors_provider: Optional[Callable[[], List[Span]]] = None
16107

17-
@classmethod
108+
@staticmethod
18109
def register_current_span_provider(
19-
cls, current_span_provider: Optional[Callable[[], Any]]
110+
current_span_provider: Optional[Callable[[], Optional[Span]]],
20111
):
21112
"""Register a custom current span provider function.
22113
23114
Args:
24115
current_span_provider: A function that returns the current span from an external
25116
tracing framework. If None, no custom span parenting will be used.
26117
"""
27-
cls._current_span_provider = current_span_provider
118+
UiPathTracingManager._current_span_provider = current_span_provider
28119

29120
@staticmethod
30-
def get_parent_context():
121+
def get_parent_context() -> context.Context:
31122
"""Get the parent context for span creation.
32123
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
124+
This method determines the correct parent context when creating a new traced span.
125+
It handles scenarios where spans may exist in both OpenTelemetry's context (current_span)
126+
and in an external tracing system (external_span), such as LangGraph.
127+
128+
The algorithm follows this priority:
129+
130+
1. **No spans available**: Returns the current OpenTelemetry context (empty context)
131+
132+
2. **Only current_span exists**: Returns a context with current_span set as parent
133+
- This is the standard OpenTelemetry behavior for nested traced functions
134+
135+
3. **Only external_span exists**: Returns a context with external_span set as parent
136+
- This occurs when an external tracing system (like LangGraph) has an active span
137+
but there's no OTel span in the current call stack
138+
139+
4. **Both spans exist**: Calls `_get_bottom_most_span()` to determine which is deeper
140+
- Uses the SpanRegistry to build parent-child relationships
141+
- Returns the span that is closer to the "bottom" (leaf) of the trace tree
142+
- This ensures new spans are always attached to the deepest/most specific parent
143+
144+
Context Sources:
145+
- **current_span**: Retrieved from OpenTelemetry's `trace.get_current_span()`
146+
- Represents the active OTel span in the current execution context
147+
- Created by `@traced` decorators or manual span creation
148+
149+
- **external_span**: Retrieved from the registered custom span provider
150+
- Set via `register_current_span_provider()`
151+
- Typically provided by external frameworks (LangGraph, LangChain, etc.)
152+
- Allows integration with tracing systems outside of OpenTelemetry
153+
154+
Returns:
155+
context.Context: An OpenTelemetry context containing the appropriate parent span,
156+
or the current empty context if no spans are available
157+
158+
Example:
159+
```python
160+
# Called by the @traced decorator when creating a new span:
161+
ctx = UiPathTracingManager.get_parent_context()
162+
with tracer.start_as_current_span("my_span", context=ctx) as span:
163+
# New span will have the correct parent based on the logic above
164+
pass
165+
```
166+
167+
See Also:
168+
- `_get_bottom_most_span()`: Logic for choosing between two available spans
169+
- `register_current_span_provider()`: Register external span provider
170+
- `get_external_current_span()`: Retrieve span from external provider
37171
"""
38-
# Always use the currently active OTel span if valid (recursion / children)
39172
current_span = trace.get_current_span()
173+
has_current_span = (
174+
current_span is not None and current_span.get_span_context().is_valid
175+
)
40176

41-
if current_span is not None and current_span.get_span_context().is_valid:
177+
external_span = UiPathTracingManager.get_external_current_span()
178+
has_external_span = external_span is not None
179+
180+
# Only one or no spans available
181+
if not has_current_span:
182+
return (
183+
set_span_in_context(external_span)
184+
if has_external_span
185+
else context.get_current()
186+
)
187+
if not has_external_span:
42188
return set_span_in_context(current_span)
43189

44-
# Only for the very top-level call, fallback to external span provider
190+
# Both spans exist - find the bottom-most one
191+
bottom_span = UiPathTracingManager._get_bottom_most_span(
192+
current_span, external_span
193+
)
194+
return set_span_in_context(bottom_span)
195+
196+
@staticmethod
197+
def _get_bottom_most_span(
198+
current_span: Span,
199+
external_span: Span,
200+
) -> Span:
201+
"""Determine which span is deeper in the ancestor tree.
202+
203+
Args:
204+
current_span: The OTel current span
205+
external_span: The external span from the provider
206+
207+
Returns:
208+
The span that is deeper (closer to the bottom) in the call hierarchy
209+
"""
210+
# Register both spans in the registry
211+
_span_registry.register_span(current_span)
212+
_span_registry.register_span(external_span)
213+
214+
# Also register external ancestors
215+
external_ancestors = UiPathTracingManager.get_ancestor_spans() or []
216+
for ancestor in external_ancestors:
217+
_span_registry.register_span(ancestor)
218+
219+
current_span_id = current_span.get_span_context().span_id
220+
external_span_id = external_span.get_span_context().span_id
221+
222+
# Check if one span is an ancestor of the other
223+
if _span_registry.is_ancestor(external_span_id, current_span_id):
224+
logger.debug(
225+
"Traced Context: current_span is a descendant of external_span -> returning current_span (deeper)"
226+
)
227+
return current_span
228+
elif _span_registry.is_ancestor(current_span_id, external_span_id):
229+
logger.debug(
230+
"Traced Context: external_span is a descendant of current_span -> returning external_span (deeper)"
231+
)
232+
return external_span
233+
234+
# Neither is an ancestor of the other - they're in different branches
235+
# Use depth as tiebreaker
236+
current_depth = _span_registry.calculate_depth(current_span_id)
237+
external_depth = _span_registry.calculate_depth(external_span_id)
238+
239+
if current_depth > external_depth:
240+
logger.debug(
241+
f"Traced Context: Different branches, current_span is deeper (depth {current_depth} > {external_depth}) -> returning current_span"
242+
)
243+
return current_span
244+
elif external_depth > current_depth:
245+
logger.debug(
246+
f"Traced Context: Different branches, external_span is deeper (depth {external_depth} > {current_depth}) -> returning external_span"
247+
)
248+
return external_span
249+
else:
250+
# Same depth, different branches - default to external
251+
logger.debug(
252+
f"Traced Context: Same depth ({current_depth}), different branches -> defaulting to external_span"
253+
)
254+
return external_span
255+
256+
@staticmethod
257+
def get_external_current_span() -> Optional[Span]:
258+
"""Get the current span from the external provider, if any."""
45259
if UiPathTracingManager._current_span_provider is not None:
46260
try:
47-
external_span = UiPathTracingManager._current_span_provider()
48-
if external_span is not None:
49-
return set_span_in_context(external_span)
261+
return UiPathTracingManager._current_span_provider()
50262
except Exception as e:
51263
logger.warning(f"Error getting current span from provider: {e}")
264+
return None
265+
266+
@staticmethod
267+
def get_ancestor_spans() -> List[Span]:
268+
"""Get the ancestor spans from the registered provider, if any."""
269+
if UiPathTracingManager._current_span_ancestors_provider is not None:
270+
try:
271+
return UiPathTracingManager._current_span_ancestors_provider()
272+
except Exception as e:
273+
logger.warning(f"Error getting ancestor spans from provider: {e}")
274+
return []
52275

53-
# Last fallback
54-
ctx = context.get_current()
276+
@staticmethod
277+
def register_current_span_ancestors_provider(
278+
current_span_ancestors_provider: Optional[Callable[[], List[Span]]],
279+
):
280+
"""Register a custom current span ancestors provider function.
55281
56-
return ctx
282+
Args:
283+
current_span_ancestors_provider: A function that returns a list of ancestor spans
284+
from an external tracing framework. If None, no custom
285+
span ancestor information will be used.
286+
"""
287+
UiPathTracingManager._current_span_ancestors_provider = (
288+
current_span_ancestors_provider
289+
)
290+
291+
@staticmethod
292+
def get_current_span_ancestors_provider():
293+
"""Get the currently set custom span ancestors provider."""
294+
return UiPathTracingManager._current_span_ancestors_provider
57295

58296

59297
__all__ = ["UiPathTracingManager"]

0 commit comments

Comments
 (0)