← Back to notes

I Traced Everything. That Made Langfuse Less Useful.

The point of observability is not to record every function. It is to make the system easier to understand when something goes wrong.

My first version of observability in CairnOps was too enthusiastic.

If a function looked important, I traced it. Then I traced the helper it called. Then the helper under that helper.

The result was technically detailed and practically annoying.

A retrieval operation could turn into something like this:

retrieve
├── build_query
├── normalize_query
├── search
├── join_chunks
└── clean_sources

Nothing was missing. I still had to mentally reconstruct the actual workflow every time I opened the dashboard.

That was when I stopped treating “more spans” as “better observability.”

I started tracing failure boundaries instead

The rule I use now is simple: an operation deserves its own span if it can fail for a meaningfully different reason, or if its performance has a meaningfully different character.

Weather fetching is a good example.

A weather request in CairnOps can fail before the weather provider is even called because the location has to be geocoded first.

So this is useful:

weather_fetch
└── geocode

If geocode fails, I know where to look without reading application logs first.

A helper that only formats a query string does not give me that kind of diagnostic value. It stays an implementation detail.

LLM calls were already observable

Another mistake I nearly made was wrapping every model call manually.

LangChain’s callback integration was already creating generation observations with prompt, output, latency, tokens, model information, and cost where available.

Adding another manual span around the same call would have created another layer without adding another answer.

The distinction that helped me was:

Trace      -> one user request
Span       -> one meaningful operation
Generation -> one model call

Once I kept those roles separate, the trace tree became much easier to read.

Complex nodes can still have children

I don’t avoid nested spans completely.

RouteAgent does several different kinds of work: it can read a GPX file, enrich route data with a topography service, and then ask the LLM to interpret the result.

Those have different failure modes and different latency profiles, so this structure earns its complexity:

route_assess
├── gpx_load
├── topo_enrich
└── LLM generation

If the route node takes four seconds, I can immediately see whether those four seconds came from file I/O, the external service, or the model.

That is what I want from a trace.

Naming matters too

I also stopped creating separate operation names for branches that were really the same operation.

For example, standard and high-risk plan generation used different code paths. I could have named them:

plan_write_standard
plan_write_high_risk

Instead I kept one operation name and moved the distinction into metadata:

plan_write
risk_level=HIGH

That makes aggregate latency and token analysis much cleaner. I can still filter high-risk plans without splitting the same business operation into multiple artificial categories.

My current traces contain less information than the first version.

They are much more useful.

When I open Langfuse now, I want to see the shape of the system and the places where it can break—not a call graph of every Python function that happened to run.