The debate around agent logs vs observability usually starts from a false premise: that logging is observability. In systems where an LLM plans, calls tools, and retries across multiple providers, log lines fragment the story. You need traces and metrics to reconstruct causality, not just a tail of stdout.
The failure mode of log-only debugging
A typical agent runs as a loop: plan, act, observe, repeat. Each iteration may hit a different model endpoint, call a vector store, or execute a sandboxed function. When you only emit log lines, you get timestamps and messages but no enforced relationship between them.
import logging
logging.basicConfig(level=logging.INFO)
def run_agent(task):
logging.info("starting task: %s", task)
steps = planner(task) # logs "planned 3 steps"
for s in steps:
logging.info("executing %s", s)
result = tool_call(s) # logs "tool returned 200" or raises
logging.info("got result len=%d", len(result))
If a user reports “the agent hung for 30 seconds then gave a bad answer,” your log file shows isolated entries. You can’t tell which model call dominated latency, whether a retry exhausted a quota, or if a cached context silently expired. The agent logs vs observability gap is exactly this missing linkage.
Logs are also lossy under concurrency. Two tasks interleave, and grep becomes your only join key. That does not scale past a single process.
What observability adds for agents
Observability is the ability to answer new questions from collected signals without shipping code. For agents, three signal types matter:
- Traces — causal paths through steps and model calls.
- Structured events — token counts, cache hits, tool schemas.
- Metrics — latency distributions, error rates, cost per route.
Distributed tracing across model calls
A trace assigns a span to each logical operation. Spans nest, carry attributes, and propagate a context object. OpenTelemetry gives you this without vendor lock-in.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
trace.set_tracer_provider(TracerProvider())
tracer = trace.get_tracer("agent.core")
def planner(task):
with tracer.start_as_current_span("planner.llm") as span:
span.set_attribute("llm.model", "gpt-4o-mini")
span.set_attribute("llm.task_type", "decompose")
# client.chat.completions.create(...)
return ["step1", "step2"]
When the executor calls a tool, you start a child span. The root trace ID now ties every log line, token count, and HTTP status to one user request. This is the core of agent logs vs observability: logs tell you what emitted a string; traces tell you why the system took that path.
Structured events for token economics
LLM agents burn tokens in hidden places—re-planning, summarization, system prompts. A log line saying “completed” hides the bill. Emit a structured event alongside the span:
{
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"event": "llm.completion",
"model": "claude-3-5-sonnet",
"usage": {"prompt_tokens": 1820, "completion_tokens": 240, "cache_read": 1500},
"provider": "anthropic"
}
Now you can attribute cost to a specific planner iteration, not just a daily total.
Metrics for latency and error budgets
Metrics aggregate traces into actionable shapes. A histogram of span.duration by llm.model reveals that one provider degrades every afternoon. A counter of agent.retry exposes fragile prompts. Logs cannot give you a p99 without post-processing the entire firehose.
Tradeoffs: when logs are enough
Honestly, logs are fine for a single-file script that calls one model synchronously. If you have no concurrency, no tools, and no retries, a print and a log file solve 80% of issues. Adding OpenTelemetry incurs a learning curve and a collector to run.
The tradeoff flips the moment the agent branches. Multi-step reasoning, parallel tool calls, or fallback across providers turn a linear flow into a graph. At that point, the cost of not having traces is repeated incident debugging that eats engineer time. Agent logs vs observability is therefore a function of system complexity, not ideology.
Implementing tracing without drowning in spans
The mistake teams make is tracing every internal function. You end up with 200 spans per request and a useless flame graph. Follow these rules:
- Span only boundaries that cross I/O: model API, tool HTTP, queue pop.
- Use attributes, not child spans, for loop iterations.
- Propagate context via
contextvarsso async tasks stay linked.
import contextvars
from opentelemetry.context import attach, detach, get_current
agent_ctx = contextvars.ContextVar("agent_ctx")
def handle_task(task):
ctx = get_current()
token = agent_ctx.set(ctx)
try:
run_agent(task)
finally:
agent_ctx.reset(token)
Sample at 10% for healthy traffic; always trace on error. This keeps storage bounded while preserving forensic depth.
Feeding gateway data into your pipeline
If you route through an OpenAI-compatible gateway such as n4n.ai, you get per-token metering and provider cache-control hints forwarded, which you can join with trace IDs to attribute cost to specific spans. The gateway’s automatic fallback when a provider is rate-limited also emits a routing directive you should record as a span event—otherwise you’ll misattribute latency to the wrong model.
A minimal join in your warehouse:
SELECT t.trace_id, g.tokens, g.provider
FROM traces t
JOIN gateway_usage g ON t.attributes['gateway.request_id'] = g.request_id
This closes the loop between infrastructure signals and application logic.
Decisive takeaway
Stop treating agent logs vs observability as a choice. Logs are cheap forensics; traces are causal reconstruction; metrics are trend detection. Ship all three from day one if your agent has more than one step. The engineering cost of a tracer provider is lower than the third time you manually reconstruct a failed run from grep. Instrument the boundaries, emit structured usage, and let the gateway handle provider routing—your future incident responder will not have to read 10,000 log lines to find the one dropped tool call.