When your Python code crashes, you get something like this:
ZeroDivisionError: division by zeroOne line. No variable values. No context. No idea which function was actually responsible or what state your program was in when it died.
Most developers respond by adding print statements, re-running the code, and slowly working backwards. It works, but it's slow and painful.
In this tutorial you'll learn how to build a crash debugger that captures everything automatically at the moment of crash — the full call stack, every variable value, the exception type, and the exact source code involved. This is the Python core behind TraceFlow, an AI-powered Python debugger for VS Code. The full source is on GitHub if you want to follow along.
In Part 2 we'll connect this Python tracer to a VS Code extension that highlights the crash, streams an AI diagnosis, and applies the fix in one click.
What You Will Build
A Python script that:
- Uses
sys.settrace()to intercept every line of execution and every exception in real time - Captures the full call stack, all variable values, exception type, and crash location
- Uses AST parsing to extract the exact source of the crashed function or block
- Handles both runtime exceptions and SyntaxErrors before the program even runs
- Outputs structured JSON that can be consumed by any frontend — in our case, a VS Code extension
Prerequisites
- Python 3.8+
- Basic understanding of Python functions and exceptions
- Familiarity with the
astmodule is helpful but not required
Part 1 — Understanding sys.settrace
sys.settrace() is one of Python's most powerful and least known features. It lets you register a callback function that Python calls on every single execution event — every line executed, every function called, every exception raised.
This is how debuggers like pdb work under the hood. And it's exactly what we need.
Here's the simplest possible tracer:
import sys
def tracer(frame, event, arg):
print(f"{event} — {frame.f_code.co_filename}:{frame.f_lineno}")
return tracer
sys.settrace(tracer)
def add(
The tracer function receives three arguments on every event:
frame— the current execution frame, which contains the local variables, filename, function name, and line numberevent— a string:"call","line","return", or"exception"arg— depends on the event. For exceptions it's a tuple of(exc_type, exc_value, traceback)
The tracer must return itself to keep tracing. If it returns None, Python stops calling it.
Part 2 — Setting Up Global State and project_path
Before building the tracer, we need a few globals that the whole system shares:
import sys
import os
import traceback as tb
import json
import ast
variable_history = []
crash_data = None
seen_exceptions = set()
target_vars = set()
project_path = os.path.dirname(os.path.abspath(sys.argv[1variable_history accumulates variable snapshots on every line of execution.
crash_data holds everything we captured at the moment of crash.
seen_exceptions prevents the same exception from being recorded multiple times as it bubbles up through frames.
target_vars holds the variable names we actually care about — more on that in the next part.
project_path deserves special attention. We derive it from sys.argv[1] — the file path passed when running the script. This gives us the project's root directory, which we use throughout the tracer to filter out noise. Python's standard library and third party packages all go through sys.settrace() too. We only want events from the user's own files, so we skip anything whose filename doesn't contain project_path.
Part 3 — Capturing Variables at Runtime
The variable values live in frame.f_locals — a dictionary of all local variables in the current frame. But we don't want to capture everything. Built-in names, module imports, and internal variables pollute the output.
The solution is to parse the source file with AST first and extract only the variable names that actually appear in the code:
def current_file_variable():
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
target_vars.add(node.id)
if isinstance(node, ast.arg):
target_vars.add(node.arg)ast.Store context means the variable is being assigned — x = 5 for example. ast.arg captures function parameters. Together these give us exactly the variables defined in user code, stored in the target_vars set.
Then we filter frame.f_locals against this set:
def clean_variables(variables: dict) -> dict:
result = {}
for key, value in variables.items():
if key.startswith("__") or key not in target_vars:
continue
try:
repr_val = repr(value)
We skip dunder names, skip anything not in our target set, and cap the repr length at 200 characters to avoid massive objects flooding the output. The try/except around repr() is important — some objects raise exceptions when you try to represent them as strings.
Part 4 — Building the Tracer
Now we put it together. The tracer handles two events:
"line"— record variable state as execution progresses"exception"— capture everything at the moment of crash
def tracer(frame, event, arg):
global crash_data
filename = frame.f_code.co_filename
SKIP_FUNCTIONS = {"<genexpr>", "<listcomp>", "<dictcomp>", "<setcomp>"}
if project_path not in filename:
return tracer
if event
A few important details here:
Why filter by project_path? Python's standard library and third party packages also go through the tracer. We skip anything outside the project directory so we only capture events relevant to the user's code.
Why track seen_exceptions? A single exception triggers the exception event in every frame it passes through as it bubbles up the call stack. We use id(exc_value) to deduplicate and only record the first occurrence — which is the actual crash site.
Why skip comprehensions? Generator expressions and list comprehensions create their own internal frames with names like <listcomp>. Crashes inside them are better attributed to the enclosing function, so we skip these frames entirely.
Why keep only the last 20 variable history entries? We record state on every line execution. For long running programs this could be thousands of entries. The last 20 gives us the execution path immediately leading up to the crash without overwhelming the output.
Why list(reversed(stack))[1:]? We walk frame.f_back to collect the call stack, which gives us frames in reverse order — innermost first. We reverse to get outermost first, then slice off the first entry which is the tracer frame itself.
Part 5 — Extracting Source Code With AST
Knowing the crash line is useful. But knowing the full source of the crashed function is much more useful — especially when sending this context to AI for analysis.
get_function_source
We use AST to find the function definition and extract its complete source:
def get_function_source(filepath: str, function_name: str) -> dict:
with open(filepath, "r", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
lines = source.splitlines()
The parent map is the key insight here. We build it by walking the entire AST and mapping every child node's id() to its parent node. When a crash happens inside a nested function, we don't just want the inner function's source — we want the outermost enclosing function, because that gives AI the full picture. The parent map lets us walk upward from the crashed function through the AST tree until we reach the top level function definition.
get_module_source
For module level code — when crash_function is "<module>" — there's no function to extract. Instead we find the specific statement that contains the crash line:
def get_module_source(filepath: str, line_number: int) -> dict:
with open(filepath, "r", encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
lines = source.splitlines()
We walk the AST looking for any statement node whose line range contains the crash line. ast.stmt matches top level statements — assignments, function calls, loops, conditionals — which is exactly what we want for module level crashes.
Part 6 — The Decision Router: module_or_function
Now we need a function that decides which source extractor to call based on the crash type. This is module_or_function():
def module_or_function():
if crash_data["exception_type"] == "SyntaxError":
info = crash_data.pop("_syntax_info", {})
return ["syntax_error_info", {
"source": info.get("source"),
"start_line": info.get("start_line", crash_data["crash_line"]),
"end_line"
Three paths:
SyntaxError — the source was already extracted during compile time (covered in Part 7) and stored in crash_data["_syntax_info"]. We pop it out and return it. The pop() also cleans up the internal key so it doesn't appear in the final JSON output.
Module level crash — crash_function is "<module>", meaning the crash happened at the top level of the file, outside any function. We call get_module_source().
Function crash — the normal case. We call get_function_source() with the crashed function's name.
The return value is always a two-element list: a string key identifying the type, and a dict containing the source, start line, end line, and file. This structure makes it easy for the consumer — the VS Code extension — to know what kind of crash it's dealing with.
Part 7 — Handling SyntaxErrors Before Runtime
SyntaxErrors are a special case. They happen during compilation, before the tracer is even active. sys.settrace() never fires. So we handle them separately, before calling exec().
When Python compiles the code and raises a SyntaxError, we know the crash line but we have no frame to inspect. We need to figure out what source to extract manually. We try three approaches in order:
Approach 1 — Find the enclosing function via AST
Parse everything above the crash line and look for a containing FunctionDef. This works when the syntax above the crash line is valid enough to parse:
try:
tree = ast.parse(pre_crash_code)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
if node.lineno <= crash_line:
if enclosing_func is None or node.lineno > enclosing_func.lineno:
enclosing_func = node
except SyntaxError:
passApproach 2 — Find the enclosing function via raw line scan
If AST fails because the syntax is too broken to parse at all, we scan backwards through lines looking for a def statement at a lower indentation level than the crash:
for i in range(crash_line - 2, -1, -1):
stripped = lines[i].lstrip()
if stripped.startswith("def ") or stripped.startswith("async def "):
func_start_line = i
func_indent = len(lines[i]) - len
Approach 3 — Find the nearest enclosing block
If there's no enclosing function at all, we look for the nearest for, while, if, with, or try block using find_enclosing_block():
def find_enclosing_block(lines, crash_line):
BLOCK_KEYWORDS = ("for ", "while ", "with ", "if ", "elif ", "else:", "try:", "except")
crash_indent = len(lines[crash_line - 1]) - len(lines[crash_line - 1].lstrip
The logic scans backwards from the crash line looking for a block keyword at a lower indentation level than the crash line. The indentation check is critical — it confirms the crash is actually inside that block, not just near it. When found, it scans forward to find where the block ends and returns the full block source.
If all three approaches fail — no enclosing function, no enclosing block — we fall back to just the single broken line. Better than nothing.
We also extract variable values from lines above the crash by scanning the pre-crash code for assignment statements:
try:
tree = ast.parse(pre_crash_code)
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name):
line = lines[node.lineno - 1]
if "=" in line:
pre_crash_vars[target.id]
ast.Assign handles regular assignments like x = 5. ast.AnnAssign handles annotated assignments like x: int = 5. This gives AI some variable context even for SyntaxErrors where the tracer was never active.
The complete SyntaxError crash data gets stored in crash_data with a special _syntax_info key containing the extracted source. The module_or_function() function we covered in Part 6 pops this key out when routing.
Part 8 — Running the Tracer
Putting it all together, the run() function handles the full lifecycle:
def run(file_path):
global code, crash_data
file_path = os.path.abspath(file_path)
with open(file_path, "r") as f:
code = f.read()
try:
current_file_variable()
except SyntaxError:
pass
A few things worth noting here:
current_file_variable() is called first to populate target_vars before any tracing begins. If the file has a SyntaxError, this call will also raise — we catch it silently because the SyntaxError handler below will deal with it properly.
sys.path.insert(0, file_dir) ensures the target file can import its own local modules. Without this, relative imports inside the user's project would fail.
sys.settrace(None) in the finally block is non-negotiable. Always disable the tracer after execution. If you don't, it stays active for everything that runs afterwards in the same process, which causes significant performance degradation.
Part 9 — Saving Crash History
TraceFlow also includes a save_crash_history() function that persists crash data to a local JSON file:
def save_crash_history(crash_data: dict, crash_info: dict, key: str):
history_file = os.path.join(os.path.dirname(sys.argv[1]), "crash_history.json")
if os.path.exists(history_file):
with open(history_file, "r") as f:
This is currently commented out in the codebase but the design is solid. Each crash gets a timestamp, the file that crashed, the full crash data, and the extracted source info. The try/except around json.load() handles the case where the history file exists but is empty or malformed.
This is useful for building a crash history UI — showing users their past crashes, which files crash most often, and patterns in their bugs over time. A natural next feature for any debugging tool.
Part 10 — Outputting Structured JSON
Once run() completes, we check if crash data was captured and output it to stdout:
if __name__ == "__main__":
run(sys.argv[1])
if crash_data:
crash_info = module_or_function()
key, value = crash_info
value["file"] = crash_data["crash_file"]
print(
f"data:{json.dumps({'type':
The data: prefix is intentional. In Part 2 we spawn this script as a child process from a VS Code extension and read its stdout line by line. The prefix makes it easy to identify and parse our structured messages from any other output the script might produce — including tracebacks that Python itself prints to stdout.
flush=True is important too. Without it, Python's output buffering might hold the message in memory and never send it to the parent process, causing the extension to hang waiting for a response that never arrives.
The final JSON output for a crash looks like this:
{
"type": "crash",
"crash_data": {
"exception_type": "ZeroDivisionError",
"exception_message": "division by zero",
"crash_file": "/path/to/file.py",
"crash_line": 4,
"crash_function": "calculate_average",
"call_stack": [...],
"variable_history": [...
This is everything an AI needs to give a precise, accurate diagnosis — not a generic guess based on a one-line error message.
What We Built
In this tutorial you learned how to:
- Use
sys.settrace()to intercept Python execution events in real time - Capture variable values, call stacks, and exception details at crash time
- Use AST parsing to extract the exact source of a crashed function with
get_function_source() - Extract module level crash source with
get_module_source() - Route between extraction strategies with
module_or_function() - Handle SyntaxErrors before the program runs using a three-tier fallback system
- Find enclosing blocks when no function exists using
find_enclosing_block() - Persist crash history to a local JSON file
- Output structured JSON with the
data:prefix for child process communication
This Python tracer is the core of TraceFlow — an AI Python debugger for VS Code that captures this crash context, sends it to AI, and applies the fix in one click.
In Part 2 we'll build the VS Code extension side — spawning this tracer as a child process, highlighting the crashed function in red, streaming the AI response, and implementing the Apply Fix button that patches the exact lines directly in your editor.
The complete source code is available on GitHub.
