Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
8cb3714
·
1 Parent(s): 4bb7623

[KM-631][AI] Langfuse tracing: tokens + latency across the chat pipeline

Browse files

Wire Langfuse (was scaffolded-only) so each chat request emits one trace with
per-call token counts + latency, plus tool spans. Gated and PII-aware.

- New src/observability/langfuse/tracing.py: RequestTracer (one trace/request),
NullTracer (no-op), TracingToolInvoker (metadata-only span per tool call),
_redact MaskFunction. Best-effort: never raises; pipeline unchanged if Langfuse
is down or disabled.
- Each LLM call (Orchestrator, Planner incl. retries, Assembler, Chatbot) gained an
optional `callbacks` param, passed to the LangChain call only when present (so
existing fakes/tests are untouched). Coordinator threads planner/assembler
callbacks through.
- ChatHandler: enable_tracing flag (default OFF so tests never hit Langfuse), creates
one trace per request, wraps the tool invoker for spans, ends the trace.
- PII policy for Cloud: UNMASKED = Orchestrator + Planner (PII-safe inputs);
MASKED (tokens+latency only) = Assembler + Chatbot (see real rows / doc chunks);
tool spans carry counts + status only, never rows.
- chatbot: stream_options include_usage so the streamed answer reports tokens too.
- api/v1/chat.py: activate tracing (ChatHandler(enable_tracing=True)).

Zero added LLM token cost (only reads usage the API returns); negligible latency
(SDK sends in a background thread). Verified live end-to-end against Langfuse Cloud.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/agents/chat_handler.py CHANGED
@@ -70,12 +70,16 @@ class ChatHandler:
70
  Callable[[str], SlowPathCoordinator] | None
71
  ) = None,
72
  analysis_store: AnalysisStore | None = None,
 
73
  ) -> None:
74
  self._intent_router = intent_router
75
  self._answer_agent = answer_agent
76
  self._catalog_reader = catalog_reader
77
  self._query_service = query_service
78
  self._document_retriever = document_retriever
 
 
 
79
  # Slow analytical path (Planner -> TaskRunner -> Assembler). OFF by default:
80
  # gated until the lead's real BusinessContext lands. When True, `structured`
81
  # intents route here instead of the single-query QueryService path. The
@@ -130,9 +134,13 @@ class ChatHandler:
130
  user_id: str,
131
  history: list[BaseMessage] | None = None,
132
  ) -> AsyncIterator[dict[str, Any]]:
 
 
133
  # ---- 1. Classify intent --------------------------------------
134
  try:
135
- decision = await self._get_intent_router().classify(message, history)
 
 
136
  except Exception as e:
137
  logger.error("intent classification failed", error=str(e))
138
  yield {"event": "error", "data": f"Could not classify message: {e}"}
@@ -150,7 +158,9 @@ class ChatHandler:
150
  try:
151
  catalog = await self._get_catalog_reader().read(user_id, "structured")
152
  if self._enable_slow_path:
153
- async for event in self._run_slow_path(user_id, rewritten, catalog):
 
 
154
  yield event
155
  return
156
  query_result = await self._get_query_service().run(
@@ -193,12 +203,16 @@ class ChatHandler:
193
  yield {"event": "sources", "data": json.dumps(sources)}
194
 
195
  # ---- 3. Stream answer ----------------------------------------
 
 
 
196
  try:
197
  async for token in self._get_answer_agent().astream(
198
  message,
199
  history=history,
200
  query_result=query_result,
201
  chunks=chunks,
 
202
  ):
203
  yield {"event": "chunk", "data": token}
204
  except Exception as e:
@@ -206,19 +220,33 @@ class ChatHandler:
206
  yield {"event": "error", "data": f"Answer generation failed: {e}"}
207
  return
208
 
 
209
  yield {"event": "done", "data": ""}
210
 
211
  # ------------------------------------------------------------------
212
  # Slow analytical path (gated, off by default)
213
  # ------------------------------------------------------------------
214
 
215
- def _get_slow_path_coordinator(self, user_id: str) -> SlowPathCoordinator:
 
 
 
 
 
 
 
 
 
 
 
 
216
  """Build the per-request slow-path coordinator (composition root).
217
 
218
  The data-access tools need the authenticated `user_id` + `CatalogReader`,
219
  so the `CompositeToolInvoker` is constructed per request. The slow-path
220
  agent code stays tool-agnostic (INV-7) — only here, the composition root,
221
- do we name concrete tool implementations.
 
222
  """
223
  if self._slow_path_factory is not None:
224
  return self._slow_path_factory(user_id)
@@ -231,10 +259,14 @@ class ChatHandler:
231
  from .slow_path.coordinator import SlowPathCoordinator
232
  from .slow_path.task_runner import TaskRunner
233
 
234
- invoker = CompositeToolInvoker(
235
  DataAccessToolInvoker(user_id, self._get_catalog_reader()),
236
  AnalyticsToolInvoker(),
237
  )
 
 
 
 
238
  registry = default_registry()
239
  return SlowPathCoordinator(
240
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
@@ -252,6 +284,7 @@ class ChatHandler:
252
  user_id: str,
253
  query: str,
254
  catalog: Any,
 
255
  ) -> AsyncIterator[dict[str, Any]]:
256
  """Run the slow path and stream its assembled answer as SSE events.
257
 
@@ -263,10 +296,22 @@ class ChatHandler:
263
  from .planner.business_context import get_business_context
264
  from .planner.inputs import Constraints
265
 
266
- coordinator = self._get_slow_path_coordinator(user_id)
 
 
 
 
 
267
  context = await get_business_context(user_id)
 
 
 
 
 
 
 
268
  try:
269
- result = await coordinator.run(context, catalog, query, Constraints())
270
  except Exception as e:
271
  logger.error("slow path failed", user_id=user_id, error=str(e))
272
  yield {"event": "error", "data": f"Analysis failed: {e}"}
@@ -278,6 +323,7 @@ class ChatHandler:
278
  await self._get_analysis_store().save(result.analysis_record)
279
  except Exception as e: # persistence must never break the user's answer
280
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
 
281
  yield {"event": "done", "data": ""}
282
 
283
 
 
70
  Callable[[str], SlowPathCoordinator] | None
71
  ) = None,
72
  analysis_store: AnalysisStore | None = None,
73
+ enable_tracing: bool = False,
74
  ) -> None:
75
  self._intent_router = intent_router
76
  self._answer_agent = answer_agent
77
  self._catalog_reader = catalog_reader
78
  self._query_service = query_service
79
  self._document_retriever = document_retriever
80
+ # Langfuse tracing (tokens + latency). OFF by default so tests never hit
81
+ # Langfuse; the live endpoint opts in with ChatHandler(enable_tracing=True).
82
+ self._enable_tracing = enable_tracing
83
  # Slow analytical path (Planner -> TaskRunner -> Assembler). OFF by default:
84
  # gated until the lead's real BusinessContext lands. When True, `structured`
85
  # intents route here instead of the single-query QueryService path. The
 
134
  user_id: str,
135
  history: list[BaseMessage] | None = None,
136
  ) -> AsyncIterator[dict[str, Any]]:
137
+ tracer = self._make_tracer(user_id, message)
138
+
139
  # ---- 1. Classify intent --------------------------------------
140
  try:
141
+ oc = tracer.callbacks() # orchestrator: PII-safe, full capture
142
+ ckw = {"callbacks": oc} if oc else {}
143
+ decision = await self._get_intent_router().classify(message, history, **ckw)
144
  except Exception as e:
145
  logger.error("intent classification failed", error=str(e))
146
  yield {"event": "error", "data": f"Could not classify message: {e}"}
 
158
  try:
159
  catalog = await self._get_catalog_reader().read(user_id, "structured")
160
  if self._enable_slow_path:
161
+ async for event in self._run_slow_path(
162
+ user_id, rewritten, catalog, tracer
163
+ ):
164
  yield event
165
  return
166
  query_result = await self._get_query_service().run(
 
203
  yield {"event": "sources", "data": json.dumps(sources)}
204
 
205
  # ---- 3. Stream answer ----------------------------------------
206
+ # masked: the answer call sees real query rows / doc chunks (possible PII).
207
+ mc = tracer.callbacks(masked=True)
208
+ akw = {"callbacks": mc} if mc else {}
209
  try:
210
  async for token in self._get_answer_agent().astream(
211
  message,
212
  history=history,
213
  query_result=query_result,
214
  chunks=chunks,
215
+ **akw,
216
  ):
217
  yield {"event": "chunk", "data": token}
218
  except Exception as e:
 
220
  yield {"event": "error", "data": f"Answer generation failed: {e}"}
221
  return
222
 
223
+ tracer.end()
224
  yield {"event": "done", "data": ""}
225
 
226
  # ------------------------------------------------------------------
227
  # Slow analytical path (gated, off by default)
228
  # ------------------------------------------------------------------
229
 
230
+ def _make_tracer(self, user_id: str, question: str) -> Any:
231
+ """One Langfuse trace per request (or a NullTracer when disabled)."""
232
+ if not self._enable_tracing:
233
+ from ..observability.langfuse.tracing import NullTracer
234
+
235
+ return NullTracer()
236
+ from ..observability.langfuse.tracing import RequestTracer
237
+
238
+ return RequestTracer.start(user_id=user_id, question=question)
239
+
240
+ def _get_slow_path_coordinator(
241
+ self, user_id: str, tracer: Any = None
242
+ ) -> SlowPathCoordinator:
243
  """Build the per-request slow-path coordinator (composition root).
244
 
245
  The data-access tools need the authenticated `user_id` + `CatalogReader`,
246
  so the `CompositeToolInvoker` is constructed per request. The slow-path
247
  agent code stays tool-agnostic (INV-7) — only here, the composition root,
248
+ do we name concrete tool implementations. When tracing is active the invoker
249
+ is wrapped so each tool call records a metadata-only span.
250
  """
251
  if self._slow_path_factory is not None:
252
  return self._slow_path_factory(user_id)
 
259
  from .slow_path.coordinator import SlowPathCoordinator
260
  from .slow_path.task_runner import TaskRunner
261
 
262
+ invoker: Any = CompositeToolInvoker(
263
  DataAccessToolInvoker(user_id, self._get_catalog_reader()),
264
  AnalyticsToolInvoker(),
265
  )
266
+ if tracer is not None and getattr(tracer, "active", False):
267
+ from ..observability.langfuse.tracing import TracingToolInvoker
268
+
269
+ invoker = TracingToolInvoker(invoker, tracer)
270
  registry = default_registry()
271
  return SlowPathCoordinator(
272
  PlannerService(), TaskRunner(invoker, registry), Assembler(), registry
 
284
  user_id: str,
285
  query: str,
286
  catalog: Any,
287
+ tracer: Any = None,
288
  ) -> AsyncIterator[dict[str, Any]]:
289
  """Run the slow path and stream its assembled answer as SSE events.
290
 
 
296
  from .planner.business_context import get_business_context
297
  from .planner.inputs import Constraints
298
 
299
+ if tracer is None:
300
+ from ..observability.langfuse.tracing import NullTracer
301
+
302
+ tracer = NullTracer()
303
+
304
+ coordinator = self._get_slow_path_coordinator(user_id, tracer)
305
  context = await get_business_context(user_id)
306
+ pc = tracer.callbacks() # planner: PII-safe, full capture
307
+ ac = tracer.callbacks(masked=True) # assembler: sees real rows -> masked
308
+ run_kw: dict[str, Any] = {}
309
+ if pc:
310
+ run_kw["planner_callbacks"] = pc
311
+ if ac:
312
+ run_kw["assembler_callbacks"] = ac
313
  try:
314
+ result = await coordinator.run(context, catalog, query, Constraints(), **run_kw)
315
  except Exception as e:
316
  logger.error("slow path failed", user_id=user_id, error=str(e))
317
  yield {"event": "error", "data": f"Analysis failed: {e}"}
 
323
  await self._get_analysis_store().save(result.analysis_record)
324
  except Exception as e: # persistence must never break the user's answer
325
  logger.error("analysis_record persist failed", user_id=user_id, error=str(e))
326
+ tracer.end() # output omitted (chat_answer may contain PII on Cloud)
327
  yield {"event": "done", "data": ""}
328
 
329
 
src/agents/chatbot.py CHANGED
@@ -119,6 +119,9 @@ def _build_default_chain() -> Runnable:
119
  azure_endpoint=settings.azureai_endpoint_url_4o,
120
  api_key=settings.azureai_api_key_4o,
121
  temperature=0.3,
 
 
 
122
  )
123
  prompt = ChatPromptTemplate.from_messages(
124
  [
@@ -153,6 +156,7 @@ class ChatbotAgent:
153
  history: list[BaseMessage] | None = None,
154
  query_result: QueryResult | None = None,
155
  chunks: list[DocumentChunk] | None = None,
 
156
  ) -> AsyncIterator[str]:
157
  """Stream tokens of the final answer.
158
 
@@ -165,5 +169,9 @@ class ChatbotAgent:
165
  "history": history or [],
166
  "context": _build_context_block(query_result, chunks),
167
  }
168
- async for token in chain.astream(payload):
169
- yield token
 
 
 
 
 
119
  azure_endpoint=settings.azureai_endpoint_url_4o,
120
  api_key=settings.azureai_api_key_4o,
121
  temperature=0.3,
122
+ # Emit token usage on the final streamed chunk (this agent only streams), so
123
+ # the fast-path answer reports tokens to Langfuse like the non-streaming calls.
124
+ model_kwargs={"stream_options": {"include_usage": True}},
125
  )
126
  prompt = ChatPromptTemplate.from_messages(
127
  [
 
156
  history: list[BaseMessage] | None = None,
157
  query_result: QueryResult | None = None,
158
  chunks: list[DocumentChunk] | None = None,
159
+ callbacks: list | None = None,
160
  ) -> AsyncIterator[str]:
161
  """Stream tokens of the final answer.
162
 
 
169
  "history": history or [],
170
  "context": _build_context_block(query_result, chunks),
171
  }
172
+ if callbacks:
173
+ async for token in chain.astream(payload, config={"callbacks": callbacks}):
174
+ yield token
175
+ else:
176
+ async for token in chain.astream(payload):
177
+ yield token
src/agents/orchestration.py CHANGED
@@ -96,11 +96,16 @@ class OrchestratorAgent:
96
  self,
97
  message: str,
98
  history: list[BaseMessage] | None = None,
 
99
  ) -> IntentRouterDecision:
100
  chain = self._ensure_chain()
101
- decision: IntentRouterDecision = await chain.ainvoke(
102
- {"message": message, "history": history or []}
103
- )
 
 
 
 
104
  logger.info(
105
  "intent classified",
106
  source_hint=decision.source_hint,
 
96
  self,
97
  message: str,
98
  history: list[BaseMessage] | None = None,
99
+ callbacks: list | None = None,
100
  ) -> IntentRouterDecision:
101
  chain = self._ensure_chain()
102
+ payload = {"message": message, "history": history or []}
103
+ if callbacks:
104
+ decision: IntentRouterDecision = await chain.ainvoke(
105
+ payload, config={"callbacks": callbacks}
106
+ )
107
+ else:
108
+ decision = await chain.ainvoke(payload)
109
  logger.info(
110
  "intent classified",
111
  source_hint=decision.source_hint,
src/agents/planner/service.py CHANGED
@@ -101,6 +101,7 @@ class PlannerService:
101
  tools: ToolRegistry,
102
  query: str,
103
  constraints: Constraints,
 
104
  ) -> TaskList:
105
  summary = CatalogSummary.from_catalog(catalog)
106
  chain = self._ensure_chain()
@@ -110,7 +111,14 @@ class PlannerService:
110
  human_content = build_planner_prompt(
111
  context, summary, tools, query, constraints, previous_error
112
  )
113
- task_list: TaskList = await chain.ainvoke({"human_content": human_content})
 
 
 
 
 
 
 
114
  try:
115
  self._validator.validate(task_list, tools, catalog, constraints)
116
  except PlannerValidationError as e:
 
101
  tools: ToolRegistry,
102
  query: str,
103
  constraints: Constraints,
104
+ callbacks: list | None = None,
105
  ) -> TaskList:
106
  summary = CatalogSummary.from_catalog(catalog)
107
  chain = self._ensure_chain()
 
111
  human_content = build_planner_prompt(
112
  context, summary, tools, query, constraints, previous_error
113
  )
114
+ # All retry attempts share `callbacks`, so each shows up under the same
115
+ # trace — that is how retry token cost becomes visible.
116
+ if callbacks:
117
+ task_list: TaskList = await chain.ainvoke(
118
+ {"human_content": human_content}, config={"callbacks": callbacks}
119
+ )
120
+ else:
121
+ task_list = await chain.ainvoke({"human_content": human_content})
122
  try:
123
  self._validator.validate(task_list, tools, catalog, constraints)
124
  except PlannerValidationError as e:
src/agents/slow_path/assembler.py CHANGED
@@ -92,13 +92,17 @@ class Assembler:
92
  run_state: RunState,
93
  context: BusinessContext,
94
  question: str | None = None,
 
95
  ) -> AssembledOutput:
96
  chain = self._ensure_chain()
97
  human_content = build_assembler_prompt(run_state, context, question)
98
  try:
99
- narrative: AssemblerNarrative = await chain.ainvoke(
100
- {"human_content": human_content}
101
- )
 
 
 
102
  except Exception as exc: # surface as a typed error for the caller
103
  raise AssemblerError(f"assembler call failed: {exc}") from exc
104
 
 
92
  run_state: RunState,
93
  context: BusinessContext,
94
  question: str | None = None,
95
+ callbacks: list | None = None,
96
  ) -> AssembledOutput:
97
  chain = self._ensure_chain()
98
  human_content = build_assembler_prompt(run_state, context, question)
99
  try:
100
+ if callbacks:
101
+ narrative: AssemblerNarrative = await chain.ainvoke(
102
+ {"human_content": human_content}, config={"callbacks": callbacks}
103
+ )
104
+ else:
105
+ narrative = await chain.ainvoke({"human_content": human_content})
106
  except Exception as exc: # surface as a typed error for the caller
107
  raise AssemblerError(f"assembler call failed: {exc}") from exc
108
 
src/agents/slow_path/coordinator.py CHANGED
@@ -38,11 +38,17 @@ class SlowPathCoordinator:
38
  catalog: Catalog,
39
  query: str,
40
  constraints: Constraints,
 
 
41
  ) -> AssembledOutput:
 
42
  task_list = await self._planner.plan(
43
- context, catalog, self._registry, query, constraints
44
  )
45
  run_state = await self._task_runner.run(
46
  task_list, business_context_id=context.project_id
47
  )
48
- return await self._assembler.assemble(run_state, context, question=query)
 
 
 
 
38
  catalog: Catalog,
39
  query: str,
40
  constraints: Constraints,
41
+ planner_callbacks: list | None = None,
42
+ assembler_callbacks: list | None = None,
43
  ) -> AssembledOutput:
44
+ plan_kw = {"callbacks": planner_callbacks} if planner_callbacks else {}
45
  task_list = await self._planner.plan(
46
+ context, catalog, self._registry, query, constraints, **plan_kw
47
  )
48
  run_state = await self._task_runner.run(
49
  task_list, business_context_id=context.project_id
50
  )
51
+ asm_kw = {"callbacks": assembler_callbacks} if assembler_callbacks else {}
52
+ return await self._assembler.assemble(
53
+ run_state, context, question=query, **asm_kw
54
+ )
src/api/v1/chat.py CHANGED
@@ -169,7 +169,7 @@ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
169
  return EventSourceResponse(stream_direct())
170
 
171
  history = await load_history(db, request.room_id, limit=10)
172
- handler = ChatHandler()
173
 
174
  async def stream_response():
175
  logger.info("stream_response started", room_id=request.room_id, user_id=request.user_id)
 
169
  return EventSourceResponse(stream_direct())
170
 
171
  history = await load_history(db, request.room_id, limit=10)
172
+ handler = ChatHandler(enable_tracing=True)
173
 
174
  async def stream_response():
175
  logger.info("stream_response started", room_id=request.room_id, user_id=request.user_id)
src/observability/langfuse/tracing.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Langfuse request tracing — tokens + latency for the chat pipeline.
2
+
3
+ One Langfuse trace per chat request. LangChain LLM calls attach a `CallbackHandler`
4
+ (auto-captures prompt/completion tokens + latency); deterministic tool calls are
5
+ recorded as metadata-only spans.
6
+
7
+ PII policy for Langfuse **Cloud** (data leaves to Langfuse's servers):
8
+ - UNMASKED (full input/output): **Orchestrator + Planner** — their inputs are the
9
+ user question and a PII-safe `CatalogSummary` (sample values stripped by design).
10
+ - MASKED (tokens + latency only; input/output redacted): **Assembler + Chatbot** —
11
+ their inputs carry real query rows / document chunks that may contain PII.
12
+ - Tool spans carry only metadata (tool name, output kind, row COUNT, status) —
13
+ never the rows themselves.
14
+
15
+ Everything here is best-effort and **never raises**: if Langfuse is unreachable or
16
+ disabled, the chat pipeline runs unchanged. Tracing is created only when the caller
17
+ opts in (ChatHandler(enable_tracing=True)); otherwise a `NullTracer` is used.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import functools
24
+ import time
25
+ from typing import Any
26
+
27
+ from src.config.settings import settings
28
+ from src.middlewares.logging import get_logger
29
+
30
+ logger = get_logger("tracing")
31
+
32
+
33
+ def _redact(*, data: Any) -> Any:
34
+ """Langfuse MaskFunction: drop the value entirely (used for PII-bearing calls)."""
35
+ return "<redacted: omitted from Langfuse (may contain user data)>"
36
+
37
+
38
+ @functools.cache
39
+ def _client() -> Any:
40
+ from langfuse import Langfuse
41
+
42
+ return Langfuse(
43
+ public_key=settings.LANGFUSE_PUBLIC_KEY,
44
+ secret_key=settings.LANGFUSE_SECRET_KEY,
45
+ host=settings.LANGFUSE_HOST,
46
+ )
47
+
48
+
49
+ class _NullSpan:
50
+ def end(self, _out: Any) -> None: ...
51
+
52
+
53
+ class NullTracer:
54
+ """No-op tracer (tracing disabled). Same surface as RequestTracer."""
55
+
56
+ active = False
57
+
58
+ def callbacks(self, *, masked: bool = False) -> list:
59
+ return []
60
+
61
+ def tool_span(self, tool: str, args: dict) -> Any:
62
+ return _NullSpan()
63
+
64
+ def end(self, *, output: Any = None) -> None: ...
65
+
66
+
67
+ class _ToolSpan:
68
+ """A metadata-only span around one tool call. Never records row data."""
69
+
70
+ def __init__(self, trace: Any, tool: str, args: dict) -> None:
71
+ self._t0 = time.perf_counter()
72
+ self._span = trace.span(
73
+ name=f"tool:{tool}",
74
+ metadata={"tool": tool, "arg_keys": sorted(args)}, # keys only, no values
75
+ )
76
+
77
+ def end(self, out: Any) -> None:
78
+ with contextlib.suppress(Exception): # never let a span break the run
79
+ kind = getattr(out, "kind", None)
80
+ is_err = kind == "error"
81
+ meta: dict[str, Any] = {
82
+ "kind": kind,
83
+ "elapsed_ms": round((time.perf_counter() - self._t0) * 1000),
84
+ }
85
+ if kind == "table":
86
+ meta["rows"] = len(getattr(out, "rows", None) or [])
87
+ err_msg = (getattr(out, "error", None) or "")[:300] if is_err else None
88
+ if err_msg:
89
+ meta["error"] = err_msg
90
+ self._span.end(
91
+ metadata=meta,
92
+ level="ERROR" if is_err else "DEFAULT",
93
+ status_message=err_msg,
94
+ )
95
+
96
+
97
+ class RequestTracer:
98
+ """One Langfuse trace per chat request; hands out callbacks + tool spans."""
99
+
100
+ active = True
101
+
102
+ def __init__(self, trace: Any) -> None:
103
+ self._trace = trace
104
+
105
+ @classmethod
106
+ def start(
107
+ cls,
108
+ *,
109
+ user_id: str,
110
+ question: str | None = None,
111
+ session_id: str | None = None,
112
+ ) -> RequestTracer | NullTracer:
113
+ try:
114
+ trace = _client().trace(
115
+ name="chat_request",
116
+ user_id=user_id,
117
+ session_id=session_id,
118
+ input=question, # the user's question (same exposure as Planner prompt)
119
+ )
120
+ return cls(trace)
121
+ except Exception as e: # never let tracing break the request
122
+ logger.warning("tracing disabled (init failed)", error=str(e))
123
+ return NullTracer()
124
+
125
+ def callbacks(self, *, masked: bool = False) -> list:
126
+ """A LangChain callback nested under this trace. `masked=True` redacts the
127
+ call's input/output (tokens + latency are still captured)."""
128
+ try:
129
+ from langfuse.callback import CallbackHandler
130
+
131
+ return [
132
+ CallbackHandler(
133
+ stateful_client=self._trace,
134
+ mask=_redact if masked else None,
135
+ )
136
+ ]
137
+ except Exception as e:
138
+ logger.warning("tracing handler unavailable", error=str(e))
139
+ return []
140
+
141
+ def tool_span(self, tool: str, args: dict) -> Any:
142
+ try:
143
+ return _ToolSpan(self._trace, tool, args)
144
+ except Exception:
145
+ return _NullSpan()
146
+
147
+ def end(self, *, output: Any = None) -> None:
148
+ # Note: callers pass output=None on PII-bearing paths so no answer text is sent.
149
+ with contextlib.suppress(Exception):
150
+ if output is not None:
151
+ self._trace.update(output=output)
152
+
153
+
154
+ class TracingToolInvoker:
155
+ """Wraps a ToolInvoker to record a metadata-only span per tool call.
156
+
157
+ Implements the ToolInvoker protocol; created at the composition root (ChatHandler)
158
+ so the slow-path agent code stays tool-agnostic and tracing-agnostic.
159
+ """
160
+
161
+ def __init__(self, inner: Any, tracer: RequestTracer) -> None:
162
+ self._inner = inner
163
+ self._tracer = tracer
164
+
165
+ async def invoke(self, tool_name: str, args: dict[str, Any]) -> Any:
166
+ span = self._tracer.tool_span(tool_name, args)
167
+ out = await self._inner.invoke(tool_name, args)
168
+ span.end(out)
169
+ return out