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

Commit 6a257db

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

4 files changed

Lines changed: 584 additions & 22 deletions

File tree

.claude/settings.local.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(python -m mypy:*)"
5+
],
6+
"deny": [],
7+
"ask": []
8+
}
9+
}

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: 277 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,315 @@
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+
25+
parent_id: Optional[int] = None
26+
27+
if hasattr(span, "parent") and span.parent is not None:
28+
parent_id = getattr(span.parent, "span_id", None)
29+
30+
self._spans[span_id] = span
31+
self._parent_map[span_id] = parent_id
32+
33+
parent_str = "{:016x}".format(parent_id) if parent_id is not None else "None"
34+
35+
logger.debug(
36+
"SpanRegistry: registered span: %s (id: %016x, parent: %s)",
37+
span.name,
38+
span_id,
39+
parent_str,
40+
)
41+
42+
def get_span(self, span_id: int) -> Optional[Span]:
43+
"""Get a span by ID."""
44+
return self._spans.get(span_id)
45+
46+
def get_parent_id(self, span_id: int) -> Optional[int]:
47+
"""Get the parent ID of a span."""
48+
return self._parent_map.get(span_id)
49+
50+
def calculate_depth(self, span_id: int) -> int:
51+
"""Calculate the depth of a span in the hierarchy.
52+
53+
Returns:
54+
The depth of the span, capped at MAX_HIERARCHY_DEPTH.
55+
"""
56+
depth = 0
57+
current_id = span_id
58+
visited = set()
59+
60+
while current_id is not None and current_id not in visited:
61+
visited.add(current_id)
62+
parent_id = self._parent_map.get(current_id)
63+
if parent_id is None:
64+
break
65+
depth += 1
66+
if depth >= self.MAX_HIERARCHY_DEPTH:
67+
logger.warning(
68+
"Hit MAX_HIERARCHY_DEPTH (%d) while calculating depth for span %016x",
69+
self.MAX_HIERARCHY_DEPTH,
70+
span_id,
71+
)
72+
break
73+
current_id = parent_id
74+
75+
return depth
76+
77+
def is_ancestor(self, ancestor_id: int, descendant_id: int) -> bool:
78+
"""Check if ancestor_id is an ancestor of descendant_id.
79+
80+
Returns:
81+
True if ancestor_id is an ancestor of descendant_id, False otherwise.
82+
If MAX_HIERARCHY_DEPTH is reached, returns False.
83+
"""
84+
current_id: Optional[int] = descendant_id
85+
visited = set()
86+
steps = 0
87+
88+
while current_id is not None and current_id not in visited:
89+
if current_id == ancestor_id:
90+
return True
91+
visited.add(current_id)
92+
current_id = self._parent_map.get(current_id)
93+
steps += 1
94+
if steps >= self.MAX_HIERARCHY_DEPTH:
95+
logger.warning(
96+
"Hit MAX_HIERARCHY_DEPTH (%d) while checking ancestry between %016x and %016x",
97+
self.MAX_HIERARCHY_DEPTH,
98+
ancestor_id,
99+
descendant_id,
100+
)
101+
return False
102+
103+
return False
104+
105+
def clear(self) -> None:
106+
"""Clear all registered spans."""
107+
self._spans.clear()
108+
self._parent_map.clear()
109+
110+
111+
# Global span registry instance
112+
_span_registry = SpanRegistry()
113+
114+
12115
class UiPathTracingManager:
13116
"""Static utility class to manage tracing implementations and decorated functions."""
14117

15-
_current_span_provider: Optional[Callable[[], Any]] = None
118+
_current_span_provider: Optional[Callable[[], Optional[Span]]] = None
119+
_current_span_ancestors_provider: Optional[Callable[[], List[Span]]] = None
16120

17-
@classmethod
121+
@staticmethod
18122
def register_current_span_provider(
19-
cls, current_span_provider: Optional[Callable[[], Any]]
123+
current_span_provider: Optional[Callable[[], Optional[Span]]],
20124
):
21125
"""Register a custom current span provider function.
22126
23127
Args:
24128
current_span_provider: A function that returns the current span from an external
25129
tracing framework. If None, no custom span parenting will be used.
26130
"""
27-
cls._current_span_provider = current_span_provider
131+
UiPathTracingManager._current_span_provider = current_span_provider
28132

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

41-
if current_span is not None and current_span.get_span_context().is_valid:
190+
external_span = UiPathTracingManager.get_external_current_span()
191+
has_external_span = external_span is not None
192+
193+
# Only one or no spans available
194+
if not has_current_span:
195+
return (
196+
set_span_in_context(external_span)
197+
if has_external_span
198+
else context.get_current()
199+
)
200+
if not has_external_span:
42201
return set_span_in_context(current_span)
43202

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

53-
# Last fallback
54-
ctx = context.get_current()
284+
@staticmethod
285+
def get_ancestor_spans() -> List[Span]:
286+
"""Get the ancestor spans from the registered provider, if any."""
287+
if UiPathTracingManager._current_span_ancestors_provider is not None:
288+
try:
289+
return UiPathTracingManager._current_span_ancestors_provider()
290+
except Exception as e:
291+
logger.warning("Error getting ancestor spans from provider: %s", e)
292+
return []
55293

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

58314

59315
__all__ = ["UiPathTracingManager"]

0 commit comments

Comments
 (0)