An AI agent fails, so you add a few log statements:
console.log('starting agent');
console.log('tool result', result);
console.log('final answer', answer);
That works until the agent calls tools in parallel, retries one of them, falls back to cached data, and makes several model calls for different purposes. The terminal still contains the events, but it no longer explains the run.
The limitation is not console.log()
itself. The limitation is flat, uncorrelated events. Agent debugging needs identity, parent-child relationships, lifecycle, and safe metadata. Without those, more log lines often create more noise rather than more understanding.
Consider this output:
10:00:01 search started
10:00:01 search started
10:00:02 model started
10:00:02 search completed
10:00:03 search timed out
10:00:03 cache fallback used
10:00:04 model completed
Several important questions remain:
Timestamps describe when events were written. They do not describe causality.
Some request paths are short and sequential, and flat logs are perfectly adequate. Agents become harder to observe because their control flow is often dynamic:
The useful representation is usually a tree:
support_agent
ββ classify_question
β ββ model_call
ββ retrieve_context
β ββ vector_search
β ββ keyword_search
ββ check_account
β ββ billing_api timeout
β ββ cached_account fallback
ββ generate_answer
ββ model_call
The two model calls now have different roles. The fallback belongs to check_account
, and the searches are parallel children of retrieval. The same events are easier to reason about because their relationships are explicit.
The hardest agent failures do not always throw exceptions. A workflow can complete successfully while using the wrong path.
Imagine a quote agent that reports an item as available. Every top-level operation says success
, but the inventory service timed out and the agent used a cache that was a day old. The response is syntactically valid and the HTTP status is 200. The behavior is still wrong for the current user.
A trace can make the path visible:
generate_quote ok
ββ find_product ok
ββ check_inventory ok
β ββ live_inventory error: timeout
β ββ cached_inventory ok: age_hours=24
ββ compose_quote ok: inventory_source=cache
Flat logs can capture all of these facts, but only if every line carries enough context to reconstruct the relationships. At that point, you are already building a tracing model.
The first improvement is to give every run and step stable identity.
type AgentEvent = {
traceId: string;
spanId: string;
parentSpanId: string | null;
event: 'started' | 'completed';
name: string;
kind: 'run' | 'model' | 'tool' | 'retrieval' | 'fallback';
timestamp: string;
status?: 'ok' | 'error' | 'cancelled';
durationMs?: number;
metadata?: Record<string, string | number | boolean | null>;
};
function writeEvent(event: AgentEvent): void {
console.log(JSON.stringify(event));
}
console.log()
is still the output mechanism. The difference is that the event has a contract. A local script, log processor, test, or trace viewer can group events by traceId
and rebuild the tree from parentSpanId
.
Structured events also make filtering reliable. Searching for a step name in prose logs is fragile; querying kind=tool
and status=error
is not.
Use one start and one completion event for each meaningful span. Completion should include status and duration.
const startedAt = Date.now();
writeEvent({
traceId,
spanId,
parentSpanId,
event: 'started',
name: 'search_docs',
kind: 'retrieval',
timestamp: new Date(startedAt).toISOString(),
});
try {
const documents = await searchDocuments(query);
writeEvent({
traceId,
spanId,
parentSpanId,
event: 'completed',
name: 'search_docs',
kind: 'retrieval',
timestamp: new Date().toISOString(),
status: 'ok',
durationMs: Date.now() - startedAt,
metadata: { resultCount: documents.length },
});
} catch (error) {
writeEvent({
traceId,
spanId,
parentSpanId,
event: 'completed',
name: 'search_docs',
kind: 'retrieval',
timestamp: new Date().toISOString(),
status: 'error',
durationMs: Date.now() - startedAt,
metadata: {
errorCategory: error instanceof Error ? error.name : 'UnknownError',
},
});
throw error;
}
This is verbose when written manually, which is why real tracing libraries provide span helpers and async context propagation. The example exposes the information those helpers manage.
Useful metadata explains behavior without copying the payload:
Avoid raw user messages, prompts, model output, tool arguments, tool results, headers, credentials, and retrieved documents by default. They increase risk and often make traces harder to scan.
For example, this metadata is enough to identify a broken context assembly step:
retrieve_docs result_count=5
build_context context_tokens=0
generate_answer input_tokens=412 output_tokens=96
The trace narrows the problem without storing any document text.
Random log wording creates accidental complexity:
tool finished
tool done
completed tool
search returned
Choose controlled names and statuses instead. A small vocabulary such as started
, ok
, error
, cancelled
, and blocked
is easier to aggregate and test. Use a separate error category for timeout, validation, authorization, rate limit, or dependency failure.
Consistency matters more than clever formatting.
Structured logs may be all you need when the agent is small, runs in one process, has a few sequential operations, and does not need a visual timeline or distributed context.
They are a good starting point when:
The important step is to establish the event contract early. Moving structured events to a richer sink later is much easier than parsing years of inconsistent prose logs.
Use a tracing system when the agent has concurrent branches, retries, handoffs, streaming lifecycles, several services, or shared CI rules. Tracing adds capabilities that a terminal stream does not provide naturally:
OpenTelemetry, framework-native integrations, local trace tools, and hosted observability products all use variations of this model. The destination can change; the core mental model remains the same.
This progression avoids a large platform investment before the execution model is understood.
console.log()
remains useful. It is available everywhere and can be a perfectly good sink for structured local events. What it cannot provide by itself is the causal model of an agent run.
Do not respond to a confusing agent by printing more uncorrelated payloads. Add identity, lifecycle, parentage, and safe metadata. Once those relationships are visible, parallel tools, retries, fallbacks, and silent failures become much easier to explain.
The next article will focus on the representation itself: how execution trees differ from flat event streams and how to reconstruct them reliably from span data.