Module 2: Structured Logging and Tracing a Run
Mini-Project: A Traced Reservo Run
Description
Seven lessons built, separately, every piece: why print() isn't enough (02), the structured JSON format (03), a deterministic trace_id that opens and closes a run (04), instrumenting every step of the loop without touching the agent's code (05), severity levels and what to capture at each one (06), and reading a real log file back (07). This mini-project brings them all together, over a batch of tasks larger and more realistic than any previous example: four Reservo runs — two clean, one with a business error, and one that fails outright — all run with traced_run, all written to the same RUN_LOG.jsonl, and a final report reconstructed entirely from that file, with no reference at all to the Python variables that produced the original runs.
That last restriction is deliberate, and it's the whole mini-project's central point: the report doesn't read history, doesn't read any RunReport in memory — it reads, exclusively, RUN_LOG.jsonl's lines. It's the definitive proof that this module's structured logging captures everything needed, without depending on the original process still being alive.
Connection to the module
This is the synthesis of the eight lessons. There's no new piece of run_logger.py — the mini-project reuses traced_run, load_events, and read_trace exactly as they stood at the end of lessons 06 and 07, and adds a single new function, summarize_by_trace, which groups a file's events by trace_id to produce a batch summary — the piece that was missing to answer, once and for all, the question that opened this entire module: "what happened with this batch of runs?"
Worked example: four runs, one file, one report
The batch: two clean, one with a business error, one that fails
import json
import logging
import reservo_agent as ra
import run_logger as rl
_file_handler = logging.FileHandler("RUN_LOG.jsonl", mode="w", encoding="utf-8")
_file_handler.setFormatter(logging.Formatter("%(message)s"))
rl.logger.handlers = [_file_handler]
rl.logger.setLevel(logging.INFO)
script_a = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_04", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1."}]},
]
script_b = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Sofia"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Boardroom pro por 1 hora para Sofía. Total $64.00. Confirmación #2."}]},
]
script_cancel_missing = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "cancel_booking", "input": {"id": 999}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "No encontré esa reserva."}]},
]
stuck_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": f"toolu_0{n}", "name": "list_rooms", "input": {}}]}
for n in range(1, 4)
]
tasks = [
("Reserva Focus pro 3h para Ana", script_a, 10),
("Reserva Boardroom pro 1h para Sofia", script_b, 10),
("Cancela la reserva 999", script_cancel_missing, 10),
("Reserva algo ambiguo", stuck_script, 2),
]
for i, (question, script, max_iter) in enumerate(tasks, start=1):
try:
with rl.traced_run(question, i):
final, history = ra.run_reservo_agent(question, script, max_iterations=max_iter)
print(f"run {i} OK :", final["content"][0]["text"])
except RuntimeError as exc:
print(f"run {i} FALLÓ:", exc)
_file_handler.close()
What to expect:
run 1 OK : Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1.
run 2 OK : Reservé Boardroom pro por 1 hora para Sofía. Total $64.00. Confirmación #2.
run 3 OK : No encontré esa reserva.
run 4 FALLÓ: max_iterations alcanzado (2)
Notice the try/except around each task: traced_run never hides run 4's RuntimeError — it re-raises it, exactly as lesson 04 designed — so the code that calls traced_run remains responsible for deciding what to do with a run that fails. Here, it simply gets reported and the loop moves on to the batch's next task — a real batch doesn't stop because one individual run failed.
The report, reconstructed 100% from RUN_LOG.jsonl
Without using any variable from the previous block — not tasks, not history, not final — read the file from scratch and group by trace_id:
def load_events(path):
events = []
with open(path, encoding="utf-8") as fh:
for line in fh:
events.append(json.loads(line))
return events
def summarize_by_trace(events):
"""Agrupa los eventos por trace_id (preservando el orden de aparición)
y arma un resumen de una línea por run: completo o no, cuántos pasos,
cuántos tool_errors."""
order = []
by_trace = {}
for e in events:
tid = e["trace_id"]
if tid not in by_trace:
by_trace[tid] = []
order.append(tid)
by_trace[tid].append(e)
summaries = []
for tid in order:
own = by_trace[tid]
question = own[0]["question"]
steps = sum(1 for e in own if e["event"] == "tool_use")
tool_errors = sum(1 for e in own if e["event"] == "tool_result" and e["is_error"])
last = own[-1]
completed = last["event"] == "run_finished"
summaries.append({
"trace_id": tid, "question": question, "steps": steps,
"tool_errors": tool_errors, "completed": completed,
})
return summaries
events = load_events("RUN_LOG.jsonl")
summaries = summarize_by_trace(events)
print("=== resumen por trace_id, reconstruido 100% desde RUN_LOG.jsonl ===")
for s in summaries:
estado = "completado" if s["completed"] else "FALLIDO"
print(f"{s['trace_id']} {estado:10} steps={s['steps']} tool_errors={s['tool_errors']} {s['question']!r}")
print()
print("=== agregado del lote ===")
print("runs totales :", len(summaries))
print("runs completados :", sum(1 for s in summaries if s["completed"]))
print("runs fallidos :", sum(1 for s in summaries if not s["completed"]))
print("tool_errors totales :", sum(s["tool_errors"] for s in summaries))
What to expect:
=== resumen por trace_id, reconstruido 100% desde RUN_LOG.jsonl ===
run-8487582448eb completado steps=4 tool_errors=1 'Reserva Focus pro 3h para Ana'
run-c720132bf969 completado steps=3 tool_errors=0 'Reserva Boardroom pro 1h para Sofia'
run-8d26276b0d45 completado steps=1 tool_errors=1 'Cancela la reserva 999'
run-61abb643a67b FALLIDO steps=2 tool_errors=0 'Reserva algo ambiguo'
=== agregado del lote ===
runs totales : 4
runs completados : 3
runs fallidos : 1
tool_errors totales : 2
Read this output with Module 1 lesson 01's five unanswered questions firmly in mind: how many steps did each run take? — 4, 3, 1, 2, all right there, exact. Which tools did it call? — reconstructible, step by step, with lesson 07's read_trace on any of these four trace_ids. Did anything fail along the way? — yes, twice: Ana's invalid tier (tool_errors=1), and the cancel_booking over a nonexistent id (tool_errors=1). Did the run complete? — three out of four, yes; the fourth, no, and the report knows it with certainty because its last event is run_failed, not run_finished. And the run that failed — run-61abb643a67b — doesn't show up with steps=0 the way it would have with run_and_observe in Module 1: it shows up with steps=2, because the two list_rooms that did run before the RuntimeError got recorded, and summarize_by_trace counts them the same as any other step.
None of these answers depended on the Python process that ran the four runs still being alive. RUN_LOG.jsonl, a plain text file on your disk, was all the evidence needed.
The limit still deliberately left open, for the remaining modules
It's worth closing precisely on what this module does not solve — not out of oversight, but because those are, exactly, this guide's five remaining modules:
run-8487582448ebcost something, and took some time — but this module didn't calculate it. Eachtool_result'scontentfield has enough text to estimate it (withlen(text)//4andclaude-sonnet-5's fixed pricing, as Module 1 previewed), but doing it for real, broken down per tool call and aggregated over batches, is Module 3 (cost) and Module 4 (latency).- Is
tool_errors=2in this batch acceptable? This module has no criterion at all to decide that — it only measures and records. Turning that question into a deterministic PASS/FAIL, against a fixed threshold, is Module 5. - If
cancel_bookingkeeps failing over the next hundred runs, should the agent stop trying it? This module records every individual failure, but has no memory between runs — everytraced_runis independent. A circuit breaker that does remember repeated failures, across runs, is Module 6. - Is this behavior the same as the agent's previous version, or did something change? Comparing two versions of the system with the same criterion is Module 7.
Each of those modules starts, literally, from observability/run_logger.py exactly as it stood at the end of this lesson — the same traced_run, unchanged, reused as the foundation.
Common mistakes
-
Calculating the batch's summary from the Python variables in the block that ran the runs, instead of reading
RUN_LOG.jsonl. It would work just as well while the same process stays alive — but it loses exactly the property this mini-project exists to demonstrate: that the information survives the process that generated it. A real system restarts its processes constantly; its log file, it doesn't. -
Counting
stepsby summingtool_useandtool_resultseparately.summarize_by_tracecounts only thetool_useevents (e["event"] == "tool_use") — counting thetool_results too would double the step count, because every tool call generates exactly one event of each type. -
Forgetting that
completeddepends on the last event, not the absence ofrun_failed. This lesson's implementation useslast["event"] == "run_finished"— looking at thetrace_id's last event, not searching for anyrun_failedat any position. For this guide's format both criteria give the same result (a run has, at most, one closing event), but the last-event criterion is more robust if the event schema were to grow. -
Averaging
tool_errorsper run instead of summing before dividing, if you wanted to calculate the whole batch's per-tool failure rate — the same mistake Module 1's lesson 08 already warned about: summingtool_errorsand summingstepsacross every run first, and dividing after, gives a different (and more correct) rate than averaging each run's individual rates. -
Thinking this mini-project "is already" Module 3 or Module 5. It doesn't measure cost or latency (Module 3/4), and it doesn't apply any PASS/FAIL criterion (Module 5) — it only observes and records, with complete precision. That is, deliberately, this module's entire responsibility.
Exercises
Exercise 1: Add a fifth clean run and recalculate the aggregate (Easy)
With mode="a", add a fifth run — a simple quote, with no error at all — to the same RUN_LOG.jsonl. Reload the whole file and recalculate the batch's aggregate summary (total runs, completed, failed, total tool_errors).
See solution
fh = logging.FileHandler("RUN_LOG.jsonl", mode="a", encoding="utf-8")
fh.setFormatter(logging.Formatter("%(message)s"))
rl.logger.handlers = [fh]
rl.logger.setLevel(logging.INFO)
script_e = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 2}}]},
{"stop_reason": "end_turn", "content": [{"type": "text", "text": "Studio pro 2h cuesta $64.00."}]},
]
with rl.traced_run("¿Cuánto cuesta Studio pro 2h?", 5):
ra.run_reservo_agent("¿Cuánto cuesta Studio pro 2h?", script_e)
fh.close()
events = load_events("RUN_LOG.jsonl")
summaries = summarize_by_trace(events)
print("runs totales :", len(summaries))
print("runs completados :", sum(1 for s in summaries if s["completed"]))
print("runs fallidos :", sum(1 for s in summaries if not s["completed"]))
print("tool_errors totales :", sum(s["tool_errors"] for s in summaries))
Expected output:
runs totales : 5
runs completados : 4
runs fallidos : 1
tool_errors totales : 2
Explanation: the fifth run adds a new trace_id to the file (mode="a" doesn't overwrite the previous four), completed and with no errors, so runs totales rises from 4 to 5, runs completados from 3 to 4, and tool_errors totales stays at 2 — the new run added no error.
Exercise 2: Find the batch's run with the most steps (Medium)
Using Exercise 1's summaries (five runs), write code that finds the trace_id with the highest number of steps, without assuming in advance which one it is.
See solution
busiest = max(summaries, key=lambda s: s["steps"])
print(f"run con más pasos: {busiest['trace_id']} ({busiest['steps']} pasos) -- {busiest['question']!r}")
Expected output:
run con más pasos: run-8487582448eb (4 pasos) -- 'Reserva Focus pro 3h para Ana'
Explanation: max(..., key=lambda s: s["steps"]) walks the whole list of summaries and returns the one with the highest value according to the key function — with no need to sort the whole list or write a manual loop with an accumulator variable. Ana's run, with four tool calls (including the attempt rejected for an invalid tier), remains the batch's longest even after adding Exercise 1's fifth clean run.
Exercise 3: Verify the file's integrity — no tool_use without its tool_result (Hard)
Write a find_orphan_tool_use(events) function that, for each trace_id, confirms every tool_use event has a tool_result event with the same step. Apply it to this mini-project's real RUN_LOG.jsonl — it should find none, because traced_dispatch always logs both events consecutively. Then apply it to this hypothetical fragment, written by hand to test your function: it represents a process that died (say, a kill -9) right after logging step 2's tool_use, without getting to log its tool_result.
See solution
def find_orphan_tool_use(events):
"""Para cada trace_id, confirma que cada tool_use tenga su tool_result
con el mismo step. Si no lo tiene, el run murió a la mitad de un paso
-- el proceso terminó ENTRE el log del tool_use y el log del
tool_result (por ejemplo, un kill -9)."""
by_trace = {}
for e in events:
by_trace.setdefault(e["trace_id"], []).append(e)
orphans = []
for trace_id, own in by_trace.items():
uses = {e["step"] for e in own if e["event"] == "tool_use"}
results = {e["step"] for e in own if e["event"] == "tool_result"}
missing = uses - results
if missing:
orphans.append((trace_id, sorted(missing)))
return orphans
real_events = load_events("RUN_LOG.jsonl")
print("log real (completo):", find_orphan_tool_use(real_events))
# Fragmento hipotético, escrito a mano: un proceso que murió justo después
# de loguear el tool_use del paso 2, antes de que dispatch_robust
# devolviera el resultado -- nunca llegó a loguear el tool_result de ese
# paso.
synthetic_crash = [
{"trace_id": "run-hipotetico", "event": "run_started", "step": 0},
{"trace_id": "run-hipotetico", "event": "tool_use", "step": 1},
{"trace_id": "run-hipotetico", "event": "tool_result", "step": 1},
{"trace_id": "run-hipotetico", "event": "tool_use", "step": 2},
# -- el proceso murió aquí; nunca se escribió el tool_result del paso 2 --
]
print("fragmento hipotético (proceso muerto a la mitad):", find_orphan_tool_use(synthetic_crash))
Expected output:
log real (completo): []
fragmento hipotético (proceso muerto a la mitad): [('run-hipotetico', [2])]
Explanation: the real RUN_LOG.jsonl has no orphans at all, because traced_dispatch (lesson 05) always logs the tool_result immediately after receiving it from original_dispatch — there's no point in the real code where the process could "get stuck halfway" between the two events under normal conditions. The hypothetical fragment, on the other hand, does have one orphan: step 2 has a tool_use with no matching tool_result — the exact signal a real system would use to detect a process that died abruptly mid-operation, something neither run_and_observe (Module 1) nor any tool that measures "at the end" could tell apart from a run that simply never started that step.
Summary and next step
- We closed the module with a batch of four Reservo runs — two clean, one with a half-corrected business error, one that fails outright — all run with
traced_runand written to the sameRUN_LOG.jsonl. - We built
summarize_by_trace, the final piece: it groups events bytrace_idand produces a batch summary — completed/failed, steps, tool_errors — reconstructed exclusively from the log file, with no dependency on any variable from the process that generated the runs. - We confirmed, with real, cited evidence, that the five questions Module 1 left unanswered — steps, tools, failures, and now also "did it complete?" — all have an exact answer, even for the run that ended in
RuntimeError. - We precisely named what this module deliberately doesn't solve — cost, latency, a PASS/FAIL criterion, memory between runs, version comparison — and which module in this guide solves each one.
With this, Module 2 closes. You have a complete observability/run_logger.py — the JSON formatter, the deterministic trace_id, traced_run with its three severity levels, and the read-back functions — and the run-tested evidence that it solves, at the root, the limit Module 1 left open.
Next module: Module 3 — Measuring Cost and Tokens per Run. With every step of the loop now recorded and available in RUN_LOG.jsonl, that module picks back up the honest token-estimation convention (len(text)//4) and claude-sonnet-5's fixed pricing to actually calculate how much each run this module already knows how to observe cost.
Additional resources
- Python —
logging— The complete module this mini-project finishes exercising, from its most basic level (lesson 02) to persistence to a file (this lesson). - Python —
json—json.dumps/json.loads, the pair of functions behind every line ofRUN_LOG.jsonland every read-back in this module. - Python —
max/minfunctions withkey— The pattern used in Exercise 2 to find the list element with the highest value without fully sorting it. - Anthropic — Building effective agents — On why an agentic system's complete observability is the foundation any other operating discipline gets built on — this module's complete argument, closed.
- Python 3.14 — What's New — The version every line of code in this module ran on, including the final batch's real evidence.