Evaluating And Operating Agents
Observability and Tracing
interleaved with every other concurrent run.
JrCodex·7 min read
Jr Codex Agentic AI Notes
Level: Advanced Prerequisites: Chapter 2: Building an Agent Eval Set Time to complete: ~20 minutes
Table of Contents
- Why Logs Are Not Enough
- The Trace Model
- Instrumenting an Agent
- LangSmith and OpenTelemetry
- Cost Attribution
- Dashboards and Alerts
- Summary & Next Steps
1. Why Logs Are Not Enough
The Problem With Log Lines
─────────────────────────────────────────
A 20-step agent produces ~200 log lines,
interleaved with every other concurrent run.
Even filtered to one run, a flat log cannot show:
- which model call PRODUCED a given tool call
- how long each step took RELATIVE to the whole
- what the model actually SAW at step 12
- where the cost went
- which sub-agent a call belonged to
Agent execution is a TREE. A log is a list.
─────────────────────────────────────────
What Tracing Adds
─────────────────────────────────────────
A trace preserves the STRUCTURE: nested spans with
timing, inputs, outputs and metadata.
It answers "what happened, in what order, nested
how, costing what" — which is the only question
worth asking about a failed agent run.
─────────────────────────────────────────
2. The Trace Model
The Hierarchy
─────────────────────────────────────────
TRACE (one run)
└─ SPAN agent_loop
├─ SPAN llm_call step=1 1.2s $0.004
│ in: messages[3]
│ out: tool_calls[1]
├─ SPAN tool get_customer 0.1s
│ in: {"id": "cust_4821"}
│ out: {...}
├─ SPAN llm_call step=2 1.8s $0.006
├─ SPAN tool list_orders 0.3s
│ └─ SPAN retrieval 0.2s ← nested
└─ SPAN llm_call step=3 2.1s $0.009
out: final answer
─────────────────────────────────────────
The Four Things Every Span Needs
─────────────────────────────────────────
IDENTITY trace id, span id, parent span id
TIMING start, end, duration
PAYLOAD input and output (redacted as needed)
METADATA model, tokens, cost, error, step
number, agent name
Parent span id is what turns a list into a tree.
Omit it and you have logs again.
─────────────────────────────────────────
3. Instrumenting an Agent
import time, uuid, contextlib
class Tracer:
def __init__(self, sink): self.sink, self.stack = sink, []
@contextlib.contextmanager
def span(self, name, **metadata):
span = {
"trace_id": self.stack[0]["trace_id"] if self.stack else str(uuid.uuid4()),
"span_id": str(uuid.uuid4()),
"parent_id": self.stack[-1]["span_id"] if self.stack else None,
"name": name, "start": time.time(), "metadata": metadata,
}
self.stack.append(span)
try:
yield span
except Exception as e:
span["error"] = f"{type(e).__name__}: {e}"
raise
finally:
span["end"] = time.time()
span["duration"] = span["end"] - span["start"]
self.stack.pop()
self.sink.write(span) # emit on close, so timing is realdef traced_agent(client, goal, tools, tracer):
with tracer.span("agent_run", goal=goal) as run:
messages = [{"role": "user", "content": goal}]
for step in range(MAX_STEPS):
with tracer.span("llm_call", step=step) as s:
reply = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools.schemas)
s["metadata"].update(
model="gpt-4o",
input_tokens=reply.usage.prompt_tokens,
output_tokens=reply.usage.completion_tokens,
cost_cents=price(reply.usage),
prompt_hash=hash_messages(messages), # HASH, not the raw prompt
)
if not reply.choices[0].message.tool_calls:
run["metadata"]["outcome"] = "completed"
return reply.choices[0].message.content
messages.append(reply.choices[0].message)
for call in reply.choices[0].message.tool_calls:
with tracer.span("tool", tool=call.function.name) as t:
result = tools.execute(call)
t["metadata"]["error"] = result.get("error") if isinstance(result, dict) else None
messages.append(tool_message(call.id, result))
run["metadata"]["outcome"] = "step_limit"Redaction Is Not Optional
─────────────────────────────────────────
Traces capture prompts and tool results, which
means they capture customer data.
Store a prompt HASH by default and the full text
only where policy allows. Redact known PII fields
in tool outputs before they reach the sink.
A trace store is a data store, with all the
obligations that implies.
─────────────────────────────────────────
4. LangSmith and OpenTelemetry
# LangSmith — automatic for LangChain/LangGraph, decorator for anything else.
import os
os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_PROJECT"] = "support-agent"
from langsmith import traceable
@traceable(run_type="tool", name="get_customer")
def get_customer(customer_id: str) -> dict:
...
@traceable(run_type="chain", name="investigate")
def investigate(ticket):
... # nesting is captured automatically# OpenTelemetry — vendor-neutral, lands in your existing APM alongside service traces.
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("agent_run") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", "gpt-4o")
span.set_attribute("gen_ai.usage.input_tokens", usage.prompt_tokens)
span.set_attribute("gen_ai.usage.output_tokens", usage.completion_tokens)Choosing Between Them
─────────────────────────────────────────
LANGSMITH purpose-built for LLM work.
Prompt diffing, run comparison,
dataset creation from traces, and
evaluation wired in. Best
developer experience by some
margin.
OPENTELEMETRY vendor-neutral, and agent spans
sit in the SAME trace as your HTTP
and database spans. Best when the
agent is one component of a larger
system.
Doing both is common and reasonable: OTel for
operations, LangSmith for agent development.
─────────────────────────────────────────
5. Cost Attribution
def attribute(trace) -> dict:
"""Answer 'where did the money go' along every axis that matters."""
llm = [s for s in trace.spans if s["name"] == "llm_call"]
by_step = {s["metadata"]["step"]: s["metadata"]["cost_cents"] for s in llm}
by_agent = {}
for s in llm:
agent = s["metadata"].get("agent", "main")
by_agent[agent] = by_agent.get(agent, 0) + s["metadata"]["cost_cents"]
input_tokens = sum(s["metadata"]["input_tokens"] for s in llm)
tool_bytes = sum(len(str(s.get("output", ""))) for s in trace.spans
if s["name"] == "tool")
return {"total_cents": sum(by_step.values()),
"by_step": by_step, "by_agent": by_agent,
"input_tokens": input_tokens,
"tool_output_bytes": tool_bytes, # the usual culprit — Module 2, Ch.1
"growth": cost_growth_per_step(by_step)}The Two Diagnostics This Gives You
─────────────────────────────────────────
COST GROWTH PER STEP
Rising sharply means the context is
accumulating — a memory management problem
(Module 3), not a model problem.
TOOL OUTPUT BYTES vs INPUT TOKENS
If tool bytes dominate, your perception layer is
not truncating enough. This is the single most
common cause of an expensive agent, and it is
invisible without this measurement.
─────────────────────────────────────────
6. Dashboards and Alerts
Four Dashboards
─────────────────────────────────────────
RELIABILITY completion rate, recovery rate,
error rate BY TYPE (rate limit,
timeout, tool error, budget) —
aggregate error counts hide which
problem you have
EFFICIENCY p50/p95 steps per run, cost per
SUCCESS, tokens per run, cache hit
rate
QUALITY eval scores per release, drift
against the stored baseline,
thumbs/regeneration from users
SAFETY unauthorised action COUNT, approval
queue depth and wait time, budget
breach count
─────────────────────────────────────────
Alerts That Actually Fire Usefully
─────────────────────────────────────────
COST PER USER PER DAY over threshold
The silent failure. Nothing is down, latency is
fine, errors are zero — and the bill is 40x.
An injection loop or a retry bug looks exactly
like this.
STEP COUNT p95 rising
Agents are wandering more than they used to.
An early warning that a prompt or tool change
degraded selection.
RECOVERY RATE falling
A tool's error messages changed and the agent no
longer understands them.
ANY unauthorised action
Page someone. This is not a metric to watch.
─────────────────────────────────────────
7. Summary & Next Steps
Key Takeaways
- Agent execution is a tree and logs are a list; the parent span id is what makes a trace answer questions a log cannot.
- Every span needs identity, timing, payload and metadata — and traces capture customer data, so redaction and prompt hashing are part of the design.
- Cost attribution by step and by agent gives two diagnostics: rising per-step cost means a memory problem, and dominant tool-output bytes mean perception is not truncating.
- Alert on cost per user per day, because a runaway agent looks perfectly healthy on every conventional signal.
Concept Check
- What can a trace answer that a filtered log cannot, and why?
- Per-step cost is rising steeply through a run. Which module's problem is that, and which is it not?
- Why is "cost per user per day" the alert most likely to catch a prompt-injection loop?
Next Chapter
→ Chapter 4: Debugging and Error Recovery
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index