โ† Back to Blog
AI EngineeringAgentsLLMs

LLM Observability: How to Actually Trace What Your Agent Is Doing

Your agent works in the demo and breaks in production, and you have no idea why. Here's how to instrument traces, spans, and evals so you can debug agent failures instead of guessing at them.

By Joinloopยท18 July 2026ยท7 min read
ShareLinkedInXReddit
๐Ÿ”Ž

The problem

You ship an agent. It works in your test cases. Three weeks later, a user reports it gave a wrong answer, or called a tool it shouldn't have, or looped for 40 seconds before timing out. You open your logs and find a wall of console.log output with no structure โ€” you can't tell which LLM call produced which output, which tool call fed which prompt, or how much that one request cost.

This is the point where teams either build real observability or keep debugging by re-running things locally and hoping to reproduce the bug. The second option doesn't scale past a handful of users.

LLM observability is not the same as regular application logging. A single user request to an agent can fan out into a dozen LLM calls, several tool invocations, retries, and branching logic โ€” and the thing you're debugging (a model's reasoning) doesn't throw exceptions when it's wrong. It just produces plausible-looking garbage. You need traces that capture the full decision path, not just error logs.

What "trace" actually means here

Borrowed from distributed tracing (OpenTelemetry), the vocabulary maps cleanly onto agents:

  • Trace: the full lifecycle of one user request, from first input to final response.
  • Span: one unit of work inside a trace โ€” a single LLM call, a tool call, a retrieval step, a sub-agent invocation.
  • Generation: a span specific to an LLM call โ€” records the prompt, completion, model, token counts, latency, and cost.
  • Session: a group of traces belonging to one user/conversation across multiple turns.

A well-instrumented agent trace should let you answer, for any bad output: which model saw which exact prompt, which tools it decided to call and why, what those tools returned, and how the final answer was assembled from that context.

Minimal instrumentation with OpenTelemetry

If you don't want to commit to a vendor yet, standard OpenTelemetry spans get you most of the way. Here's a TypeScript agent loop with tracing wired in manually:

import { trace, SpanStatusCode } from "@opentelemetry/api";

const tracer = trace.getTracer("agent-runtime");

async function runAgentTurn(userInput: string, sessionId: string) {
  return tracer.startActiveSpan("agent.turn", async (span) => {
    span.setAttribute("session.id", sessionId);
    span.setAttribute("input.text", userInput);

    try {
      const plan = await tracer.startActiveSpan("llm.plan", async (planSpan) => {
        planSpan.setAttribute("llm.model", "claude-sonnet-5");
        const result = await callLLM({
          model: "claude-sonnet-5",
          messages: [{ role: "user", content: userInput }],
        });
        planSpan.setAttribute("llm.completion_tokens", result.usage.output_tokens);
        planSpan.setAttribute("llm.prompt_tokens", result.usage.input_tokens);
        planSpan.setAttribute("output.text", result.text.slice(0, 500));
        planSpan.end();
        return result;
      });

      if (plan.toolCalls?.length) {
        for (const call of plan.toolCalls) {
          await tracer.startActiveSpan(`tool.${call.name}`, async (toolSpan) => {
            toolSpan.setAttribute("tool.input", JSON.stringify(call.input));
            const output = await executeTool(call);
            toolSpan.setAttribute("tool.output", JSON.stringify(output).slice(0, 500));
            toolSpan.end();
          });
        }
      }

      span.setStatus({ code: SpanStatusCode.OK });
      return plan;
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
      span.recordException(err as Error);
      throw err;
    } finally {
      span.end();
    }
  });
}

This works and is vendor-neutral โ€” export to any OTel-compatible backend (Jaeger, Honeycomb, or an LLM-specific tool that ingests OTLP). The downside: you're manually tracking token counts, costs, and prompt/completion pairs, which is exactly the boilerplate that dedicated LLM observability tools eliminate.

Proxy-based vs SDK-based instrumentation

There are two fundamentally different ways vendors capture LLM traces, and the choice affects how much you have to touch your codebase.

Proxy-based (e.g., Helicone): you route your API calls through a proxy URL instead of calling the provider directly. The proxy sees every request and response and logs it, no SDK integration required.

// Before
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

// After โ€” same client, traffic now traced
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
  baseURL: "https://anthropic.helicone.ai",
  defaultHeaders: { "Helicone-Auth": `Bearer ${process.env.HELICONE_KEY}` },
});

SDK-based (e.g., Langfuse, Braintrust): you wrap or decorate your functions, and the SDK builds a nested trace tree client-side before shipping it to the backend.

import { observe } from "langfuse";

const runAgentTurn = observe(async (userInput: string) => {
  const plan = await observe(callLLM, { name: "llm.plan" })({
    model: "claude-sonnet-5",
    messages: [{ role: "user", content: userInput }],
  });
  return plan;
}, { name: "agent.turn" });

Proxy-based setup is faster (minutes, one config change) but only sees API traffic โ€” it can't capture your internal control flow (why the agent decided to retry, which branch of your code ran). SDK-based setup takes longer to instrument but gives you full visibility into application logic, not just the API calls. For anything beyond a single-call chatbot, you want SDK-based tracing, or both layered together (proxy for cost/cache visibility, SDK for the reasoning trace).

Comparison of common options

Tool Instrumentation Self-host option Best for
Langfuse SDK (decorators/wrappers) Yes, MIT core Deep pipeline tracing, teams with data residency needs
Helicone Proxy (change base URL) Yes Fast setup, cost/cache visibility, retrofitting legacy apps
Braintrust SDK No Evals tightly coupled to tracing, regression testing on prompts
AgentOps SDK, framework-native No CrewAI/AutoGen multi-agent session replay
Raw OpenTelemetry Manual spans Yes (your infra) Vendor neutrality, existing OTel stack

Turning traces into evals, not just dashboards

Traces are diagnostic. Evals are what stop the same failure from recurring. Once you have traces, the highest-leverage next step is: take the traces where things went wrong, turn them into eval cases, and run those against every future prompt or model change.

# Pseudocode: promote a bad trace into a regression test
def trace_to_eval_case(trace):
    return {
        "input": trace.input,
        "expected_behavior": "should NOT call refund_tool without order_id",
        "actual_tool_calls": [s.name for s in trace.spans if s.type == "tool"],
    }

def check_regression(eval_case, new_run):
    called_refund = "refund_tool" in [s.name for s in new_run.spans if s.type == "tool"]
    assert not called_refund, f"Regression: {eval_case['input']} triggered refund_tool again"

This closes the loop: production trace โ†’ eval case โ†’ CI gate. Without it, observability is just a nicer log viewer.

Common pitfalls

  • Logging full prompts/completions without redaction. If your agent ever touches PII, payment info, or credentials, traces become a compliance liability. Redact before it leaves your process, not after it's stored.
  • No sampling strategy at scale. Tracing every single request at high volume gets expensive and noisy. Sample fully on errors and a percentage of successes; don't sample uniformly and miss rare failure modes.
  • Traces without session grouping. A single bad turn is hard to diagnose without the preceding conversation context. Always attach a session/conversation ID.
  • Treating cost tracking as optional. Token costs compound fast with agentic loops (tool calls triggering more LLM calls). If you're not tracking cost per trace, you'll find out you're burning money the hard way โ€” via the invoice.
  • Not instrumenting tool calls, only LLM calls. The LLM call is often not where things go wrong โ€” a tool returning malformed data or a retrieval step returning irrelevant context is just as common a root cause, and invisible if you only trace generations.

Next steps

Start with SDK-based tracing on your highest-traffic agent flow this week โ€” you don't need full coverage on day one. Once you have a week of real traces, pull the five worst ones and turn them into eval cases before you touch the prompt again.

If you want a structured path through building and evaluating production LLM agents โ€” tracing, evals, and the failure modes that don't show up until real users hit them โ€” that's a core part of what we cover in the Joinloop AI engineering cohort.

ShareLinkedInXReddit

Want to build this yourself?

Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.