Part VI: Building Your Tracing Toolkit
7.17 The Essential Tracing Toolkit Checklist
You've spent the last several sections learning about debuggers, framework tools, profilers, and tracing workflows. But here's the uncomfortable truth: knowing these tools exist doesn't mean you'll actually use them when you're stuck at 11 PM trying to figure out why a user's password reset email isn't sending.
The problem isn't knowledge—it's readiness. When you're under pressure, you default to what's immediately available and familiar. If that means print() statements, that's what you'll use, even though you know a debugger would be better.
This part is about closing that gap. We're going to build your personal tracing toolkit—not a theoretical list of tools you should know, but an actual, configured, practiced set of capabilities you can deploy in seconds when you need them.
Think of this section as your pre-flight checklist. Pilots don't figure out how to start the engines while the plane is rolling down the runway. They verify everything works before they need it. Your tracing toolkit should be the same.
Let's walk through what "ready" actually means for each tool, and more importantly, how you'll know when you've achieved it.
For Python Developers
[ ] VS Code Python debugger configured
Here's what "configured" actually means—not just installed, but battle-tested:
You should be able to open any Python project, press F5, and have the debugger start. No fumbling with launch.json, no googling error messages. This requires setup you do once, then forget about.
Let's verify your setup right now. Open VS Code in a Python project and press F5. What happens?
Scenario 1: A debugger starts and hits a breakpoint you set. Excellent—you're ready.
Scenario 2: VS Code asks you to select a debug configuration. This means you need to create launch.json. Here's what that looks like for a Django project:
{
"version": "0.2.0",
"configurations": [
{
"name": "Django: Current File",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": ["runserver", "--noreload"],
"django": true,
"justMyCode": false
}
]
}
Notice "justMyCode": false. This is critical—it's the difference between "I can only debug my code" and "I can step into Django's internals to see what's actually happening." Most debugging happens at the boundaries between your code and framework code. You need to see both.
Create this file once at .vscode/launch.json in your project root, commit it to version control, and you're done. Every teammate gets the same instant-start debugging experience.
The readiness test: Can you set a breakpoint in a Django view, press F5, make a request in your browser, and have execution pause at your breakpoint? If yes, check this box. If no, stop right now and make it work. Every minute you invest here will save you hours later.
[ ] Django Debug Toolbar installed and understood
"Installed and understood" is doing heavy lifting here. Let's break it down.
Installation verification: Add this to your Django project's settings.py:
# In INSTALLED_APPS
INSTALLED_APPS = [
# ...
'debug_toolbar',
]
# In MIDDLEWARE (near the top)
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware',
# ...
]
# At the bottom of settings.py
INTERNAL_IPS = ['127.0.0.1']
And add this to your main urls.py:
from django.conf import settings
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
Run your Django dev server and open any page. You should see a collapsible toolbar on the right side of your browser window. If you don't see it, the most common issue is INTERNAL_IPS. Your request IP must match one of those addresses.
But installation is only half the story. "Understood" means you know what each panel tells you and when to look at it. Let's walk through a real scenario.
You load a page and it feels slow. Where do you start? Open the Debug Toolbar and click the "Time" panel. You see the total request took 847ms. That's your baseline. Now look at the breakdown:
SQL queries: 623ms (47 queries)
Template rendering: 89ms
Python execution: 135ms
The SQL queries are the problem. Notice this carefully: you didn't need to guess. You didn't need to add timing statements around different parts of your code. The Debug Toolbar showed you exactly where the time went.
Now click the "SQL" panel. You see 47 queries listed. Scroll down and you notice the same query repeated 15 times, just with different IDs:
SELECT * FROM products_review WHERE product_id = 123
SELECT * FROM products_review WHERE product_id = 124
SELECT * FROM products_review WHERE product_id = 125
...
This is the classic N+1 query problem, and you spotted it in 30 seconds. You didn't need to enable query logging, parse logs, or instrument your ORM calls. The Debug Toolbar just showed you.
The readiness test: Can you look at the Debug Toolbar and immediately identify whether performance issues are in SQL, templates, or Python code? Can you spot N+1 queries? If yes, this box is checked. If you've never actually used the toolbar to investigate a real slowness issue, you're not ready yet.
[ ] py-spy installed globally
py-spy is your production-safe profiler. Unlike most profilers, it doesn't require code changes and has near-zero overhead. You attach it to a running Python process and it samples the call stack.
Installation is genuinely simple:
pip install py-spy
# or
cargo install py-spy
But "installed globally" means something specific: you should be able to type py-spy from any directory and have it work. Test this right now:
py-spy --help
If that works, you're installed. But are you ready to actually use it?
Here's the scenario where py-spy saves you: your Django application is running but you can't attach a debugger because it's on a server, or because stopping execution would disrupt testing, or because the slowness only reproduces under load. Traditional debuggers won't help. Profilers that require code changes won't help. But py-spy will.
Let's practice. Start a Django dev server in one terminal:
python manage.py runserver
In another terminal, find its process ID:
ps aux | grep "manage.py runserver"
You'll see output like:
username 34521 0.3 0.5 python manage.py runserver
That 34521 is the PID. Now attach py-spy:
py-spy top --pid 34521
You'll see a live-updating display of where Python is spending time—functions sorted by how often they appear in stack traces. While this runs, make some requests to your Django app. Watch the py-spy output change to show where time is being spent.
Now try capturing a flamegraph:
py-spy record -o profile.svg --pid 34521
Let it run for 30 seconds while you use your application, then Ctrl+C. Open profile.svg in a browser. You're looking at a visual representation of your call stacks—wide bars are functions where lots of time was spent.
The readiness test: Can you attach py-spy to a running Python process without looking up the syntax? Do you understand what the flamegraph is showing you? If you've never actually captured and analyzed a profile, this box isn't checked yet.
[ ] Basic pdb commands memorized
"Memorized" doesn't mean you can recite them. It means muscle memory—when you're in pdb, your fingers know what to type without conscious thought.
Here are the commands that matter:
-
n(next): Execute the current line and move to the next one -
s(step): Step into function calls -
c(continue): Resume execution until the next breakpoint -
l(list): Show surrounding source code -
p variable_name: Print a variable's value -
pp variable_name: Pretty-print a complex structure -
w(where): Show the call stack -
u(up): Move up one stack frame -
d(down): Move down one stack frame -
q(quit): Exit the debugger
The difference between knowing these and having them memorized is speed. When you're debugging, you're building a mental model of execution flow. Every time you have to pause to remember a command, that mental model starts to decay.
Let's practice right now. Create a simple Python file:
def calculate(x, y):
result = x * 2
result += y
return result
def main():
a = 5
b = 10
c = calculate(a, b)
print(c)
if __name__ == "__main__":
main()
Add a breakpoint:
def main():
a = 5
b = 10
breakpoint() # Execution will pause here
c = calculate(a, b)
print(c)
Run it. When pdb starts, practice this sequence without looking at notes:
-
Type
lto see where you are -
Type
nto execute the next line (the call tocalculate()) -
Wait—you just stepped over the function. Let's try again:
r(return) to finish this function, thencto restart -
When you hit the breakpoint again, type
sto step intocalculate() -
Type
lto see you're now inside the function -
Type
ntwice to execute the first two lines of the function -
Type
p resultto see the current value -
Type
wto see the full call stack -
Type
qto exit
The readiness test: Can you navigate a call stack in pdb without referring to documentation? Can you switch between stepping into and stepping over function calls fluidly? If you have to think about commands, keep practicing.
[ ] sys.settrace() pattern in snippets library
This is about having a ready-to-deploy tracing pattern you can drop into any Python codebase. Not something you build from scratch each time, but a proven snippet you've already tested.
Here's the pattern you should have saved:
import sys
import functools
def trace_calls(frame, event, arg):
"""Minimal function call tracer"""
if event != 'call':
return
code = frame.f_code
filename = code.co_filename
# Only trace project code, not stdlib
if '/site-packages/' in filename or '/lib/python' in filename:
return
func_name = code.co_name
line_num = frame.f_lineno
print(f"→ {filename}:{line_num} {func_name}()")
return trace_calls
# Enable tracing
sys.settrace(trace_calls)
# Your code here
# ... do something you want to trace ...
# Disable tracing
sys.settrace(None)
But having it in a snippets library means more than just saving this code. It means you've configured your editor to insert it instantly. In VS Code, that's a user snippet:
{
"Python Function Tracer": {
"prefix": "trace",
"body": [
"import sys",
"",
"def trace_calls(frame, event, arg):",
" if event != 'call':",
" return",
" code = frame.f_code",
" filename = code.co_filename",
" if '/site-packages/' in filename:",
" return",
" func_name = code.co_name",
" print(f'→ {filename}:{code.co_firstlineno} {func_name}()')",
" return trace_calls",
"",
"sys.settrace(trace_calls)",
"$0",
"sys.settrace(None)"
],
"description": "Insert minimal function call tracer"
}
}
Now when you're in any Python file, you type trace, press Tab, and the entire pattern appears. You've gone from "I need to trace execution" to "tracing is enabled" in two keystrokes.
The readiness test: Can you enable function call tracing in a Python script in under 10 seconds without looking up syntax? If you have to reconstruct the pattern each time, you're not ready.
For JavaScript/TypeScript Developers
[ ] Chrome DevTools Sources panel mastery
"Mastery" is a strong word. Here's what it actually means: when something goes wrong in the browser, your hands know where to go without conscious thought.
Open Chrome DevTools right now (F12 or Cmd+Option+I). Click the "Sources" tab. You should see three panes: file tree on the left, source code in the middle, call stack and watch variables on the right.
Let's verify you're ready with a scenario. Say you have this React component:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
The user reports: "The name flashes 'Loading...' then shows 'undefined'." Where do you start?
If your immediate thought is "add console.log(user) after setUser", you're not ready yet. That's not wrong, but it's slow. Here's the expert move:
-
Press Cmd+P (Ctrl+P on Windows) in DevTools to open the file finder
-
Type the component filename
-
Click on the line where
setUseris called -
Click the line number to set a breakpoint
-
Trigger the bug
-
When execution pauses, hover over
userIdto see its value -
Look at the call stack pane to see what called this effect
-
Click "Step over" (F10) to execute
fetchUser() -
Expand the returned Promise in the Scope pane to see what data came back
You've just traced the entire data flow without modifying code, without reloading, without sprinkling console.log everywhere. And you did it in about 15 seconds.
But there's more to mastery. You should also know:
Conditional breakpoints: Right-click a line number, choose "Add conditional breakpoint", enter userId === 123. Now the breakpoint only triggers for that specific user. This is how you debug rare issues without pausing on every execution.
Logpoints: Right-click a line number, choose "Add logpoint", enter user. This logs the value without pausing execution—like a console.log that you don't have to remove later.
Blackboxing: Right-click a script in the file tree, choose "Add script to ignore list". Now the debugger won't pause in that file. Use this for library code you don't care about.
The readiness test: Can you set a breakpoint in a React component, inspect state at that point, and examine the call stack that led there—all without referring to documentation? Can you use conditional breakpoints to isolate rare bugs? If you still reach for console.log as your first debugging tool, keep practicing.
[ ] VS Code Node debugger configuration
This is the server-side equivalent of Chrome DevTools mastery. You should be able to debug any Node.js application in VS Code by pressing F5.
Create a .vscode/launch.json file in your Node project:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Node App",
"skipFiles": ["<node_internals>/**"],
"program": "${workspaceFolder}/src/index.js",
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
},
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand", "--no-cache"],
"console": "integratedTerminal"
}
]
}
Notice two configurations. The first debugs your main application. The second debugs your tests—incredibly powerful when a test is failing and you can't figure out why.
But here's what separates "configured" from "ready": you should understand what each field does.
skipFiles tells the debugger to skip Node.js internal files. Without this, stepping through code will take you into Node's HTTP server implementation, which you almost never want to see.
outFiles is for TypeScript projects. It tells the debugger where the compiled JavaScript lives, so it can map breakpoints from your TypeScript source to the running code.
console: "integratedTerminal" for the Jest configuration means output appears in VS Code's terminal, not the debug console. This matters for tests that check stdout.
The readiness test: Can you debug a Node.js Express server by pressing F5? Can you set a breakpoint in a route handler and have it pause when you make a request? Can you debug a failing Jest test by selecting "Debug Jest Tests" from the debug menu? If any of these require googling, you're not ready.
[ ] React/Vue/Angular DevTools installed
Installation is clicking a link, but "installed" on this checklist means "deployed in actual debugging."
Let's focus on React DevTools since it's most common, but the principle applies to all framework DevTools.
Open your React application in Chrome, then open Chrome DevTools. You should see two new tabs: "⚛️ Components" and "⚛️ Profiler". If you don't see them, install the React DevTools extension and refresh the page.
Click the Components tab. You're looking at your component tree. This is your execution map—every component currently mounted, its props, its state, its hooks.
Here's the scenario where this becomes essential: a component is re-rendering too often, causing performance issues. Your first instinct might be to add console.log('render') in every component. Don't. Use the Profiler instead.
-
Click the Profiler tab
-
Click the blue circle to start recording
-
Perform the action that feels slow
-
Click the circle again to stop recording
You now see a flame graph of component renders. Wide bars are components that took significant time. Colors indicate wasted renders (renders where props didn't change).
Click on a suspicious component. The right panel shows:
-
How long it took to render
-
Why it rendered (which prop or state changed)
-
How many times it rendered during this recording
You've just diagnosed a performance issue without instrumenting code. But here's the expert move most people miss: you can click "Record why each component rendered while profiling" in the settings. Now every render shows exactly what changed.
The readiness test: Can you identify which component in your application is rendering most frequently? Can you see what prop change triggered a render? If you've never actually profiled a real performance issue with the framework DevTools, you're not ready.
[ ] Source map debugging practiced
This is where many TypeScript developers get stuck. They set breakpoints in their TypeScript code, but when they debug, they're looking at transpiled JavaScript. Variables have mangled names. The flow is confusing. They give up on debugging.
Source maps solve this, but only if they're configured correctly. Here's what "practiced" means.
First, verify your tsconfig.json has source maps enabled:
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true
}
}
inlineSources is the key. It embeds your original TypeScript source in the source map, so the debugger can show it even if the filesystem paths don't match exactly.
Now compile your TypeScript:
tsc
Look in your output directory. For every .js file, you should see a .js.map file. If you don't, source maps aren't generating.
Open Chrome DevTools, go to Settings (F1), scroll to Sources, and check "Enable JavaScript source maps". Now load your application and open the Sources panel.
Press Cmd+P and type your TypeScript filename. You should see both the .ts and .js versions listed. Click the .ts version. Set a breakpoint. When execution pauses, you should see TypeScript syntax, not JavaScript.
But here's where "practiced" comes in: sometimes source maps break. You set a breakpoint in TypeScript, but the debugger can't find it. Or you see the JavaScript version when you step through code. Here's how you diagnose this:
-
Open the
.js.mapfile and look for"sources". It should list the TypeScript file path. -
Check that the path is correct relative to where the map file lives.
-
Look at the Network tab in DevTools. If you see failed requests for
.tsfiles, your source map paths are wrong.
The most common issue: your build tool outputs source maps with absolute paths, but the browser can't access those paths. The fix is configuring your bundler (webpack, esbuild, vite) to use relative paths.
The readiness test: Can you set a breakpoint in TypeScript code and have it pause in the original TypeScript, not transpiled JavaScript? When it does pause, can you inspect TypeScript variables by their original names? If you've never actually debugged through TypeScript with source maps working correctly, you're not ready.
[ ] console.trace() in debugging arsenal
Everyone knows console.log(). Fewer know console.trace(), which prints not just a value but the entire call stack that led to this point.
Here's why this matters. You have a function that's being called from multiple places:
function deleteUser(userId) {
// This is getting called, but from where?
console.log("Deleting user:", userId);
// ... deletion logic ...
}
You could add console.log() in every place that calls deleteUser(). Or you could use console.trace():
function deleteUser(userId) {
console.trace("deleteUser called with:", userId);
// ... deletion logic ...
}
Now when this runs, you see:
deleteUser called with: 123
deleteUser @ user-service.js:15
handleRequest @ api-handler.js:42
middleware @ auth.js:28
(anonymous) @ express.js:214
You immediately see the call chain. The function was called from handleRequest, which was called from middleware, which was called by Express routing.
But console.trace() is most powerful combined with conditional logic:
function updateCart(cart) {
// Only trace if cart total is negative (a bug)
if (cart.total < 0) {
console.trace("Negative cart total detected:", cart);
}
// ... rest of function ...
}
Now you only get trace output when the bug occurs, and you see exactly how execution reached that state.
The readiness test: When you encounter a bug where you need to know "how did we get here?", is your first instinct to use console.trace() rather than sprinkling console.log() everywhere? If you still default to logging in multiple locations, practice using trace on your next bug.
Universal Tools
These tools transcend language and should be in every developer's arsenal.
[ ] Git bisect for historical bug tracing
This is execution tracing through time. You know something worked in the past and is broken now. Which commit introduced the bug?
Manual approach: check out old commits one by one, test, narrow down the range. This works but is tedious for large commit ranges.
git bisect automates this using binary search. Let's walk through it:
git bisect start
git bisect bad HEAD # Current commit is broken
git bisect good abc123 # This old commit was working
Git checks out a commit halfway between good and bad. You test it:
# Test the application
npm test # or whatever your test is
# If it's broken:
git bisect bad
# If it works:
git bisect good
Git jumps to the midpoint of the new range. You repeat. After logâ‚‚(n) iterations, Git tells you the exact commit that introduced the bug:
abc123def is the first bad commit
commit abc123def
Author: Someone
Date: Last week
Added user caching
Now you know what changed. You can read that commit's diff to understand what broke.
But here's the expert move: you can automate the testing:
git bisect start
git bisect bad HEAD
git bisect good abc123
git bisect run npm test
Git will automatically run your test suite at each commit and mark it good or bad based on the exit code. You can walk away while it runs. When you come back, you have your answer.
The readiness test: Have you actually used git bisect to find when a bug was introduced? If you've only read about it, you're not ready. Try it on your current project: pick a feature you know worked in the past, use bisect to find when it broke (even if you already know why).
[ ] Logging aggregation setup (local or cloud)
Execution tracing often requires looking at logs across multiple requests, processes, or services. Individual log files don't cut it.
"Setup" means you have a place where logs from your application end up, searchable and filterable. This could be:
-
Local: Running the ELK stack (Elasticsearch, Logstash, Kibana) in Docker
-
Cloud: Using Papertrail, Loggly, or DataDog
-
Simple: Using a structured logging library that outputs JSON, plus
jqfor filtering
Let's focus on the simplest approach that actually works: structured logging with JSON output.
In Python:
import logging
import json
import sys
class JSONFormatter(logging.Formatter):
def format(self, record):
log_data = {
'timestamp': self.formatTime(record),
'level': record.levelname,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
}
if hasattr(record, 'request_id'):
log_data['request_id'] = record.request_id
return json.dumps(log_data)
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JSONFormatter())
logger = logging.getLogger()
logger.addHandler(handler)
logger.setLevel(logging.INFO)
In JavaScript:
const logger = {
info: (message, data = {}) => {
console.log(
JSON.stringify({
timestamp: new Date().toISOString(),
level: "INFO",
message,
...data,
}),
);
},
};
Now your logs are structured. To query them:
# Find all ERROR level logs
cat app.log | jq 'select(.level == "ERROR")'
# Find all logs for a specific request
cat app.log | jq 'select(.request_id == "abc-123")'
# Find all logs from a specific function
cat app.log | jq 'select(.function == "process_payment")'
This is infinitely more powerful than greping unstructured logs. And it's simple enough to set up in 10 minutes.
The readiness test: Can you filter your application logs by severity, module, or request ID without manually reading through log files? If you're still using tail -f and grep, you're not ready.
[ ] Basic profiler usage (language-specific)
You should be able to profile any application you work on to see where time is spent. Not for optimization (that's premature), but for understanding.
In Python:
python -m cProfile -o profile.stats my_script.py
python -m pstats profile.stats
In the pstats interface:
sort cumtime # Sort by cumulative time
stats 20 # Show top 20 functions
But raw cProfile output is hard to read. Install visualization:
pip install snakeviz
snakeviz profile.stats
This opens an interactive flamegraph in your browser.
In Node.js:
node --prof my_script.js
node --prof-process isolate-*-v8.log > profile.txt
Or use Chrome DevTools:
node --inspect my_script.js
# Open chrome://inspect in Chrome
# Click "inspect" next to your script
# Go to Profiler tab, click "Record"
The readiness test: Can you profile a script in your primary language and identify its slowest function without referring to documentation? If you've never actually profiled something, do it now on any script you have handy—even if it's not slow.
[ ] Documentation habit: Record discoveries
This isn't a tool—it's a practice. But it's the most important item on this checklist.
Every time you trace execution through an unfamiliar codebase and discover something non-obvious, write it down. Not in your brain. Not "I'll remember this." Write. It. Down.
Where? Anywhere consistent:
-
A
ARCHITECTURE.mdfile in the repo -
Comments in the code
-
Your team wiki
-
A personal notes file you commit
Here's what a good execution discovery note looks like:
## Login Flow (Django)
Traced on 2024-01-15 using Django Debug Toolbar + debugger
1. Request hits `LoginView` (django-allauth)
2. Validates form
3. Calls `perform_login()` (allauth.account.utils)
4. This triggers `user_logged_in` signal
5. Signal handler in `accounts/signals.py` updates last_login_ip
6. Another signal handler in `analytics/signals.py` creates LoginEvent
7. Session middleware saves session
8. Response redirected to LOGIN_REDIRECT_URL
**Gotcha**: The analytics LoginEvent creation sometimes fails silently if
analytics DB is down. It's in a signal handler that swallows exceptions.
Found this by setting breakpoint in signal handler and noticing exception
being caught.
**Performance**: Login took 847ms, mostly in signal handlers (623ms).
Consider async signal dispatch.
This note has everything: the discovery path (how you learned this), the execution flow, gotchas discovered, performance insights. Future you, six months from now, will be grateful.
The readiness test: Open your current project. Can you find documentation of a complex execution flow that you or a teammate traced? If not, start documenting your next tracing session. Commit to writing at least one execution flow document per month.