Lesson 6: Hands-On: Wiring an Observability Layer onto the Harness
Learning goals:
- Wire a working observability layer into your own harness: JSON Lines structured logs, trace tree reconstructed from logs, one-line metrics summary
- Walk through a real error task from symptom → filter → first divergence point → fix → re-run comparison (stub-pinned model side for full re-runs; real APIs return to resume-from-error), and explain which absurdities are causes vs. which are contagion
- Draw the boundaries of this observability layer: covers one process, one run; content defaults to off; thresholds not invented
Prerequisites: Complete Lessons 1–5, have the harness loop from Course 7 (Agent Harness Fundamentals: Loops and Control) runnable on hand | Previous: Lesson 5 <<
Let's start with a concrete, smell-it-at-the-desk scenario.
You wrote a small agent to handle weekly reports: data/ directory holds three quarterly sales CSVs, it reads them, aggregates by region, writes out summary.md. Three tools: list_files, read_file, write_file. Ran fine for weeks.
Monday morning, a colleague asks in chat: "Where did this 'Central China' region come from? We don't have a Central China region."
You open summary.md, and sure enough:
You open data/, three files inside: 2026-q1-east.csv, 2026-q1-south.csv, 2026-q1-north.csv, content has region names East China, South China, North China. Full-text search the entire directory for "Central China" — zero matches. South China vanished entirely, Central China appeared out of nowhere, and the number 208000 came from who-knows-where.
The question now is: where did this step go wrong?
Without an observability layer, you have two things: a wrongly-written summary.md, and the phrase "the model made it up." That phrase solves nothing — you don't know if it failed to read the file in the first place, or read it but calculated wrong, or read all three but mixed up rows when writing. And you can't "just reproduce it with a breakpoint" to force it out: agents are non-deterministic between runs, same prompts and same tools might take a completely different but equally valid path1. You re-run three times, all three might succeed, or the fourth might fail in a new way.
Worse, errors compound. One step failing can make the agent veer into a completely different trajectory, and the final result looks unrelated to the original small glitch1. So you can't just stare at the endpoint — the absurdity at the endpoint is often just contagion (Lesson 1 called this trajectory diversion, same thing), the actual lesion is upstream at some step.
This lesson's job is to turn "can't say" into "can be checked": solder an observability layer onto the harness, then walk through this real bug once to locate it. Everything from the previous five lessons lands in a single runnable file.
The three-piece observability kit: what to record
When running agents in production, you need visibility into four things: which tools they called, how long each model request took, how many tokens were spent, where failures occurred2. The official approach is to export these as OpenTelemetry traces, metrics, and log events; this lesson doesn't pull in any OTel library, we hand-roll a minimal version, three pieces:
- Structured logs: one JSON Lines entry per model request, one per tool call, written to
run.log.jsonl.
- Trace tree: after the run finishes, reconstruct parent-child relationships from that JSONL, print indented.
- Metrics summary: one line outputting total rounds, tool call count, tokens, error count, total duration.
Span model: who's whose parent
With enhanced telemetry enabled officially, each step of the agent loop becomes an inspectable span: one interaction is the root span, model requests and tool executions are its child spans2. Note that in the official tree, model requests and tool calls are peer siblings under the root — the tree you rebuilt in Lesson 4 has this shape. Our minimal version deliberately uses a different attachment: we hang tool calls under the model request that triggered them, so the tree's shape directly shows "what did the model want to do this round," parallel tools under the same parent visible at a glance. The parent-child mechanism is exactly the same, we just picked a different parent for tools — both attachments are valid, which one you pick depends on what question you want the tree to answer first. Our three layers look like this:
Parent-child relationships don't rely on an in-memory call stack, they rely on two fields in the logs: each record carries a span_id, plus a parent_id pointing to its parent. The tree is reconstructed from the on-disk JSONL after the run finishes, not printed as you go. This point matters: anything visible in the tree must first be recorded in the logs. If you find something missing from the tree, it's not a printing-code problem, it's a recording-code problem.
Field table
The field design below is this lesson's engineering approach, not an official spec — officially it's OTel span attribute names, when you write your own harness the field names are up to you. Four names differ from Lessons 3 and 4, let's map them first so you don't think they're typos: Lesson 3's type is called kind here (back then only two record kinds, now we have agent_run, a semantically broader term fits better); input_tokens/output_tokens are folded into a tokens:{input,output} object (model-call-specific stuff packed together); tool_response is called tool_result here (hook payload calls it response, the return value here comes directly from tool implementation, following the API content block's naming); Lesson 4's parent_span_id shortened to parent_id. The cost is also stated: Lesson 3's stats.mjs needs two field name changes to read this log — this is a live demonstration of "vocabulary alignment matters more than pretty naming." The field vocabulary is still worth aligning with official materials, so when you eventually hook up a backend you don't have to swap concepts, just swap spelling:
The trace_id trick is learned from official: one user prompt triggers several API calls and several tools, official uses a prompt.id attribute to tie them all back to the triggering prompt; official's tracing approach is also direct — to trace all activity triggered by a single prompt, filter events by a specific prompt.id value3. We use trace_id here, one run is one task, so use trace_id, does exactly the same thing. By the way there's no session_id here: this script's one run is one session, keeping this field would be meaningless; multi-round multi-session scenarios add it back, vocabulary reference in Lesson 3.
The metrics line isn't randomly picked either. Besides top-level accuracy, official recommends collecting: total runtime of individual tool calls and tasks, total number of tool calls, total token consumption, tool errors4 — these four things have landing spots in the summary line and each record's duration_ms. rounds is the fifth number I added, handy to see at a glance how many loop iterations. You've already used the official set in Course 10 (Verification and Quality Assurance: Don't Let 'Looks Right' Slip Through) — there for grading, here for diagnosis with the same ruler.
How much content to record: the only line this lesson asks you to draw yourself
tool_input and tool_result might be an entire CSV, entire user input, entire document written out. Recording everything is technically a one-liner, but by default you shouldn't.
Official telemetry's default stance is clear: structural things always recorded, content never recorded — every span has duration, model name, tool name, token count recorded when API returns usage, while agent-read and agent-written content defaults to not collected2. User prompts same, default only records length, recording content requires a separate environment variable3. And official pairs this kind of switch with a hard statement: unless your observability pipeline is approved to store the data your agent handles, leave these unset2.
Our layer leaves a compromise: default records shape (is it string or object, how long, what keys), chars (character count), plus a snippet of the first 60 characters as a head summary. The snippet is so you can recognize "which file did it read this time" at a glance during your own debug, without re-running repeatedly. The HEAD_CHARS = 60 in the code is where this line sits, set to 0 and not a single word of content hits disk. Where you draw this line in your own project depends on where the logs land, who can see them, whether data approval was passed — this is a compliance question, not a technical one.
The verification setup: where the three versions' differences are nailed down
This lesson runs three times: one normal, one buggy, one fixed. The three outputs must be line-by-line comparable, so model responses can't be real — real model responses differ every time, you can't use them to teach locating. Following the old approach from Courses 8 through 10 of this series: stub client with fixed response queue. client.messages.create() doesn't send a network request, returns pre-written response objects from an array in sequence, each object carries complete stop_reason, content, usage. The harness loop doesn't change a single word — what it gets is shape-identical to what a real client returns.
All three versions' differences are nailed down in the code's VERSIONS table, each version has two things:
Aside from this table, every other line of code is shared across all three versions. The tools genuinely read/write disk: list_files genuinely readdirSync, read_file genuinely reads files, genuinely throws because file doesn't exist, write_file genuinely writes summary.md to disk. So that error in v-bug isn't a forged error object, it's the filesystem genuinely not finding that file.
To be clear: stubs solve "the model side is reproducible," not "the agent is deterministic." When actually running, the same prompt twice might choose different tools, take different paths1. This observability layer's value is exactly here — paths differ each time, but each time there's a record to review.
What needs stating clearly: stubs pin the model side so full re-runs work; real APIs return to resume-from-error. This lesson dares to do full re-runs exactly because the model side is stub-pinned — re-runs introduce no new variables, line-by-line comparison holds. When you hook up real APIs, stubs are gone, return to Lesson 5's approach: resume from error.
Complete code: observed-agent.mjs
One entire file, zero dependencies, bare node runs. Save as observed-agent.mjs, then node observed-agent.mjs --version v-bug runs it.
A few points worth calling out separately:
- The loop itself hasn't changed. That
while (response.stop_reason === "tool_use") from Course 7 (Agent Harness Fundamentals: Loops and Control) didn't move a single word, observability wraps around the outside: callModel() records a timestamp before and after the request, runToolUses() wrapped a try/catch plus timer around each tool block. Strip off those two wrappers, what's left is the original loop.
MAX_ROUNDS is a hard gate. Agents need stopping conditions, such as maximum iteration count, this is part of control5. Exceeding it throws an error, records a harness_error, exit code 2.
- Exit code division of labor. This script only handles running and recording, run finishes means 0; wrong params or runaway means non-zero. "Is the output correct" is the job of the verifier suite from Course 10 (Verification and Quality Assurance: Don't Let 'Looks Right' Slip Through) — note
v-bug also exits 0, the harness thinks it finished smoothly. Verification tells you if it broke, this layer tells you why.
- Tool errors don't break the loop. Errors get wrapped into an
is_error: true tool_result and stuffed back to the model, loop continues. This is correct — agents need to gain ground truth from the environment at each step to assess progress5, errors are feedback too. This lesson's entire bug happens in the second half of that sentence: feedback was given, but given too poorly.
First run smooth sailing: v-good
See what normal looks like first. The terminal output below and all subsequent terminal outputs are genuinely run, not hand-written examples.
Your trace_id, span_id, ts and millisecond counts will differ from mine — ids are randomly generated each run, milliseconds are genuine duration. Besides those, every line should match word-for-word.
Reading this tree down is one complete sentence: first list directory (turn-1), then in one round parallel-read three files (turn-2 with three sibling nodes below), then write file (turn-3), finally wrap up (turn-4, stop=end_turn). Those three parallel lines are three tool_use blocks in the same model response, so their parent_id points to the same model_call — the tree's shape directly shows "what did the model want to do this round."
Don't take that 0ms column in model_call seriously: stub client has no network round-trip, so model request duration is all 0. After hooking up real APIs this column gains diagnostic value — tracking API request durations and tool execution times is exactly for finding performance bottlenecks3.
The log file looks like this, one complete JSON per line, can grep directly:
Line two is that list_files: parent_id points to line one's span_id (so it hangs under turn-1), inside tool_input only has shape, length, and a small snippet, tool_result same — shape is string(52), head has the three filenames. Not a single byte in this line is "file contents," but you can already answer "what did this step call, what shape of thing did it get, did it error."
Look again at the metrics summary line: 4 rounds, 5 tool calls, 0 errors, 6033 tokens, line end also has total duration. This line of numbers is worth a glance every time a run finishes — tool call count can expose fixed routines the agent repeatedly walks, a pile of redundant calls often suggests pagination or token limit params should be tuned; while a pile of invalid-parameter errors might say tool descriptions should be written clearer, examples should be given more fully4. Tokens especially worth watching: when analyzing eval performance, official found that token usage by itself explains 80% of the variance, the other two explanatory factors are tool call count and model choice1.
Reproduce the symptom: v-bug
Now run the buggy one. The stub queue has the real divergence from the lesson's opening buried in it, don't peek first, find it yourself from the output.
The artifact is indeed wrong:
First notice a few things invisible from the outside:
- Round count, tool call count identical to
v-good: 4 rounds, 5 calls. Just looking at these two numbers, the two runs look identical.
- Tokens only up 67 (6100 vs 6033). If your alert is "tokens exceed threshold," this one wouldn't even ring.
- Only
errors=1, this one number changed. This is why tool errors must be a first-class citizen in metrics4 — it's the only signal at the summary level that this run looks wrong.
- That last
model_call is stop=end_turn, the agent thinks it successfully completed the task. It didn't error, didn't ask for help, didn't mention missing a piece of data. What it omits in feedback can often be more important than what it includes4.
Five-step locating: from symptom tracing to first divergence point
Lesson 5's five-step locating is general orchestration; this round's materials are special — three line-by-line comparable logs in hand — so three of the five steps changed form, written out side-by-side:
Step five needs separate explanation. Lesson 5 advocates "after fix resume from error, don't re-run from scratch," reason being full re-runs reintroduce non-determinism, you can't tell "fixed correctly" from "got lucky this time." This lesson dares to do full re-runs exactly because the model side is stub-pinned — re-runs introduce no new variables, line-by-line comparison holds. When you hook up real APIs, stubs are gone, return to Lesson 5's approach: resume from error.
Landing on this round's materials is five steps below. Pretend you don't know the answer yet, walk through once.
Step one: pin down this one run
Production environment all runs' logs mix into one stream. First simulate this situation, merge three runs' logs:
Of 32 lines, only 10 belong to the buggy run. This step uses official's given tracing approach: to trace all activity triggered by one prompt, filter events by that specific id3. Whether it's called prompt.id or trace_id doesn't matter, what matters is this id exists, and every record carries it.
While we're here can check how many errors are in the entire stream:
Two: v-bug one, v-fixed one. v-good squeaky clean.
Step two: identify first divergence point in tree
Tree's already printed, scan top to bottom, find first record that doesn't match expectation:
Under turn-2 three parallel reads, middle one broke. The reason it broke is written in tool_input: path is data/2026-q1-sourth.csv — south typo'd as sourth. That list_files line in the tree only shows ok string(52), correct filenames need to dig back into logs: pull out that record (you already saw it in the head -3 above), tool_result.head says 2026-q1-east.csv 2026-q1-north.csv 2026-q1-south.csv — the model did receive the correct name. Tree handles locating, logs handle details, two layers cooperate exactly like this.
To see that complete record, fish it out of the stream:
This is the divergence point. Note how it was recognized: not by guessing, by three fields — trace_id locks scope to this one run, error being non-null picks it out from ten records, tool_input.head tells you where params went wrong. Three fields, not one dispensable.
Step three: recognize downstream absurdities as contagion, don't fix separately
After the divergence point, turn-3 has the model write an aggregate with a Central China region, turn-4 reports "task complete." Both steps look quite absurd, but they're both downstream:
In agent systems, one step failing is enough to make it veer into a completely different trajectory, final result unpredictable1 — this is the cleanest example. If you only got the final summary.md, where would you go fix? Probably go change the prompt: "don't fabricate data" "must note data sources." All these changes hit contagion, don't hit the lesion. Next time swap the typo method, it'll still fabricate.
By the way why this symptom grew into "Central China" instead of "South China missing": it did receive the filename (2026-q1-south.csv is right there in list_files's return), the east-to-East China, north-to-North China correspondence is already in the first two successful read contents — what it lacks is just those three months' specific numbers. But that opaque ENOENT error told it neither "retry with correct filename" nor "stop and explain clearly," so it picked the easiest road: disguise the gap as complete, fill in both region name and numbers. Fabrication isn't because it knows nothing, it's because the error gave it no better exit.
Step four: determine cause — the feedback it got was terrible
At this step don't rush to blame the model. Look what that error actually gave it:
This line has enough information for a human engineer, for an agent deciding "what to do next" it's nearly empty. It can't read out "which files in this directory are readable," can't read out "did I typo or does this data genuinely not exist," even less can it read out "when encountering this situation I should stop and ask, not fill in myself." Agents need to rely on ground truth feedback from the environment at each step to judge progress5, this ENOENT is all the feedback it got.
Official's suggestion on tool engineering is exactly for this gap: when tool calls raise errors, the error responses themselves should be well-written, explaining specific, actionable improvements clearly, instead of throwing an opaque error code or stack trace4. So what needs changing this time isn't the prompt, it's read_file's error message.
Step five: re-run comparison (stubs pinned the model side, can do full re-run here)
Fix method in next section, after running come back to see if the numbers changed. Locating doesn't end at "I know the cause," it ends at "after fixing, that step on the same trace really is different."
Fix and re-run: v-fixed
What's changed is the code's read_file section, only the error message:
This message stuffs in three things: current state (what's actually in the directory), what to do next (retry with original filename), when to stop (if data genuinely isn't there ask a person, don't estimate). First two give the model a road to walk, the third blocks the fabrication road.
The v-fixed response queue demonstrates the model's reaction after receiving this error: no longer fabricating downward, but turning back to seek verification from environment — re-read once with the original filename listed in the error, finally in the wrap-up sentence also asks the user back "if there's other regions' data outside data/, tell me where the file is, I won't fill in numbers myself."
Tree's shape changed: that ERROR in turn-2 still sits in its original spot, but below it grew a turn-3, inside is one re-read with the correct filename. Artifact's correct:
Two summary.md byte-identical, Central China region gone.
Three runs side-by-side:
Two things must be stated clearly, or this fix is easy to misunderstand:
First, errors didn't return to zero, nor should it. That typo'd read still errored, we just swapped the error message, let the model climb out of the error. The fix that genuinely returns errors to zero is in another direction — write tool descriptions more explicitly, give examples, so the model doesn't typo in the first place. Official's diagnostic reading corresponds exactly like this: large amounts of invalid-parameter errors say tool descriptions should be clearer, examples should be fuller4. The read_file description in this script already says "Path must use original filename as returned by list_files," clearly still not enough, next round should give it a positive example.
Second, the fix isn't free. Tokens went from 6033 to 8359, up 2326, a 38% increase, the extra came from that round of re-read's round-trip. Not a blowout, but not zero cost either. Fix's cost must be put on the table and calculated, can't just look at "result's correct" and call it done.
Where this observability layer's boundaries are
This thing is small, boundaries need stating clearly, lest you think wiring it up means you have production observability.
It covers one process, one run. Logs are appendFileSync writing local file directly, process gets killed also doesn't lose — this is deliberate. Hook up real backend and it's not this treatment: on the OTLP road, export failure defaults to silent, endpoint unreachable or rejects, agent still runs, telemetry directly dropped, not even an error shows in your application; and telemetry batches before exporting on an interval, process gets killed before export, whatever's in the batch buffer is gone2. Lesson 4's "observability pipeline will silently lie to you" talks about this segment. Local file sidesteps this pit, cost is it's only on local machine.
Hooking up real backends and multi-process aggregation not in this lesson. To connect this layer to Honeycomb, Datadog, Grafana, Langfuse, or self-hosted collector, takes the OTLP protocol suite2, fields need re-mapping, that's another topic. How multiple agent process logs aggregate together, how to distinguish by service name, same deal.
Alert thresholds this lesson doesn't give numbers. "Tool error rate exceeding what should alert" "one run exceeding how many tokens counts as anomaly" — official docs only mentioned alerts should be done by your backend, didn't give any numbers3. I won't invent either. Your own thresholds can only grow from your own baseline: first run a while, see what normal run distribution looks like, then draw the line.
Content recording defaults to off. That HEAD_CHARS = 60 above only left a very short snippet. To genuinely turn on full text, prerequisite is your observability pipeline is approved to store the data your agent handles2 — pass data approval first, then change code, not the other way around.
Sampling rate, log retention window also not expanding. One run dozens of JSONL lines, locally run a few hundred times no need to manage; when you need to consider these, it's already a backend problem.
Final word: this observability layer's value isn't in how much it recorded, it's in it lets you ask a specific question. "Why did it fabricate a Central China region" is an unanswerable question; "in this run with trace_id=tr-b8934bdd, which record is the first one with error non-null, what are the params" is an answerable question. After wiring up complete production tracing, only then can you systematically diagnose why agents failed, systematically fix1.
💻 Exercises
Recap
- Observability three-piece each handles one segment: JSON Lines logs handle "record it down," trace tree handles "see order and belonging clearly," metrics summary handles "see at a glance if this run looks normal"; tree and summary both rebuild from on-disk JSONL, what's not recorded in logs will never appear in the tree
- Every record must carry
trace_id and parent_id: former circles scattered records back to same run, latter lets them rebuild into tree — this is exactly the same technique official uses to tie all events triggered by one prompt with prompt.id, filter by it to locate3
- Content defaults to not writing full text: official telemetry's default stance is structural things all recorded, agent-read/written content not collected, user prompts only record length; to enable content recording, prerequisite is your observability pipeline is approved to store this type of data2
- Those five metrics numbers (duration, call count, tokens, error count, total duration) are the same set used for grading in Course 10 (Verification and Quality Assurance: Don't Let 'Looks Right' Slip Through)4, here swapped to diagnostic use; in this round's comparison,
v-good and v-bug's round count, call count, tokens all nearly identical, only thing that changed is error count
- Locating's key action is to identify first divergence point in the tree, then treat all absurdities downstream uniformly as contagion: one step failing is enough to make the agent veer into a completely different trajectory1, going to fix that final artifact layer equals fixing a shadow
- Fixing tool error message is a fix hitting the lesion: error responses should explain specific, actionable improvements clearly, instead of throwing an opaque error code or stack trace4; after this fix the model changed from "fabricate a Central China region" to "re-read once with original filename, and turn back to ask user if there's other data"
- After fixing must re-run compare, and must acknowledge the bill:
errors didn't return to zero (typo still there), tokens up 38% (added one round-trip); "result correct" doesn't equal "cost zero"
Six lessons' main line finishes here. Lesson 1 stated clearly why you can't say — agent two runs take different roads, one symptom underneath presses several causes that look identical from outside. Lesson 2 pinned first-hand evidence on the raw transcript, not its self-report. Lesson 3 turned every step into data with fields. Lesson 4 threaded scattered data into a tree, by the way telling you this pipeline itself will silently lie. Lesson 5 installed probes at the loop's gates, gave locating's walking method. This lesson soldered the previous five lessons into a ~400-line, zero-dependency file, and used it to genuinely trace "where did Central China region come from" to that typo'd-path read in turn-2.
This is also Course 11 of this series. Next time your agent can't say where it went wrong, you no longer only have one phrase "the model made it up" in hand — you have a grep-able log, a tree where you can point at a certain line and speak, a summary table that can calculate cost, and a set of walking methods from symptom tracing to first divergence point. What remains is to entirely move the three observability segments from observed-agent.mjs (logger, trace tree, metrics summary) into your own harness, following section 7's pattern wrap those two layers around your loop — fixtures and stubs are this lesson's teaching scaffolding, don't take them — then run the first real task, see what's in that first run.log.jsonl that you originally had no idea about.