Back to resources
// technical · observability

Trace and score an agent with Langfuse

An agent is several steps and several nested LLM calls. Without traces, you debug blind. Here’s how I instrument every step, measure cost, and score quality — automatically.

TechnicalJul 7, 2026~9 min · intermediate
LangfuseLangGraphLangChainTypeScriptLLM-as-a-judge
01

Why an agent is a black box

A prompt is one call: input, output, easy to read. An agent is a graph — as I tell it in from a prompt to agentic: it gathers context, decides, calls one or more models, reviews itself, loops. When the output is bad, the real question is: which step went wrong?

a prompt

1 call.

one input, one output
a single log is enough to understand
an agent

N nested calls.

multiple steps, loops
cost and latency scattered everywhere
the error can come from anywhere
i

Langfuse is built for this: it records the full tree of a run — every step, every LLM call, with tokens, cost and latency — and lets you attach quality scores to it.

02

The vocabulary, in 4 words

Before instrumenting, four notions are enough to understand everything.

tracea full agent run, end to end
observationa step inside it (a span, or an LLM generation)
generationan LLM call: prompt, response, tokens, cost, model
scorea score attached to a trace (0/1, rating, text)
03

Instrument in three lines

Install the Langfuse SDK and its LangChain integration, then set the keys — those of your instance.

shellcopy
$ npm i langfuse langfuse-langchain @langchain/langgraph @langchain/openai
.env.localcopy
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASEURL=https://langfuse.mondomaine.com # ton instance auto-hebergee

Instrumentation is one object: the langfuse-langchain CallbackHandler passed to graph.invoke links every graph node — and every model call — to a single trace. No decorator to sprinkle everywhere.

route.tscopy
// app/api/agent/route.ts
import { CallbackHandler } from "langfuse-langchain";
// un handler par run — relie chaque noeud LangGraph a une trace
const handler = new CallbackHandler({ sessionId }); // sessionId : optionnel
const result = await graph.invoke(
{ brief },
{ callbacks: [handler] }, // gather -> clarify -> draft -> review -> apply
);
const traceId = handler.getTraceId(); // pour y accrocher des scores ensuite

Every node that calls a ChatOpenAI then shows up as a generation — prompt, response, tokens, cost — with nothing extra.

nodes.tscopy
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({ model: "gpt-4o" });
// un noeud du graphe : l'appel ChatOpenAI est trace comme "generation"
// (prompt, reponse, tokens, cout) sans une ligne de plus
async function draft(state: AgentState) {
const res = await model.invoke(buildPrompt(state));
return { draft: res.content };
}

Result: in Langfuse you see the gather → clarify → draft → review → apply tree, with the latency and cost of each node. The review ↺ draft loop shows up as-is.

// the graph (langgraph)
gather
context
clarify
ask ?
draft
decide
review
self-review
apply
apply
review ↺ draft — loop if not enoughclarify → human-in-the-loop
Postgres — durable stateLangfuse — traced
04

Scoring: three levels

A trace tells you what happened. A score tells you whether it was good. Three sources, from least to most automatic.

Business rule and user feedback first — simple, reliable, free.

scores.tscopy
import { Langfuse } from "langfuse";
const langfuse = new Langfuse();
// 1. score programmatique (regle metier)
langfuse.score({
traceId,
name: "roadmap_valide",
value: plan.milestones.length ? 1 : 0,
comment: "au moins un jalon produit",
});
// 2. feedback utilisateur (pouce) — depuis une route API,
// avec le traceId qu'on a renvoye au front
langfuse.score({
traceId,
name: "user_feedback",
value: 1, // 1 = pouce haut, 0 = pouce bas
dataType: "BOOLEAN",
});

LLM-as-a-judge

For subjective quality (faithfulness, tone, relevance), an LLM-as-a-judge scores another’s output. Trace it too — a judge that drifts is debugged like everything else.

judge.tscopy
import { ChatOpenAI } from "@langchain/openai";
import { z } from "zod";
// un petit modele, en sortie structuree
const judge = new ChatOpenAI({ model: "gpt-4o-mini" }).withStructuredOutput(
z.object({ score: z.number(), reason: z.string() }),
);
async function scoreOutput(question: string, answer: string, traceId: string) {
const v = await judge.invoke(
`Note la reponse de 1 a 5 sur sa fidelite au contexte.
Q: ${question}
R: ${answer}`,
);
langfuse.score({ traceId, name: "fidelite", value: v.score, comment: v.reason });
return v;
}
!

An LLM judge is not ground truth: calibrate it against a few hand-scored examples before trusting it. And keep it small (gpt-4o-mini is enough) — otherwise scoring costs more than producing.

05

Close the loop: datasets & experiments

The real win comes when you replay a frozen set of cases on every change, via Langfuse datasets & experiments. Freeze ~30 representative briefs, and before each deploy re-run the agent on them, scored by the judge. A regression shows up before production, not after.

eval.tscopy
// rejouer un jeu de cas fige AVANT de deployer -> pas de regression
const dataset = await langfuse.getDataset("briefs-de-reference");
for (const item of dataset.items) {
const handler = new CallbackHandler();
const output = await graph.invoke(
{ brief: item.input },
{ callbacks: [handler] },
);
await item.link(handler, "v2-review-loop"); // rattache le cas au run
await scoreOutput(item.input, output, handler.getTraceId());
}
await langfuse.flushAsync(); // vide le buffer avant la fin du process
// -> compare v1 vs v2 dans l'onglet Experiments
compare two versions side by side (v1 vs v2) on the same inputs;
spot the regression at the step level, not just the final result;
turn “it feels better” into a number you can track.
06

In production — the gotchas

PII: prompts go into Langfuse. Mask sensitive data, or self-host Langfuse so nothing leaves.
Volume: at scale, sample traces instead of keeping everything.
Async: the SDK sends in the background (batch) — await flushAsync() before a serverless route returns, otherwise the function freezes before it ships.
i

Keeping traces in-house solves the PII point in one move. That is exactly what my deploy Langfuse on AWS EC2 guide covers — self-hosted, your data stays yours.

07

Anchor traces in reality: sessions, users, versions

An isolated trace answers “did this run work?”. But in production the real question is “did this run work *for this user*, in *this conversation*, on *this version* of the agent?”. Three attributes are enough to go from a heap of anonymous runs to something you can filter and aggregate.

sessionIdgroups the traces of one multi-turn conversation — Langfuse replays the full interaction
userIdties each run to a user: cost, tokens and feedback segment per person
version / releasetags which agent version produced the trace — essential to compare before/after a deploy

With the CallbackHandler there is nothing new to install: you pass these attributes in the invocation `metadata`, and Langfuse lifts them to the trace level. Sessions accept any US-ASCII string under 200 characters as an identifier.

route.tscopy
const handler = new CallbackHandler();
await graph.invoke(
{ brief },
{
callbacks: [handler],
metadata: {
langfuseSessionId: conversationId, // regroupe la conversation / groups the conversation
langfuseUserId: user.id, // segmente par user / segments by user
langfuseTags: ["prod", "agent-v3"],
},
},
);
i

User tracking accepts an email, username or any stable identifier. Careful: a plaintext email ends up in Langfuse — same PII precaution as for prompts (section 06).

The prompt as a versioned object, not a hardcoded string

As long as your prompt lives in the code, changing an instruction means redeploying. Langfuse prompt management takes it out of the code: each edit creates an immutable version (1, 2, 3…), and a movable label (`production`, `staging`) points at the active version. You roll back by reassigning the `production` label to the older version, no code change. The SDK caches the prompt client-side, so it adds zero latency.

The payoff compounds with scoring: when a generation is linked to its prompt version, you compare the judge’s scores *per version*. “Prompt v4 of review dropped faithfulness by 8 points” becomes something the data tells you, not a hunch.

08

Scaling up: OTel, sampling, alerts

Three problems show up as traffic grows: fitting into an existing observability stack, not blowing up on volume, and getting alerted when things go sideways without watching the dashboard all day.

OpenTelemetry: Langfuse as an OTLP backend

If you already run an OpenTelemetry collector, you do not have to choose. Langfuse is OTel-native: it exposes an OTLP endpoint at `/api/public/otel/v1/traces` (HTTP, JSON or protobuf — no gRPC yet). You add Langfuse as an *exporter* on your collector, and spans emitted by any OTel-instrumented library nest automatically into the right trace.

!

Classic gotcha: trace attributes (userId, sessionId, tags, version) must be propagated to *every* span for filtering and aggregation to work on the Langfuse side. One orphan span without userId, and it drops out of your segmentations.

Sampling: keep 20% instead of everything

At high volume, tracing everything costs storage and drowns the signal. Sampling is handled client-side: you set `LANGFUSE_SAMPLE_RATE` (or `sampleRate` in the constructor) between 0 and 1. The decision is made *at the trace level* — if a trace is kept, all its observations and scores are; otherwise nothing ships. No half-sent traces.

.env.localcopy
# Garde 20% des traces en prod haut-volume / keep 20% of traces at high volume
LANGFUSE_SAMPLE_RATE=0.2
i

My rule: sample the high-volume, low-value traffic, but keep 100% of runs carrying negative feedback or a judge score below threshold — those are exactly the ones you want to debug. A global 0.2 rate on the firehose, a dedicated 1.0 handler on flagged cases.

Alerts: stop staring at the dashboard

Nobody watches a dashboard at 3 a.m. Monitors & alerts fire on a threshold — average cost, p95 latency, or a quality aggregate — and notify via Slack, webhook or a GitHub Actions trigger. “p95 latency > 8s” or “average cost per trace > $0.05” ping you before the user complains.

!

One caveat to know: monitors are currently Langfuse Cloud only. Self-hosted, you pull the same numbers via the Metrics API v2 (count, latency, totalCost, totalTokens) and wire your own alerting on top.

09

Sources & further reading

01Langfuse — LangChain & LangGraph tracing (CallbackHandler)The handler that links every graph node to a trace, plus passing sessionId / userId via metadata.02Langfuse — SessionsGrouping the traces of a multi-turn conversation and replaying the full interaction.03Langfuse — Prompt managementTaking prompts out of the code: immutable versions, production/staging labels, rollback with no redeploy.04Langfuse — SamplingLANGFUSE_SAMPLE_RATE, trace-level decision, to control volume at scale.05Langfuse — OpenTelemetry integrationOTLP endpoint /api/public/otel: plug Langfuse in as a backend for an existing OTel stack.06Langfuse — Monitors & alertsAlerts on cost, p95 latency or quality, via Slack, webhook or GitHub Actions (Cloud).07Langfuse — Metrics APIThe v2 API (count, latency, totalCost, totalTokens) to wire your own reporting or alerting when self-hosted.

Once the agent is traced and scored, you stop guessing: you see which step costs, which one fails, and whether your last change improved or broke things. That’s what takes an agent from demo to production system.