|
1 | 1 | """Tracing manager for handling tracer implementations and function registry.""" |
2 | 2 |
|
3 | 3 | import logging |
4 | | -from typing import Any, Callable, Optional |
| 4 | +from typing import Any, Callable, Dict, List, Optional |
5 | 5 |
|
6 | 6 | from opentelemetry import context, trace |
| 7 | +from opentelemetry.sdk.trace import ReadableSpan |
7 | 8 | from opentelemetry.trace import set_span_in_context |
8 | 9 |
|
9 | 10 | logger = logging.getLogger(__name__) |
10 | 11 |
|
11 | 12 |
|
| 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 clear(self) -> None: |
| 94 | + """Clear all registered spans.""" |
| 95 | + self._spans.clear() |
| 96 | + self._parent_map.clear() |
| 97 | + |
| 98 | + |
| 99 | +# Global span registry instance |
| 100 | +_span_registry = SpanRegistry() |
| 101 | + |
| 102 | + |
12 | 103 | class UiPathTracingManager: |
13 | 104 | """Static utility class to manage tracing implementations and decorated functions.""" |
14 | 105 |
|
15 | 106 | _current_span_provider: Optional[Callable[[], Any]] = None |
| 107 | + _current_span_ancestors_provider: Optional[Callable[[], List[Any]]] = None |
16 | 108 |
|
17 | | - @classmethod |
| 109 | + @staticmethod |
18 | 110 | def register_current_span_provider( |
19 | | - cls, current_span_provider: Optional[Callable[[], Any]] |
| 111 | + current_span_provider: Optional[Callable[[], Any]], |
20 | 112 | ): |
21 | 113 | """Register a custom current span provider function. |
22 | 114 |
|
23 | 115 | Args: |
24 | 116 | current_span_provider: A function that returns the current span from an external |
25 | 117 | tracing framework. If None, no custom span parenting will be used. |
26 | 118 | """ |
27 | | - cls._current_span_provider = current_span_provider |
| 119 | + UiPathTracingManager._current_span_provider = current_span_provider |
28 | 120 |
|
29 | 121 | @staticmethod |
30 | | - def get_parent_context(): |
| 122 | + def get_parent_context() -> context.Context: |
31 | 123 | """Get the parent context for span creation. |
32 | 124 |
|
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 |
| 125 | + This method determines the correct parent context when creating a new traced span. |
| 126 | + It handles scenarios where spans may exist in both OpenTelemetry's context (current_span) |
| 127 | + and in an external tracing system (external_span), such as LangGraph. |
| 128 | +
|
| 129 | + The algorithm follows this priority: |
| 130 | +
|
| 131 | + 1. **No spans available**: Returns the current OpenTelemetry context (empty context) |
| 132 | +
|
| 133 | + 2. **Only current_span exists**: Returns a context with current_span set as parent |
| 134 | + - This is the standard OpenTelemetry behavior for nested traced functions |
| 135 | +
|
| 136 | + 3. **Only external_span exists**: Returns a context with external_span set as parent |
| 137 | + - This occurs when an external tracing system (like LangGraph) has an active span |
| 138 | + but there's no OTel span in the current call stack |
| 139 | +
|
| 140 | + 4. **Both spans exist**: Calls `_get_bottom_most_span()` to determine which is deeper |
| 141 | + - Uses the SpanRegistry to build parent-child relationships |
| 142 | + - Returns the span that is closer to the "bottom" (leaf) of the trace tree |
| 143 | + - This ensures new spans are always attached to the deepest/most specific parent |
| 144 | +
|
| 145 | + Context Sources: |
| 146 | + - **current_span**: Retrieved from OpenTelemetry's `trace.get_current_span()` |
| 147 | + - Represents the active OTel span in the current execution context |
| 148 | + - Created by `@traced` decorators or manual span creation |
| 149 | +
|
| 150 | + - **external_span**: Retrieved from the registered custom span provider |
| 151 | + - Set via `register_current_span_provider()` |
| 152 | + - Typically provided by external frameworks (LangGraph, LangChain, etc.) |
| 153 | + - Allows integration with tracing systems outside of OpenTelemetry |
| 154 | +
|
| 155 | + Returns: |
| 156 | + context.Context: An OpenTelemetry context containing the appropriate parent span, |
| 157 | + or the current empty context if no spans are available |
| 158 | +
|
| 159 | + Example: |
| 160 | + ```python |
| 161 | + # Called by the @traced decorator when creating a new span: |
| 162 | + ctx = UiPathTracingManager.get_parent_context() |
| 163 | + with tracer.start_as_current_span("my_span", context=ctx) as span: |
| 164 | + # New span will have the correct parent based on the logic above |
| 165 | + pass |
| 166 | + ``` |
| 167 | +
|
| 168 | + See Also: |
| 169 | + - `_get_bottom_most_span()`: Logic for choosing between two available spans |
| 170 | + - `register_current_span_provider()`: Register external span provider |
| 171 | + - `get_external_current_span()`: Retrieve span from external provider |
37 | 172 | """ |
38 | | - # Always use the currently active OTel span if valid (recursion / children) |
39 | 173 | current_span = trace.get_current_span() |
| 174 | + has_current_span = ( |
| 175 | + current_span is not None and current_span.get_span_context().is_valid |
| 176 | + ) |
40 | 177 |
|
41 | | - if current_span is not None and current_span.get_span_context().is_valid: |
| 178 | + external_span = UiPathTracingManager.get_external_current_span() |
| 179 | + has_external_span = external_span is not None |
| 180 | + |
| 181 | + # Only one or no spans available |
| 182 | + if not has_current_span: |
| 183 | + return ( |
| 184 | + set_span_in_context(external_span) |
| 185 | + if has_external_span |
| 186 | + else context.get_current() |
| 187 | + ) |
| 188 | + if not has_external_span: |
42 | 189 | return set_span_in_context(current_span) |
43 | 190 |
|
44 | | - # Only for the very top-level call, fallback to external span provider |
| 191 | + # Both spans exist - find the bottom-most one |
| 192 | + bottom_span = UiPathTracingManager._get_bottom_most_span( |
| 193 | + current_span, external_span |
| 194 | + ) |
| 195 | + return set_span_in_context(bottom_span) |
| 196 | + |
| 197 | + @staticmethod |
| 198 | + def _get_bottom_most_span( |
| 199 | + current_span: ReadableSpan, external_span: ReadableSpan |
| 200 | + ) -> ReadableSpan: |
| 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(): |
| 258 | + """Get the current span from the external provider, if any.""" |
45 | 259 | if UiPathTracingManager._current_span_provider is not None: |
46 | 260 | 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() |
50 | 262 | except Exception as e: |
51 | 263 | logger.warning(f"Error getting current span from provider: {e}") |
| 264 | + return None |
| 265 | + |
| 266 | + @staticmethod |
| 267 | + def get_ancestor_spans() -> List[Any]: |
| 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 [] |
52 | 275 |
|
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[Any]]], |
| 279 | + ): |
| 280 | + """Register a custom current span ancestors provider function. |
55 | 281 |
|
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 |
57 | 295 |
|
58 | 296 |
|
59 | 297 | __all__ = ["UiPathTracingManager"] |
0 commit comments