Your Traces Already Know What Broke. Your Agent Doesn’t.
Langfuse records what went wrong. Memanto remembers the lesson. Inside the new langfuse-memanto integration: why 812 observations collapse into 2 memories, why grouping by error signature is the entire trick, and what a span processor can and cannot see.
Every team running agents in production eventually installs observability, and every one of them ends up with the same quietly absurd arrangement: a system that records, in perfect detail, exactly how the agent failed — and an agent that will never read a word of it.
Langfuse is very good at its job. Traces, spans, evaluation scores, latency distributions, per-call cost. When something breaks at 3am, the answer is in there. But the audience for that data is a human with a dashboard open. The agent that caused the failure has no idea any of it exists. Tomorrow it wakes up, plans from scratch, and walks into the same wall — while the dashboard faithfully records that it happened again.
That gap is the whole reason `langfuse-memanto` exists, shipped in Memanto v0.2.14. Langfuse records what went wrong. Memanto remembers the lesson. This post is about the design decisions in between, because the naive version of this integration is a trap — and the trap is instructive.
The obvious version of this integration is a disaster
The tempting design is a firehose: subscribe to traces, write each failing one as a memory, done. It takes an afternoon and it destroys your memory store.
Memanto performs no deduplication on write — deliberately, because dedup-on-write means an LLM call on every save and a guess about what counts as "the same." So a firehose means one bad deploy writes four thousand near-identical memories overnight. Recall stops working, not because retrieval got worse, but because every query now returns forty variations of the same sentence and the one genuinely useful memory from last month is buried under them. You didn't give your agent a memory of the incident. You gave it a landfill with the incident somewhere inside.
The volume asymmetry is the core problem, and it is severe: observability is high-frequency by design, memory is high-signal by design. Anything that connects them has to be a reducer, not a pipe.
“A thousand occurrences of one failure is not a thousand lessons. It is one lesson, learned harder.
One memory per signature, not per occurrence
So the integration groups before it writes. Every observation is reduced to an error signature: the operation name, plus the message with its volatile parts — ids, numbers, emails, IPs, file paths, quoted strings — normalized away. Everything sharing a signature is one memory.
textLangfuse observations (thousands)
│ filter level=ERROR, your score rules, your latency/cost budgets
│ group signature = operation + normalized message
│ reconcile new → write · changed → update in place · same → skip
▼
Memanto (dozens)Here is a real run against a live project. 812 observations became 2 memories:
textNamespace <str> not found in generate-response error confidence 0.60
Slow: generate-response (anthropic.claude-sonnet-4-6) observationThe <str> in that title is not a bug and not a truncation — it is the group's identity. The specific namespace that was missing changed 400 times; the fact that this operation fails on missing namespaces did not. The placeholder is what lets the variations collapse.
Occurrence count is not thrown away either — it becomes confidence. The formula is min(0.95, 0.60 + 0.15 × log₁₀(occurrences)), so a one-off scores 0.60, a hundred occurrences scores 0.90, and nothing ever reaches certainty. That curve is the point: seeing a failure once is weak evidence, seeing it a hundred times is strong evidence, and the difference between the hundredth and the thousandth occurrence is not worth much. A recurring failure does not write a second memory — it updates the existing one in place with the new count, the new last-seen timestamp, and the higher confidence.
And the whole reduction is rule-based. Regex normalization, a hash, a log curve. No LLM calls, no token cost, no nondeterminism in the thing that decides what your agent believes. An integration that summarizes your errors with a model is an integration that hallucinates into your memory store at scale.
Two ways in, one ledger
There are two paths into Memanto, and they exist because they see genuinely different things.
| Live SDK handler | CLI sync | |
|---|---|---|
| Setup | pip install langfuse-memanto + one line | Already in memanto |
| Latency | Seconds | When you run it |
| Requires | langfuse>=3 in your app | Nothing — reads the Langfuse API |
| Captures | Errors, latency | Everything, including scores and cost |
Most teams run both: the handler for instant error capture, and a periodic sync for the signals that only exist server-side. That is safe because both paths write through the same ledger (~/.memanto/migrate/langfuse/state.json), scoped by Langfuse project and destination agent. Whichever gets there first writes; the other sees it as already stored.
That scoping detail is worth a sentence. A signature written to agent A tells you nothing about whether agent B received it, and two unrelated projects can produce byte-identical signatures for completely different faults. Scope the ledger too loosely and the sync confidently skips a write the destination never got — the worst kind of bug, because it looks like success.
Path 1 — the CLI sync, starting with a look at your own data
No app changes, works with any Langfuse version including v2. And it starts by refusing to do anything:
bashmemanto migrate langfuse --discovertext Name Type Count Observed Suggested rule
rating NUMERIC 60 1.0 … 5.0 --score-fail 'rating<3.4'
user-thumbs BOOLEAN 3 0.0 … 1.0 --score-fail 'user-thumbs=false'
Operation Count p50 ms p95 ms p99 ms cost p95
generate-response 300 2000 2000 40000 $0.002409Discovery writes nothing. It reports what is actually in your project: your score names, their real observed ranges, your latency spread per operation. This exists because Langfuse scores are user-defined in name, type, and range — and the Langfuse docs specify no convention for whether a higher score is better. toxicity: 0.9 and correctness: 0.9 are opposite outcomes with identical shapes.
So the integration does not guess the direction. You state it:
bashmemanto migrate langfuse \
--capture errors,slow,low-score \
--score-fail 'correctness<0.7' \
--score-fail 'toxicity>0.3' \
--score-fail 'thumbs_up=false' \
--latency-percentile 95 \
--saveOperators are < <= > >= = != and in, so categorical scores work too (--score-fail 'tone in rude,evasive'). --save stores the rules per Langfuse project in ~/.memanto/migrate/langfuse/config.json, which means a platform team can set them centrally once and every service just points at them.
Then preview and run:
bashmemanto migrate langfuse --dry-run
memanto migrate langfuseRun it a second time and you should see New: 0 · Unchanged: N. That is not a no-op you should worry about — that is the ledger doing exactly its job. Idempotence is what makes this safe to put on a cron.
If you would rather click than type, memanto ui → Migrate → Langfuse gives you the same thing with checkboxes and threshold fields. It reads and writes the same config and the same ledger as the CLI, so the two are never out of step.
Path 2 — two lines in your app
The live handler is the part I find most satisfying, because of how little it asks for. Langfuse's Python SDK (v3+) is built on OpenTelemetry and attaches its span processor to the global TracerProvider. So this package attaches a second one:
textyour app ──▶ Langfuse SDK ──▶ OTel TracerProvider ──┬──▶ LangfuseSpanProcessor ──▶ Langfuse
└──▶ MemantoLangfuseHandler ──▶ MemantoIt sees every span your app already produces. No extra instrumentation, no decorators to add, no call sites to change, and — worth emphasizing — no calls to the Langfuse API at all. You are not paying for a second read of data your process already has in hand.
pythonfrom langfuse import Langfuse
from langfuse_memanto import attach
Langfuse() # your existing setup
attach(agent_id="my-agent") # start capturingThat is the entire setup. pip install langfuse-memanto brings memanto with it, there is no server to run, and the agent is created and activated on the first write. So this failure:
python@observe()
def summarize(doc):
raise RuntimeError("context window exceeded")becomes this memory, seconds later:
textcontext window exceeded in summarize [error]
Langfuse recorded 6 failing 'summarize' observations: context window exceeded.
Seen 6x between 2026-08-07T17:38:14Z and 2026-08-07T17:38:19Z.
tags: langfuse, capture=errors, sig=4c092b52d146, op=summarizeOne ordering rule: call attach() after Langfuse(). Before that, OpenTelemetry has only a ProxyTracerProvider, which cannot accept a span processor — so attach() raises immediately and tells you that, rather than attaching to nothing and going quiet for a week.
Capture rules can come from code, and precedence is code → stored profile → default (`errors`):
pythonattach(
agent_id="my-agent",
capture=["errors", "slow"],
latency_ms=5000, # slower than 5s is an anomaly
group_by="metadata.error_code", # if your messages group poorly
)A solo developer never touches the CLI. A team manages rules centrally with --save and each service calls attach(agent_id=...). Bad settings raise at attach() instead of silently capturing nothing — which is the failure mode every "it was working, right?" observability integration eventually has.
What a span can and cannot see
Here is the part most integration write-ups leave out, and it is the part I would want to read first. Not every signal is available everywhere, and pretending otherwise would mean capturing nothing while looking like it works.
| Mode | Catches | Needs | Live? |
|---|---|---|---|
errors | level=ERROR spans | nothing | Yes |
slow | Latency outliers | --latency-ms or --latency-percentile | Yes, with an absolute budget |
costly | Expensive calls | --cost-usd or --cost-percentile | Sync only, unless your app sets cost_details |
low-score | Traces your evals failed | --score-fail rule | Sync only |
success | Traces your evals passed | --score-pass rule | Sync only |
Each "no" has a reason worth knowing. Scores cannot be live because Langfuse attaches them after a trace finishes — nothing in the span carries them, so a span processor is looking at the wrong moment in time. Cost is usually not live because Langfuse computes it server-side after ingestion, unless your app sets cost_details itself. Latency is different — it is on the span — which is why slow works live. And percentile budgets cannot be live because a percentile needs a population to calibrate against; the sync has the whole pulled window, a single span has only itself.
When you enable a mode the handler cannot honor, it logs a warning at startup. When a sync mode captures nothing because you gave it no rule, it says so in plain language rather than reporting a clean zero:
text! 'slow' captured nothing: no latency budget set — use --latency-ms <n> for a
fixed budget, or --latency-percentile 95 to calibrate from your own traffic“An observability integration that fails silently is worse than one that does not exist, because you stop looking.
It runs next to your app, never through it
The bar for anything that attaches to a production hot path is not "usually fine." Four properties, each non-negotiable:
- Nothing runs on your hot path.
on_endmaps the span and buffers it; grouping and network I/O happen on a daemon thread. - Your app is never harmed. Every entry point swallows its own exceptions — a memory that fails to write will not break the application it was watching.
- Failed writes are retried, not dropped. A failed batch is retained and retried, which is safe precisely because reconciliation is idempotent. After 4 consecutive failures it is abandoned, so a dead backend cannot fill your memory store with garbage.
- The buffer is bounded. During a failure storm the buffer stops growing and drops are counted in
handler.stats()["dropped"]— visible, not silent.
Note the trade in the third and fourth points. Under sustained failure the integration would rather lose observations than lose your application, and it tells you when it does. That is the correct priority for a component whose entire job is to be secondary.
What your agent actually gets
The memories land typed, sourced, and linked back to the trace they came from:
| Field | Value |
|---|---|
type | error · learning (score modes) · observation (latency/cost) |
source | langfuse |
provenance | imported — preserves the original Langfuse timestamps |
source_ref | Deep link back to a representative trace |
confidence | Rises with occurrence count, capped at 0.95 |
tags | langfuse, capture=<mode>, sig=<signature>, op=<operation>, model=…, env=… |
Everything without a schema slot — occurrence count, models involved, peak latency, total cost, sample trace ids, first and last seen — goes into a bounded [Supporting data] footer. Reduced, not discarded.
Which means the loop finally closes at the only place it matters: the next run. Before an agent plans, it can ask what production already knows about this exact task:
bashmemanto recall "known failures for generate-response" --type error
memanto answer "Before this run: what has actually broken in production for this operation, and what should I avoid?"A source_ref on each memory means the answer is not a vibe — it is a claim with a trace behind it. Your agent stops proposing the approach that has failed 812 times, and if a human asks why it made that call, the receipt is one click away.
The point is not the integration
Observability and memory have been solving two halves of the same problem while facing opposite directions. Observability answers what happened for a human, after the fact, in a dashboard. Memory answers what should I do for an agent, before the fact, in a context window. The data is the same data. Only the audience and the tense differ.
Connecting them is what turns your traces from a record of repeated mistakes into a system that stops repeating them. And it generalizes — Langfuse is the first, not the shape of the ceiling. Any system that records production reality is a system whose lessons your agents are currently not learning.
Because an agent_id is just a namespace, these memories are not stuck in one place either. The same agent that receives your Langfuse errors is readable from the MCP server, your CrewAI pipeline, or your IDE — so a failure your production stack observed at 3am is context your coding agent plans from at 9am.
“Langfuse records what went wrong. Memanto remembers the lesson. Your agents focus; the lesson stays learned.
Get started
- Live capture:
pip install langfuse-memanto, thenattach(agent_id="my-agent")after yourLangfuse()setup (PyPI) - Batch sync: already in
memanto— start withmemanto migrate langfuse --discover, which writes nothing and tells you what your project actually contains - Full guide: docs.memanto.ai/integrations/langfuse — capture modes, score rules, the ledger, and troubleshooting
- Requirements: Python 3.10+, a Moorcheh API key (free tier: 100K ops/month), and
langfuse>=3for the live handler. The CLI sync works with any Langfuse version, including v2.
One note if nothing appears: Langfuse Cloud is regional and keys are not valid across regions. A project on US needs --host https://us.cloud.langfuse.com or LANGFUSE_HOST — a mismatch surfaces as 401 Invalid credentials, which reads like a bad key and is not one.