How Code-Graph-RAG Runtime Call Tracing Works with the Trace CLI
The code-graph-rag trace CLI captures dynamic Python call graphs using a low-overhead profiler that hooks into the interpreter's sys.monitoring API (PEP 669), recording caller-callee pairs with invocation counts, workload tags, and sampled receiver types.
Runtime call tracing in vitali87/code-graph-rag bridges the gap between static code analysis and actual program execution. By instrumenting live Python workloads, the tool reveals dynamic edges—virtual method dispatches, plugin callbacks, and factory patterns—that static analysis alone cannot resolve. This article explains the complete tracing pipeline from initialization to trace file generation.
How the CallGraphTracer Initializes and Starts
The tracing system centers on the CallGraphTracer class defined in codebase_rag/trace/tracer.py. When you instantiate a tracer, it prepares two critical data structures:
self._root_prefix— the absolute path of your repository root used to filter in-scope files- A path cache that quickly excludes system paths like
site-packagesornode_modules【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/constants/trace.py】
The start() method (lines 74-82) registers the callback _on_py_start for PY_START events using the built-in PROFILER_ID slot and marks the tool as cgr-trace:
from pathlib import Path
from codebase_rag.trace.tracer import CallGraphTracer
# Initialise tracer for the repository root
tracer = CallGraphTracer(repo_root=Path(__file__).parent.parent)
# Optionally label a workload (e.g., "unit-tests")
tracer.set_workload("unit-tests")
# Start tracing—hooks into sys.monitoring
tracer.start()
Collecting Call Pairs at Runtime
Every Python code object execution triggers _on_py_start. The callback performs four operations in sequence:
- Scope validation — verifies both caller and callee filenames are under
repo_rootand not excluded (lines 100-110)【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/trace/tracer.py#L100-L110】 - Pair accumulation — increments a counter in
self._pairskeyed by(caller_code, callee_code)tuples - Workload tagging — adds the current workload identifier to the pair's
workloadsset if one is active - Receiver sampling — for the first
TRACE_RECEIVER_SAMPLE_LIMITobservations, captures the fully-qualified type name ofselforclsto resolve dynamic dispatch targets (lines 124-143)【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/trace/tracer.py#L124-L143】
This sampling mechanism is crucial: it lets the static analysis pipeline later distinguish between BaseClass.method() and DerivedClass.method() calls without full type inference.
Aggregating and Structuring Trace Records
The records() method transforms raw pairs into structured CallRecord objects containing:
caller,callee—FramePointobjects withpath,qualname, andlinecount— total invocations observedworkloads— tuple of workload names that exercised this edgereceiver_types— sorted tuple of sampled type strings
The field names follow the trace interchange format defined in codebase_rag/constants/trace.py (TRACE_KEY_CALLER, TRACE_KEY_CALLEE, TRACE_KEY_COUNT, etc.)【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/constants/trace.py#L10-L28】.
# Stop tracing and write results
tracer.stop()
records_written = tracer.write(Path("my_trace.jsonl"))
print(f"Wrote {records_written} call records")
Writing the Trace File to Disk
The write(output_path) method (lines 66-77) produces a line-delimited JSON file:
- Creates a
TraceHeaderwithlanguage="python",repo_root,tracer_name="cgr-trace", andsampled=False - Serializes the header followed by one JSON line per
CallRecordviawrite_trace_file【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/trace/tracer.py#L66-L77】
The default output filename is cgr-trace.jsonl. This format integrates directly with GraphLoader.ingest_trace():
from codebase_rag.graph_loader import GraphLoader
loader = GraphLoader(repo_root=Path("."))
# Merge runtime observations into the static graph
loader.ingest_trace(Path("my_trace.jsonl"))
graph = loader.graph()
print(f"Graph has {graph.number_of_nodes()} nodes and {graph.number_of_edges()} edges")
Key Implementation Files
| File | Responsibility |
|---|---|
codebase_rag/trace/tracer.py |
Core CallGraphTracer class: event registration, callback handling, record aggregation |
codebase_rag/constants/trace.py |
Interchange format constants, exclusion patterns, sampling limits |
codebase_rag/trace/records.py |
Data classes (CallRecord, FramePoint, TraceHeader) and serialization helpers |
codebase_rag/graph_loader.py |
Trace ingestion and dynamic edge merging into NetworkX graphs |
Performance Characteristics
The tracer operates entirely in-process with no global locks, using the PEP 669 sys.monitoring API designed for low-overhead profiling. The overhead scales with call frequency, not graph size: each PY_START event performs only hash-map operations and conditional sampling. Memory usage is bounded by the number of unique caller-callee pairs observed, not total invocations.
Summary
- Initialization —
CallGraphTracerfilters by repository root and excluded directories - Activation —
start()registers_on_py_startforPY_STARTevents viasys.monitoring - Collection — Callbacks validate scope, count invocations, tag workloads, and sample receiver types
- Aggregation —
records()produces structuredCallRecordobjects matching the interchange format - Output —
write()emits JSON-L files ingestible byGraphLoaderto enrich static graphs with dynamic edges
Frequently Asked Questions
What Python versions support the trace CLI runtime call tracing?
The tracer requires Python 3.12 or newer because it depends on PEP 669's sys.monitoring API. Earlier Python versions lack the PY_START event registration mechanism used in codebase_rag/trace/tracer.py.
How does the tracer handle dynamic dispatch like virtual methods?
The tracer samples receiver objects (self or cls) for each caller-callee pair up to TRACE_RECEIVER_SAMPLE_LIMIT times, storing their fully-qualified type names. These receiver_types allow the static analysis pipeline to resolve which concrete implementation was actually invoked, even when the static type is abstract.
Can I trace multiple workloads in a single session?
Yes. Call tracer.set_workload("name") before executing different code paths. Each recorded pair accumulates workload identifiers in its workloads set, letting you later filter edges by which scenarios exercised them. The default workload is None if never set.
What files are excluded from tracing automatically?
Paths containing site-packages, node_modules, .git, or other patterns defined in codebase_rag/constants/trace.py are excluded. The tracer uses self._root_prefix to ensure only files under your repository root are considered in-scope.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →