Skip to content

Commit d9d4896

Browse files
feat(datafabric): add OTEL span instrumentation and error classification for SQL queries
- Add OpenTelemetry span around Data Fabric SQL query execution with entity metadata, success/error attributes, and structured error codes - Integrate DataFabricError classification from uipath-platform for richer error diagnostics (category, code, trace_id) on spans - Propagate error category and detail to parent graph state for termination messaging - Bump uipath upper bound to <2.13.0 and uipath-platform to 0.1.90 - Add comprehensive unit tests for datafabric_subgraph (98% coverage) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a986c45 commit d9d4896

4 files changed

Lines changed: 2800 additions & 1960 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ description = "Python SDK that enables developers to build and deploy LangGraph
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath>=2.11.14, <2.12.0",
8+
"uipath>=2.11.14, <2.13.0",
99
"uipath-core>=0.5.20, <0.6.0",
10-
"uipath-platform>=0.1.86, <0.2.0",
10+
"uipath-platform>=0.1.90, <0.2.0",
1111
"uipath-runtime>=0.11.4, <0.12.0",
1212
"langgraph>=1.1.8, <2.0.0",
1313
"langchain-core>=1.2.27, <2.0.0",

src/uipath_langchain/agent/tools/datafabric_tool/datafabric_subgraph.py

Lines changed: 159 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
import asyncio
1515
import logging
16-
from typing import Annotated, Any
16+
from contextlib import contextmanager
17+
from typing import Annotated, Any, Iterator
1718

1819
from langchain_core.language_models import BaseChatModel
1920
from langchain_core.messages import (
@@ -30,12 +31,19 @@
3031
from langgraph.graph.state import CompiledStateGraph
3132
from pydantic import BaseModel
3233
from uipath.platform.entities import EntitiesService, Entity
34+
from uipath.platform.errors import DataFabricError, EnrichedException
3335

3436
from ..datafabric_query_tool import DataFabricQueryTool
3537
from . import datafabric_prompt_builder
3638
from .models import DataFabricExecuteSqlInput
3739

3840
logger = logging.getLogger(__name__)
41+
CATEGORY_MARKER = "(category: "
42+
43+
44+
@contextmanager
45+
def _noop_context() -> Iterator[None]:
46+
yield None
3947

4048

4149
class DataFabricSubgraphState(BaseModel):
@@ -44,33 +52,124 @@ class DataFabricSubgraphState(BaseModel):
4452
messages: Annotated[list[AnyMessage], add_messages] = []
4553
iteration_count: int = 0
4654
last_tool_success: bool = False
55+
last_error_category: str = ""
56+
last_error_detail: str = ""
4757

4858

4959
class QueryExecutor:
5060
"""Executes SQL queries against Data Fabric."""
5161

52-
def __init__(self, entities_service: EntitiesService) -> None:
62+
def __init__(
63+
self, entities_service: EntitiesService, entities: list[Entity]
64+
) -> None:
5365
self._entities = entities_service
66+
native = [e.name for e in entities if not e.external_fields]
67+
federated = [e.name for e in entities if e.external_fields]
68+
self._entity_attrs: dict[str, str | int] = {
69+
"df.entity_count": len(entities),
70+
"df.native_entity_count": len(native),
71+
"df.federated_entity_count": len(federated),
72+
"df.native_entities": ", ".join(native) if native else "",
73+
"df.federated_entities": ", ".join(federated) if federated else "",
74+
}
5475

5576
async def __call__(self, sql_query: str) -> dict[str, Any]:
5677
logger.debug("execute_sql called with SQL: %s", sql_query)
78+
5779
try:
58-
records = await self._entities.query_entity_records_async(
59-
sql_query=sql_query,
80+
from opentelemetry import trace as otel_trace
81+
82+
tracer = otel_trace.get_tracer("uipath_langchain.datafabric")
83+
except ImportError:
84+
tracer = None
85+
86+
span_ctx = (
87+
tracer.start_as_current_span(
88+
"Data Fabric SQL query",
89+
attributes={
90+
"openinference.span.kind": "TOOL",
91+
"span_type": "datafabricQuery",
92+
"uipath.custom_instrumentation": True,
93+
"df.sql_query": sql_query,
94+
**self._entity_attrs,
95+
},
6096
)
61-
return {
62-
"records": records,
63-
"total_count": len(records),
64-
"sql_query": sql_query,
65-
}
66-
except Exception as e:
67-
logger.error("SQL query failed: %s", e)
68-
return {
69-
"records": [],
70-
"total_count": 0,
71-
"error": str(e),
72-
"sql_query": sql_query,
73-
}
97+
if tracer
98+
else _noop_context()
99+
)
100+
101+
with span_ctx as span:
102+
try:
103+
records = await self._entities.query_entity_records_async(
104+
sql_query=sql_query,
105+
)
106+
if span is not None:
107+
span.set_attribute("df.record_count", len(records))
108+
span.set_attribute("df.success", True)
109+
return {
110+
"records": records,
111+
"total_count": len(records),
112+
"sql_query": sql_query,
113+
}
114+
except Exception as e:
115+
return self._handle_query_error(e, span, sql_query)
116+
117+
def _handle_query_error(
118+
self, e: Exception, span: Any, sql_query: str
119+
) -> dict[str, Any]:
120+
"""Handle a failed SQL query: log, record span attributes, return error dict."""
121+
logger.error("SQL query failed: %s", e)
122+
123+
df_error = None
124+
if isinstance(e, EnrichedException):
125+
df_error = DataFabricError.from_enriched_exception(e)
126+
127+
if span is not None:
128+
self._record_error_span(span, e, df_error)
129+
130+
return {
131+
"records": [],
132+
"total_count": 0,
133+
"error": self._build_error_detail(e, df_error),
134+
"sql_query": sql_query,
135+
}
136+
137+
@staticmethod
138+
def _record_error_span(
139+
span: Any, e: Exception, df_error: "DataFabricError | None"
140+
) -> None:
141+
"""Set error attributes on an OTEL span."""
142+
span.set_attribute("df.success", False)
143+
span.set_attribute("df.error.raw", str(e)[:500])
144+
if df_error:
145+
if df_error.code:
146+
span.set_attribute("df.error.code", df_error.code)
147+
if df_error.message:
148+
span.set_attribute("df.error.message", df_error.message)
149+
if df_error.trace_id:
150+
span.set_attribute("df.error.trace_id", df_error.trace_id)
151+
span.set_attribute("df.error.category", df_error.category.value)
152+
153+
from opentelemetry.trace import Status, StatusCode
154+
155+
span.record_exception(e)
156+
span.set_status(Status(StatusCode.ERROR, str(e)[:200]))
157+
158+
@staticmethod
159+
def _build_error_detail(exc: Exception, df_error: "DataFabricError | None") -> str:
160+
"""Build a structured error string for the inner LLM."""
161+
if df_error and df_error.code:
162+
parts = [f"[{df_error.code}]"]
163+
if df_error.category.value != "unknown":
164+
parts.append(f"(category: {df_error.category.value})")
165+
if df_error.message:
166+
parts.append(df_error.message)
167+
if df_error.is_retryable:
168+
parts.append("— This error is transient, retry the same query.")
169+
elif df_error.is_bad_sql:
170+
parts.append("— Fix the SQL syntax and retry.")
171+
return " ".join(parts)
172+
return str(exc)
74173

75174

76175
class DataFabricGraph:
@@ -130,16 +229,33 @@ async def tool_node(self, state: DataFabricSubgraphState) -> dict[str, Any]:
130229
results = await asyncio.gather(
131230
*[self._execute_tool_call(tc) for tc in last.tool_calls]
132231
)
133-
tool_messages = [msg for msg, _ in results]
134-
all_succeeded = bool(results) and all(success for _, success in results)
232+
tool_messages = [msg for msg, _, _, _ in results]
233+
all_succeeded = bool(results) and all(ok for _, ok, _, _ in results)
234+
235+
# Capture last error info from the most recent failed call
236+
last_category = ""
237+
last_detail = ""
238+
for _, ok, cat, detail in reversed(results):
239+
if not ok and detail:
240+
last_category = cat
241+
last_detail = detail
242+
break
243+
135244
return {
136245
"messages": tool_messages,
137246
"iteration_count": state.iteration_count + len(last.tool_calls),
138247
"last_tool_success": all_succeeded,
248+
"last_error_category": last_category or state.last_error_category,
249+
"last_error_detail": last_detail or state.last_error_detail,
139250
}
140251

141-
async def _execute_tool_call(self, tool_call: ToolCall) -> tuple[ToolMessage, bool]:
142-
"""Execute a single tool call and report whether it succeeded."""
252+
async def _execute_tool_call(
253+
self, tool_call: ToolCall
254+
) -> tuple[ToolMessage, bool, str, str]:
255+
"""Execute a single tool call and report whether it succeeded.
256+
257+
Returns (message, succeeded, error_category, error_detail).
258+
"""
143259
args = tool_call.get("args", {})
144260
try:
145261
result = await self._execute_sql_tool.ainvoke(args)
@@ -150,33 +266,42 @@ async def _execute_tool_call(self, tool_call: ToolCall) -> tuple[ToolMessage, bo
150266
"error": str(e),
151267
"sql_query": args.get("sql_query", ""),
152268
}
269+
error_str = result.get("error", "") if isinstance(result, dict) else ""
153270
succeeded = (
154271
isinstance(result, dict)
155-
and not result.get("error")
272+
and not error_str
156273
and result.get("total_count", 0) > 0
157274
)
275+
# Extract category from structured error like "[SQL_VALIDATION] (category: bad_sql) ..."
276+
error_category = ""
277+
if error_str and CATEGORY_MARKER in error_str:
278+
start = error_str.index(CATEGORY_MARKER) + len(CATEGORY_MARKER)
279+
end = error_str.find(")", start)
280+
if end != -1:
281+
error_category = error_str[start:end]
158282
return (
159283
ToolMessage(
160284
content=str(result),
161285
tool_call_id=tool_call["id"],
162286
name="execute_sql",
163287
),
164288
succeeded,
289+
error_category,
290+
error_str,
165291
)
166292

167293
async def termination_node(self, state: DataFabricSubgraphState) -> dict[str, Any]:
168294
"""Produce a clear message when max iterations is reached."""
169-
return {
170-
"messages": [
171-
AIMessage(
172-
content=(
173-
"I was unable to resolve the query after "
174-
f"{state.iteration_count} SQL attempts. "
175-
"Please try rephrasing the question or narrowing the scope."
176-
)
177-
)
178-
]
179-
}
295+
parts = [
296+
f"I was unable to resolve the query after "
297+
f"{state.iteration_count} SQL attempts.",
298+
]
299+
if state.last_error_category:
300+
parts.append(f"Last error category: {state.last_error_category}.")
301+
if state.last_error_detail:
302+
parts.append(f"Last error: {state.last_error_detail[:300]}")
303+
parts.append("Please try rephrasing the question or narrowing the scope.")
304+
return {"messages": [AIMessage(content=" ".join(parts))]}
180305

181306
def router(self, state: DataFabricSubgraphState) -> str:
182307
"""Route from ``inner_llm`` to tool, termination, or END."""
@@ -214,7 +339,7 @@ def _create_execute_sql_tool(
214339
"tables and columns. Retry with a corrected query on errors."
215340
),
216341
args_schema=DataFabricExecuteSqlInput,
217-
coroutine=QueryExecutor(entities_service),
342+
coroutine=QueryExecutor(entities_service, entities),
218343
metadata={"tool_type": "datafabric_sql"},
219344
)
220345

0 commit comments

Comments
 (0)