Skip to content

Commit b34b7db

Browse files
committed
feat(traced): instrument traceable with otel
1 parent b347c22 commit b34b7db

File tree

7 files changed

+891
-30
lines changed

7 files changed

+891
-30
lines changed

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "UiPath Langchain"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.10"
77
dependencies = [
8-
"uipath>=2.1.10, <2.2.0",
8+
"uipath>=2.1.28.dev1005170000,<2.1.28.dev1005180000",
99
"langgraph>=0.5.0, <0.7.0",
1010
"langchain-core>=0.3.34",
1111
"langgraph-checkpoint-sqlite>=2.0.3",
@@ -111,3 +111,7 @@ name = "testpypi"
111111
url = "https://test.pypi.org/simple/"
112112
publish-url = "https://test.pypi.org/legacy/"
113113
explicit = true
114+
115+
116+
[tool.uv.sources]
117+
uipath = { index = "testpypi" }

src/uipath_langchain/_cli/_runtime/_runtime.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
1212
from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError
1313
from langgraph.graph.state import CompiledStateGraph
14-
from openinference.instrumentation.langchain import LangChainInstrumentor
14+
from openinference.instrumentation.langchain import (
15+
LangChainInstrumentor,
16+
get_current_span,
17+
)
1518
from opentelemetry import trace
1619
from opentelemetry.sdk.trace import TracerProvider
1720
from opentelemetry.sdk.trace.export import BatchSpanProcessor
@@ -20,6 +23,7 @@
2023
UiPathErrorCategory,
2124
UiPathRuntimeResult,
2225
)
26+
from uipath.tracing import TracingManager
2327

2428
from ..._utils import _instrument_traceable_attributes
2529
from ...tracers import (
@@ -57,30 +61,43 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
5761
Raises:
5862
LangGraphRuntimeError: If execution fails
5963
"""
60-
_instrument_traceable_attributes()
64+
65+
use_otel_ff = os.getenv("UIPATH_USE_OTEL_TRACING", "false").lower() == "true"
66+
67+
# Instrument LangSmith @traceable to use appropriate adapter
68+
# When OTEL is enabled: uses UiPath @traced()
69+
# When OTEL is disabled: uses existing dispatch_event system
70+
_instrument_traceable_attributes(useOtel=use_otel_ff)
6171

6272
await self.validate()
6373

64-
with suppress(Exception):
65-
provider = TracerProvider()
66-
trace.set_tracer_provider(provider)
67-
provider.add_span_processor(BatchSpanProcessor(LangchainExporter())) # type: ignore
74+
if use_otel_ff:
75+
try:
76+
provider = TracerProvider()
77+
trace.set_tracer_provider(provider)
78+
provider.add_span_processor(BatchSpanProcessor(LangchainExporter())) # type: ignore
79+
80+
provider.add_span_processor(
81+
BatchSpanProcessor(
82+
JsonFileExporter(
83+
".uipath/traces.jsonl", LangchainSpanProcessor()
84+
)
85+
)
86+
)
6887

69-
provider.add_span_processor(
70-
BatchSpanProcessor(
71-
JsonFileExporter(".uipath/traces.jsonl", LangchainSpanProcessor())
88+
provider.add_span_processor(
89+
BatchSpanProcessor(
90+
SqliteExporter(".uipath/traces.db", LangchainSpanProcessor())
91+
)
7292
)
73-
)
7493

75-
provider.add_span_processor(
76-
BatchSpanProcessor(
77-
SqliteExporter(".uipath/traces.db", LangchainSpanProcessor())
94+
LangChainInstrumentor().instrument(
95+
tracer_provider=trace.get_tracer_provider()
7896
)
79-
)
8097

81-
LangChainInstrumentor().instrument(
82-
tracer_provider=trace.get_tracer_provider()
83-
)
98+
TracingManager.register_current_span_provider(get_current_span)
99+
except Exception as e:
100+
logger.error(f"Failed to initialize OpenTelemetry tracing: {e}")
84101

85102
if self.context.state_graph is None:
86103
return None
@@ -111,9 +128,10 @@ async def execute(self) -> Optional[UiPathRuntimeResult]:
111128
# Set up tracing if available
112129
callbacks: List[BaseCallbackHandler] = []
113130

114-
# if self.context.job_id and self.context.tracing_enabled:
115-
# tracer = AsyncUiPathTracer(context=self.context.trace_context)
116-
# callbacks = [tracer]
131+
if not use_otel_ff:
132+
if self.context.job_id and self.context.tracing_enabled:
133+
tracer = AsyncUiPathTracer(context=self.context.trace_context)
134+
callbacks = [tracer]
117135

118136
graph_config: RunnableConfig = {
119137
"configurable": {

src/uipath_langchain/tracers/LangchainSpanProcessor.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
22
import logging
33
from abc import ABC, abstractmethod
4-
from typing import Any, Dict, MutableMapping, override
4+
from typing import Any, Dict, MutableMapping
5+
6+
from typing_extensions import override
57

68
logger = logging.getLogger(__name__)
79

@@ -209,14 +211,25 @@ def extract_attributes(self, span_data: MutableMapping[str, Any]) -> Dict[str, A
209211
for key in ["Attributes", "attributes"]:
210212
if key in span_data:
211213
value = span_data.pop(key)
212-
return safe_parse_json(value) if isinstance(value, str) else value
213-
return None
214+
if isinstance(value, str):
215+
try:
216+
parsed_value = json.loads(value)
217+
return parsed_value if isinstance(parsed_value, dict) else {}
218+
except json.JSONDecodeError:
219+
logger.warning(f"Failed to parse attributes JSON: {value}")
220+
return {}
221+
elif isinstance(value, dict):
222+
return value
223+
else:
224+
return {}
225+
return {}
214226

215227
@override
216228
def process_span(self, span_data: MutableMapping[str, Any]) -> Dict[str, Any]:
229+
logger.info(f"Processing span: {span_data}")
217230
attributes = self.extract_attributes(span_data)
218231

219-
if attributes:
232+
if attributes and isinstance(attributes, dict):
220233
if "openinference.span.kind" in attributes:
221234
# Remove the span kind attribute
222235
span_type = attributes["openinference.span.kind"]

src/uipath_langchain/tracers/_instrument_traceable.py

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any, Callable, Dict, List, Literal, Optional
88

99
from langchain_core.callbacks import dispatch_custom_event
10-
from uipath.tracing import TracingManager
10+
from uipath.tracing import TracingManager, traced
1111

1212
from ._events import CustomTraceEvents, FunctionCallEventData
1313

@@ -387,12 +387,112 @@ def register_uipath_tracing():
387387

388388

389389
# Apply the patch
390-
def _instrument_traceable_attributes():
390+
def _map_traceable_to_traced_args(
391+
run_type: Optional[str] = None,
392+
name: Optional[str] = None,
393+
tags: Optional[List[str]] = None,
394+
metadata: Optional[Dict[str, Any]] = None,
395+
**kwargs: Any,
396+
) -> Dict[str, Any]:
397+
"""
398+
Map LangSmith @traceable arguments to UiPath @traced() arguments.
399+
400+
Args:
401+
run_type: Function type (tool, chain, llm, retriever, etc.)
402+
name: Custom name for the traced function
403+
tags: List of tags for categorization
404+
metadata: Additional metadata dictionary
405+
**kwargs: Additional arguments (ignored)
406+
407+
Returns:
408+
Dict containing mapped arguments for @traced()
409+
"""
410+
traced_args = {}
411+
412+
# Direct mappings
413+
if name is not None:
414+
traced_args["name"] = name
415+
416+
# Pass through run_type directly to UiPath @traced()
417+
if run_type:
418+
traced_args["run_type"] = run_type
419+
420+
# For span_type, we can derive from run_type or use a default
421+
if run_type:
422+
# Map run_type to appropriate span_type for OpenTelemetry
423+
span_type_mapping = {
424+
"tool": "tool_call",
425+
"chain": "chain_execution",
426+
"llm": "llm_call",
427+
"retriever": "retrieval",
428+
"embedding": "embedding",
429+
"prompt": "prompt_template",
430+
"parser": "output_parser"
431+
}
432+
traced_args["span_type"] = span_type_mapping.get(run_type, run_type)
433+
434+
# Note: UiPath @traced() doesn't support custom attributes directly
435+
# Tags and metadata information is lost in the current mapping
436+
# This could be enhanced in future versions
437+
438+
return traced_args
439+
440+
441+
def otel_traceable_adapter(
442+
func: Optional[Callable] = None,
443+
*,
444+
run_type: Optional[str] = None,
445+
name: Optional[str] = None,
446+
tags: Optional[List[str]] = None,
447+
metadata: Optional[Dict[str, Any]] = None,
448+
**kwargs: Any,
449+
):
450+
"""
451+
OTEL-based adapter that converts LangSmith @traceable decorator calls to UiPath @traced().
452+
453+
This function maintains the same interface as LangSmith's @traceable but uses
454+
UiPath's OpenTelemetry-based tracing system underneath.
455+
456+
Args:
457+
func: Function to be decorated (when used without parentheses)
458+
run_type: Type of function (tool, chain, llm, etc.)
459+
name: Custom name for tracing
460+
tags: List of tags for categorization
461+
metadata: Additional metadata dictionary
462+
**kwargs: Additional arguments (for future compatibility)
463+
464+
Returns:
465+
Decorated function or decorator function
466+
"""
467+
def decorator(f: Callable) -> Callable:
468+
# Map arguments to @traced() format
469+
traced_args = _map_traceable_to_traced_args(
470+
run_type=run_type,
471+
name=name,
472+
tags=tags,
473+
metadata=metadata,
474+
**kwargs
475+
)
476+
477+
# Apply UiPath @traced() decorator
478+
return traced(**traced_args)(f)
479+
480+
# Handle both @traceable and @traceable(...) usage patterns
481+
if func is None:
482+
# Called as @traceable(...) - return decorator
483+
return decorator
484+
else:
485+
# Called as @traceable - apply decorator directly
486+
return decorator(func)
487+
488+
489+
def _instrument_traceable_attributes(useOtel: bool = False):
391490
"""Apply the patch to langsmith module at import time."""
392491
global original_langsmith, original_traceable
393492

394-
# Register our custom tracing decorator
395-
register_uipath_tracing()
493+
if not useOtel:
494+
# Register our custom tracing decorator when not using opentelemetry
495+
register_uipath_tracing()
396496

397497
# Import the original module if not already done
398498
if original_langsmith is None:
@@ -408,7 +508,12 @@ def _instrument_traceable_attributes():
408508
original_traceable = original_langsmith.traceable
409509

410510
# Replace the traceable function with our patched version
411-
original_langsmith.traceable = patched_traceable
511+
if useOtel:
512+
# Use OTEL-based adapter when OTEL is enabled
513+
original_langsmith.traceable = otel_traceable_adapter
514+
else:
515+
# Use existing dispatch_event-based adapter
516+
original_langsmith.traceable = patched_traceable
412517

413518
# Put our modified module back
414519
sys.modules["langsmith"] = original_langsmith

0 commit comments

Comments
 (0)