Module 2: Structured Logging and Tracing a Run
Reading a Trace Back
Description
Up through this lesson, every log line in this module ended up in the terminal — visible while the program ran, lost as soon as the terminal closed. A real system never works that way: logs get written to a file (or to an aggregation service, but the underlying mechanism is the same), precisely so they can be looked at later, once nobody's watching the terminal in real time. This lesson takes that step: traced_run writes, for the first time, to a real file — RUN_LOG.jsonl — and this lesson builds the functions that read it back.
Reading it back isn't simply "open the file and look at it" — with several runs mixed together in the same file, the real skill is filtering by trace_id and reconstructing, from just those lines, a readable narrative of what happened in a specific run. This lesson does exactly that, over a file that combines a clean run, a run with a corrected tool error, and a run that fails outright — all three, mixed in the same file, with none of them contaminating the reading of the others.
Connection to the module
This lesson doesn't add anything to run_logger.py — it uses lesson 06's final version as is, and builds, separately, the reading functions: load_events, read_trace. These functions, together with traced_run, are what lesson 08's mini-project combines to close the module.
Analogy: a flight's black box
When a plane reports an anomaly, nobody was watching every instrument in real time, seat by seat. What exists is the black box: a continuous record of every instrument reading, written as it happened, available to reconstruct the whole flight after it ended — no matter how it ended. Reconstructing a flight from its black box isn't reading an already-written narrative — it's taking thousands of individual readings, ordering them, and building, from them, the story of what happened, step by step.
RUN_LOG.jsonl is a Reservo agent run's black box. Every line is a reading — an event — recorded while the run was happening. Reading a trace back is, precisely, what an investigator does with a black box: taking the readings that correspond to a specific flight — here, a specific trace_id — and reconstructing, from them and only them, the complete story.
Worked example: a real RUN_LOG.jsonl, with three runs mixed together
Writing to a real file
logging.FileHandler replaces the previous lessons' StreamHandler without any other piece of run_logger.py having to change — the logger neither knows nor cares where its handler writes.
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] # solo archivo para esta demo -- nada a pantalla
rl.logger.setLevel(logging.INFO)
mode="w" opens the file in write mode, replacing any earlier content — the correct choice for starting a log file from scratch. The alternative, mode="a" (append), doesn't overwrite existing content — you use it in this lesson's Exercise 3.
Three runs, three different outcomes
script_ana = [
{"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 3h para Ana. Confirmación #1."}]},
]
script_luis = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "basic", "hours": 5}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Studio basic 5h cuesta $200.00."}]},
]
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)
]
with rl.traced_run("Reserva Focus pro 3h para Ana", 1):
ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script_ana)
with rl.traced_run("¿Cuánto cuesta Studio basic 5h?", 2):
ra.run_reservo_agent("¿Cuánto cuesta Studio basic 5h?", script_luis)
try:
with rl.traced_run("Reserva algo ambiguo", 3):
ra.run_reservo_agent("Reserva algo ambiguo", stuck_script, max_iterations=2)
except RuntimeError:
pass
_file_handler.close()
with open("RUN_LOG.jsonl", encoding="utf-8") as fh:
print(f"total de líneas: {sum(1 for _ in fh)}")
What to expect:
total de líneas: 20
_file_handler.close() explicitly closes the file, making sure everything written gets flushed to disk before reading it back — without this close, some lines might remain in the handler's internal buffer without having been written yet. Open RUN_LOG.jsonl with any text editor at this point: you're going to see twenty lines of real JSON, generated by your own machine, with three different trace_ids mixed together in chronological order.
load_events and read_trace: filter and reconstruct
import json
def load_events(path):
"""Lee RUN_LOG.jsonl línea por línea y devuelve la lista de dicts, en
el mismo orden en que se escribieron -- cada línea es un objeto JSON
completo, independiente de las demás."""
events = []
with open(path, encoding="utf-8") as fh:
for line in fh:
events.append(json.loads(line))
return events
def read_trace(events, trace_id):
"""Filtra por trace_id y reconstruye una narrativa legible de UN run,
ignorando por completo las líneas de cualquier otro run mezcladas en el
mismo archivo."""
own = [e for e in events if e["trace_id"] == trace_id]
lines = []
for e in own:
if e["event"] == "run_started":
lines.append(f"[{e['seq']}] RUN INICIADO : {e['question']!r}")
elif e["event"] == "tool_use":
lines.append(f"[{e['seq']}] paso {e['step']}: llamando {e['tool']}")
elif e["event"] == "tool_result":
tag = " [ERROR]" if e["is_error"] else ""
lines.append(f"[{e['seq']}] paso {e['step']}: resultado{tag}: {e['content']}")
elif e["event"] == "run_finished":
estado = "con errores" if e["tool_errors"] else "limpio"
lines.append(f"[{e['seq']}] RUN COMPLETADO ({estado}, {e['tool_errors']} tool_errors)")
elif e["event"] == "run_failed":
lines.append(f"[{e['seq']}] RUN FALLIDO : {e['error']}")
return lines
read_trace only recognizes the INFO/ERROR/WARNING-level events (run_started, tool_use, tool_result, run_finished, run_failed) — not DEBUG's _detail ones, which in this file don't even exist because the logger was configured at INFO. If the file did have _detail events, read_trace would simply ignore them (no elif recognizes them), which is exactly the correct behavior for this summarized narrative.
Reconstructing all three runs, one by one
events = load_events("RUN_LOG.jsonl")
trace_ids = sorted({e["trace_id"] for e in events}, key=lambda t: next(e["seq"] for e in events if e["trace_id"] == t))
print(f"trace_ids distintos en el archivo: {len(trace_ids)}")
print()
for trace_id in trace_ids:
print(f"=== reconstruyendo {trace_id} ===")
for line in read_trace(events, trace_id):
print(" ", line)
print()
What to expect:
trace_ids distintos en el archivo: 3
=== reconstruyendo run-8487582448eb ===
[1] RUN INICIADO : 'Reserva Focus pro 3h para Ana'
[2] paso 1: llamando list_rooms
[4] paso 1: resultado: [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
[6] paso 2: llamando get_quote
[8] paso 2: resultado [ERROR]: 'tier'='premium' no está en enum ['basic', 'pro']
[10] paso 3: llamando get_quote
[12] paso 3: resultado: {"price_cents": 6000}
[14] paso 4: llamando book_room
[16] paso 4: resultado: {"booking_id": 1, "confirmed": true}
[18] RUN COMPLETADO (con errores, 1 tool_errors)
=== reconstruyendo run-ea4e21ce8f78 ===
[19] RUN INICIADO : '¿Cuánto cuesta Studio basic 5h?'
[20] paso 1: llamando get_quote
[22] paso 1: resultado: {"price_cents": 20000}
[24] RUN COMPLETADO (limpio, 0 tool_errors)
=== reconstruyendo run-327ad4677ded ===
[25] RUN INICIADO : 'Reserva algo ambiguo'
[26] paso 1: llamando list_rooms
[28] paso 1: resultado: [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
[30] paso 2: llamando list_rooms
[32] paso 2: resultado: [{"room": "Focus", "rate_cents": 2500}, {"room": "Studio", "rate_cents": 4000}, {"room": "Boardroom", "rate_cents": 8000}]
[34] RUN FALLIDO : RuntimeError: max_iterations alcanzado (2)
This is the moment where the previous seven lessons fully pay off. All three runs ended up in the same file, with no explicit separator between them — and yet, read_trace reconstructs each one separately, with not a single line of Ana's run slipping into Luis's reading, or vice versa. The third run — the one that fails — shows exactly what Module 1 could never show: two complete steps (list_rooms twice, each with its result) before the final RUN FALLIDO. It isn't a simulation of that evidence — it's the real reconstruction, read from a real file, written by a real execution.
Common mistakes
-
Trying to load the complete
RUN_LOG.jsonlwith a singlejson.loads(archivo.read()). As lesson 03 already warned, a JSON Lines file isn't a single JSON document — it's many independent documents, one per line.load_eventsparses them one at a time, in a loop; trying to parse the whole file as if it were a single object or array fails with ajson.JSONDecodeError. -
Forgetting to close (or flush) the
FileHandlerbefore reading the file.logginghandlers can keep content in an internal buffer before physically writing it to disk. Without an explicitclose()(or at least aflush()), reading the file right after writing can return fewer lines than were actually generated. -
Comparing
trace_idpartially or case-insensitively.read_traceusese["trace_id"] == trace_id, an exact string comparison. A truncated SHA-256 hash, like this guide's, is always lowercase hexadecimal — but copying atrace_idby hand from a terminal and making a transcription error (one letter too many, one too few) makes the filter find no events at all, silently, with no error. -
Thinking
read_tracereconstructs EVERYTHING that happened, including DEBUG detail. As noted above,read_trace, as written in this lesson, only recognizes the five INFO/ERROR/WARNING-level event types. If the file contained_detailevents (from having been logged with the level atDEBUG), this version ofread_tracewould silently ignore them — a real limitation, and a natural extension for anyone who needs that level of detail in the reconstruction. -
Assuming the order of
trace_idsin asetreflects chronological order. A Pythonsetguarantees no order at all. This lesson's code explicitly sorts thetrace_ids by each one's lowestseq(key=lambda t: next(...)) — without that step, the reconstruction order would be arbitrary, although the content of each individual reconstruction would still be correct.
Exercises
Exercise 1: Reconstruct a single trace by its exact id (Easy)
Using the already-generated RUN_LOG.jsonl, call read_trace directly with Luis's run's trace_id ("run-ea4e21ce8f78"), without going through the loop that walks all three. Confirm you get only that run's four lines.
See solution
events = load_events("RUN_LOG.jsonl")
lines = read_trace(events, "run-ea4e21ce8f78")
for line in lines:
print(line)
Expected output:
[19] RUN INICIADO : '¿Cuánto cuesta Studio basic 5h?'
[20] paso 1: llamando get_quote
[22] paso 1: resultado: {"price_cents": 20000}
[24] RUN COMPLETADO (limpio, 0 tool_errors)
Explanation: read_trace filters over all the file's events (events, all twenty), but the result is exactly the four lines belonging to that trace_id — the sixteen lines from the other two runs never show up, because own = [e for e in events if e["trace_id"] == trace_id] discards them before building any output line.
Exercise 2: Find every failed run in a file (Medium)
Write a find_failed_traces(events) function that returns the set of trace_ids whose last event (or any event) is run_failed. Apply it to RUN_LOG.jsonl and confirm it returns exactly the trace_id for the "Reserva algo ambiguo" run.
See solution
def find_failed_traces(events):
failed = set()
for e in events:
if e["event"] == "run_failed":
failed.add(e["trace_id"])
return failed
events = load_events("RUN_LOG.jsonl")
print("traces fallidos:", find_failed_traces(events))
Expected output:
traces fallidos: {'run-327ad4677ded'}
Explanation: only one trace_id, out of the three present in the file, has a run_failed event — the one for "Reserva algo ambiguo", the stuck_script. This function is the direct foundation for the aggregate report lesson 08's mini-project builds: instead of reading trace by trace by hand, it lets you answer, all at once, "which runs in this batch failed?"
Exercise 3: Add a fourth run to the file with mode="a", and find which tools failed across the whole batch (Hard)
Reopen the FileHandler with mode="a" (append, without overwriting existing content) and run a fourth run: cancel_booking over an id that doesn't exist (999), which produces an error tool_result. Confirm the file now has 24 lines (the original 20 plus 4 new ones). Then write tool_with_most_errors(events), which counts is_error: True by tool name across the whole file, and confirm two tools now show up with errors: get_quote (from Ana's run) and cancel_booking (from the new run).
See solution
fh = logging.FileHandler("RUN_LOG.jsonl", mode="a", encoding="utf-8") # 'a' = agregar, no pisa lo existente
fh.setFormatter(logging.Formatter("%(message)s"))
rl.logger.handlers = [fh]
rl.logger.setLevel(logging.INFO)
script_cancel = [
{"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."}]},
]
with rl.traced_run("Cancela la reserva 999", 4):
ra.run_reservo_agent("Cancela la reserva 999", script_cancel)
fh.close()
events = load_events("RUN_LOG.jsonl")
print("total de líneas ahora:", len(events))
def tool_with_most_errors(events):
counts = {}
for e in events:
if e["event"] == "tool_result" and e["is_error"]:
counts[e["tool"]] = counts.get(e["tool"], 0) + 1
return counts
print("errores por tool:", tool_with_most_errors(events))
Expected output:
total de líneas ahora: 24
errores por tool: {'get_quote': 1, 'cancel_booking': 1}
Explanation: mode="a" appends the four new lines (run_started, tool_use, tool_result with error, run_finished) to the end of the existing file, without touching the previous twenty — the same mechanism a real system running continuously would use, appending every new run to the same log file over time. tool_with_most_errors walks the entire file, regardless of which trace_id each event belongs to, and confirms two different tools — get_quote (Ana's invalid tier) and cancel_booking (this exercise's nonexistent id) — accumulated errors in completely different runs.
Summary and next step
- For the first time in this module, we wrote
RUN_LOG.jsonlto a real file on disk, withlogging.FileHandler— the sametraced_runfrom lesson 06, unchanged, just pointed at a different destination. - We built
load_events(parse the file line by line) andread_trace(filter bytrace_idand reconstruct a readable narrative), and tested them over a file with three mixed-together runs: one clean, one with a corrected error, and one that fails outright. - We confirmed, with real execution, that
read_tracecorrectly reconstructs each of the three runs, with none contaminating another's reading — including the run that fails, whose two partial steps appear exactly where they should, followed by theRUN FALLIDOevent.
Next lesson: 08 — Mini-Project: A Traced Reservo Run. We close the module with a larger batch of runs — including one that fails on purpose — all run with the complete instrumentation, and an aggregate report, reconstructed 100% from the log file.
Additional resources
- Python —
logging.FileHandler— The handler that writes every line to disk, including the difference betweenmode="w"andmode="a". - Python — reading files line by line — The
for line in fh:pattern that walks a file without loading it entirely into memory at once, relevant for log files that can grow quite large. - JSON Lines — The file format
RUN_LOG.jsonluses, and why it's parsed line by line instead of as a single document. - Python —
json.JSONDecodeError— The exception raised when trying to parse a complete JSON Lines file as if it were a single JSON document. - Python 3.14 — What's New — The version every line of code in this lesson ran on, including the real writing and reading of
RUN_LOG.jsonl.