In previous posts, I showed how DSPy classifies intent and how the custom embedding-based router decides the macro-intent. The two have one thing in common: they can degrade silently.
DSPy degrades when the provider updates the model. The semantic router degrades when users' vocabulary changes. Neither throws an exception when this happens. You find out through response quality—or a user complaint.
Observability changes that situation. This post is about instrumenting a LangGraph graph with Langfuse to trace exactly where each decision happened and detect degradation before it reaches the user.
What structured logs do not solve#
A structured logger covers what happened inside each node. It does not answer questions such as:
Which scopes did DSPy route to in the last 500 queries? What is the distribution?
Has the fallback rate to geral increased in the last 30 minutes?
How many responses did
critique_nodeintervene in today? For which type of violation?How many requests required more than one attempt in
dspy.Refine?
These questions require an aggregated view across multiple executions, not the log of an individual execution. That is where Langfuse comes in.
Basic instrumentation: @observe on the nodes#
The simplest entry point is the @observe decorator on graph nodes:
Enlarge ↗capture_input=False and capture_output=False are not paranoia. In any system with user data, capturing the full graph state in Langfuse means capturing conversation history, session identifiers, and potentially personal data. What you want in the trace is not the state but decision metadata.
update_trace_metadata: injecting context into the active trace#
Inside any node, update_trace_metadata accumulates metadata in the current request's trace:
Enlarge ↗Each node contributes its metadata to the same trace. In Langfuse, you see the complete trace of a request with all those fields aggregated, without having to cross-reference logs from different workers.
Tags and user_id: segmentation without personal data#
In the supervisor, when assembling the graph configuration:
Enlarge ↗langfuse_user_id is an internal identifier, never a CPF, email address, or phone number. Tags let you filter traces by channel, environment, and degraded mode in the dashboard without exposing personal data.
RouterMetricsCollector: in-process metrics with a Redis backend#
Langfuse traces individual executions. For real-time aggregated metrics—fallback rate, scope distribution, and anaphora hits—the system has its own collector:
Enlarge ↗The Redis backend uses HINCRBY, compatible with multi-worker deployments in which each process has its own in-process counter, but all write to the same HASH:
Enlarge ↗The increment is fire-and-forget: it does not block router_node's hot path. If Redis is unavailable, the in-process counter still works and the fallback alert still fires.
What to monitor to detect DSPy degradation#
In router_node, after each inference:
Enlarge ↗The three most important indicators:
Fallback rate to geral: when DSPy cannot classify the query, it returns scope="geral". A rate above 25% is a sign that the compiled demos are out of date relative to users' vocabulary. Fix: expand the dataset and recompile.
Resolved-anaphora rate: if
anaphora_hit_count/total drops abruptly, the anaphora resolver's regex patterns have stopped matching users' messages. Fix: review the patterns.Forced-coercion rate: if
coercion_fallback_countrises, the LLM has started returning output formats that the coercion layer cannot parse. Fix: inspectdspy.inspect_history()and review the Signature.
What Langfuse shows that logs do not#
With update_trace_metadata in every node, Langfuse aggregates the following in a single trace:
Enlarge ↗In 5 seconds of execution, you know: the anaphora was resolved, DSPy routed to catalog search, Refine needed 2 attempts, and critique injected a missing disclaimer.
Without that trace, you have 5 logs in different files, with no direct correlation and no timeline.
Important points:#
capture_input=False on every span that touches user messages.capture_output=False on every span that returns graph state.What goes to Langfuse is decision metadata—scope, selected tool, latency, and compliance flags—not the data itself.
User data belongs in the legal archive. Decision metadata belongs in the observability system. Mixing the two creates privacy problems and inflates Langfuse costs without adding debugging value.
Next week: how the generation module uses runtime self-correction with a reward function, and the latency trade-off this creates.