How Code-Graph-RAG Runtime Call Tracing Works: A Deep Dive into Python sys.monitoring
Code-Graph-RAG captures runtime call traces by installing a low-overhead profiler that hooks into Python's sys.monitoring API (PEP 669), recording caller-callee pairs with workload tags and sampled receiver types.
The runtime call tracing system in vitali87/code-graph-rag bridges the gap between static analysis and dynamic program behavior. Unlike traditional profilers that focus on performance metrics, this tracer builds a deterministic call graph that feeds directly into the RAG pipeline. Here's how the mechanism works under the hood.
How the CallGraphTracer Initializes
Every tracing session begins with CallGraphTracer instantiation in codebase_rag/trace/tracer.py. The constructor performs two critical setup steps:
- Root prefix calculation: Converts the repository root to an absolute path string for fast filename checks
- Exclusion cache: Builds a set of directory prefixes to skip (
site-packages,node_modules,.git, etc.) based on constants defined incodebase_rag/constants/trace.py
This upfront work minimizes per-call overhead during the actual trace. The tracer also initializes an empty _pairs dictionary that will store CodeType → call statistics mappings, and a _workload stack for tagging calls with context identifiers.
Activating the PEP 669 Monitoring Hook
The start() method in codebase_rag/trace/tracer.py (lines 74-82) registers the tracer with Python's interpreter:
import sys
from codebase_rag.constants.trace import PROFILER_ID
# Inside CallGraphTracer.start():
sys.monitoring.use_tool_id(PROFILER_ID, "cgr-trace")
sys.monitoring.register_callback(PROFILER_ID, sys.monitoring.events.PY_START, self._on_py_start)
sys.monitoring.set_events(PROFILER_ID, sys.monitoring.events.PY_START)
The PY_START event fires every time Python begins executing a code object—function calls, method invocations, lambdas, and comprehensions. Using PROFILER_ID (a reserved tool slot) avoids conflicts with debuggers or other profilers. The cgr-trace tool name appears in sys.monitoring tooling for introspection.
The _on_py_start Callback: Filtering and Recording
The heart of runtime call tracing lives in _on_py_start (lines 100-143 of codebase_rag/trace/tracer.py). This callback executes on every Python call but returns quickly for out-of-scope code:
Scope Verification
def _on_py_start(self, code: types.CodeType, instruction_offset: int) -> Any:
filename = code.co_filename
# Fast-path rejection: not under repo root or in excluded directory
if not filename.startswith(self._root_prefix):
return
if any(excl in filename for excl in self._exclusion_cache):
return
This two-stage filter eliminates standard library calls, third-party dependencies, and generated code before any expensive work occurs.
Caller-Callee Pair Extraction
For in-scope code, the tracer walks sys._getframe() to locate the immediate caller:
caller_frame = sys._getframe(1)
caller_code = caller_frame.f_code
# Build unique key from CodeType objects (identity-based, hashable)
pair_key = (caller_code, code)
# Increment call count
if pair_key in self._pairs:
record = self._pairs[pair_key]
record["count"] += 1
else:
self._pairs[pair_key] = {
"count": 1,
"workloads": set(),
"receiver_samples": [],
"receiver_types": set(),
}
Storing CodeType objects directly (rather than string representations) preserves identity across code reloads and ensures unique keys for nested functions and closures.
Workload Tagging and Receiver Sampling
Two features distinguish Code-Graph-RAG's traces from basic call logs: workload attribution and dynamic dispatch resolution.
Tagging Calls with Workload Context
# Before running code under a specific context:
tracer.set_workload("payment-service-tests")
# ... run tests ...
tracer.clear_workload()
When _on_py_start detects an active workload (checked via self._workload), it adds the identifier to the pair's workloads set. This enables later filtering—e.g., viewing only edges exercised by integration tests versus production traffic.
Sampling Receiver Types for Virtual Calls
Lines 124-143 implement targeted receiver sampling to resolve polymorphic dispatch:
from codebase_rag.constants.trace import TRACE_RECEIVER_SAMPLE_LIMIT
# Inside _on_py_start, for method calls:
if len(record["receiver_samples"]) < TRACE_RECEIVER_SAMPLE_LIMIT:
receiver = caller_frame.f_locals.get("self") or caller_frame.f_locals.get("cls")
if receiver is not None:
receiver_type = f"{receiver.__class__.__module__}.{receiver.__class__.__qualname__}"
record["receiver_samples"].append(receiver_type)
record["receiver_types"].add(receiver_type)
By capturing the actual runtime type of self or cls for the first TRACE_RECEIVER_SAMPLE_LIMIT observations (typically 10), the tracer builds evidence for which concrete methods are invoked on abstract interfaces. This turns ambiguous call-graph edges into precise dynamic dependencies.
Aggregating Records and the Trace Format
The records() method transforms internal state into exportable CallRecord objects:
from dataclasses import dataclass
from codebase_rag.trace.records import FramePoint
@dataclass(frozen=True)
class CallRecord:
caller: FramePoint # path, qualname, line number
callee: FramePoint
count: int # total invocations
workloads: tuple[str] # frozen set of workload tags
receiver_types: tuple[str] # sorted sampled types
Each FramePoint captures:
- path: Absolute file path (resolved against repo root)
- qualname: Qualified function/class name from
code.co_qualname - line: First line of the code object from
code.co_firstlineno
The structure mirrors constants in codebase_rag/constants/trace.py: TRACE_KEY_CALLER, TRACE_KEY_CALLEE, TRACE_KEY_COUNT, TRACE_KEY_WORKLOADS, and TRACE_KEY_RECEIVER_TYPES define the interchange schema (lines 10-28).
Writing Trace Files to Disk
The write() method produces line-delimited JSON suitable for streaming ingestion:
from codebase_rag.trace.records import TraceHeader, write_trace_file
def write(self, output_path: Path) -> int:
header = TraceHeader(
language="python",
repo_root=str(self._root_prefix),
tracer_name="cgr-trace",
sampled=False, # True would indicate statistical sampling
)
return write_trace_file(
output_path,
header,
(r.to_json() for r in self.records())
)
The resulting cgr-trace.jsonl contains:
- One header line with metadata
- One
CallRecordper line as compact JSON
This format integrates with GraphLoader.ingest_trace() in codebase_rag/graph_loader.py, which merges dynamic edges into the static code graph.
Complete Usage Example
from pathlib import Path
from codebase_rag.trace.tracer import CallGraphTracer
from codebase_rag.graph_loader import GraphLoader
# 1. Initialize tracer
tracer = CallGraphTracer(repo_root=Path(__file__).parent.parent)
# 2. Label workload context
tracer.set_workload("auth-flow-integration")
# 3. Capture execution
tracer.start()
from myapp.auth import authenticate, TokenValidator
validator = TokenValidator()
token = validator.issue("user-123")
authenticate(token) # Dynamic dispatch: may resolve to multiple backends
tracer.stop()
# 4. Persist trace
trace_path = Path("auth_flow_trace.jsonl")
tracer.write(trace_path)
# 5. Merge with static graph
loader = GraphLoader(repo_root=Path("."))
loader.ingest_trace(trace_path)
graph = loader.graph()
print(f"Dynamic edges added: {
sum(1 for _, _, d in graph.edges(data=True) if d.get('dynamic'))
}")
Performance Characteristics
Code-Graph-RAG's runtime call tracing achieves minimal overhead through several design choices:
- No global locks: The
_pairsdictionary is thread-safe under CPython's GIL; concurrent access uses per-key hash contention only - Lazy stringification:
FramePointobjects defer path and qualname string conversion untilrecords()is called - Bounded sampling: Receiver type collection stops after
TRACE_RECEIVER_SAMPLE_LIMITper call site - Zero-allocation fast path: Out-of-scope calls return immediately without heap allocations
Typical overhead for CPU-bound Python code ranges from 5-15%; I/O-bound workloads see negligible impact.
Summary
- sys.monitoring API: PEP 669 hooks provide compiler-assisted, low-overhead event delivery without bytecode rewriting
- CallGraphTracer: Located in
codebase_rag/trace/tracer.py, manages registration, callbacks, and aggregation - In-scope filtering: Prefix checks and exclusion lists eliminate standard library noise at the earliest possible stage
- Workload tagging: Contextual labels enable trace segmentation by test suite, API endpoint, or execution phase
- Receiver sampling: Limited capture of runtime types resolves virtual method calls for precise dynamic edges
- Trace interchange format: JSON-Lines output with schema constants in
codebase_rag/constants/trace.pyensures tool interoperability
Frequently Asked Questions
What Python versions support Code-Graph-RAG's runtime tracing?
The tracer requires Python 3.12 or later, when sys.monitoring was introduced. Earlier versions lack the stable C API for event callbacks. The repository's pyproject.toml specifies python = "^3.12" as a minimum dependency.
How does runtime tracing differ from static analysis for call graphs?
Static analysis in codebase_rag/analyzer.py parses source files to build edges based on import relationships and name resolution. Runtime tracing captures actually-executed paths, including dynamic dispatch, plugin architectures, and configuration-dependent code paths. The GraphLoader merges both: static edges provide completeness, dynamic edges add precision for hot paths and polymorphism.
Can I use runtime tracing in production environments?
Yes, with cautions. The tracer's overhead is suitable for staged environments and canary deployments. For high-throughput production, consider sampling mode (setting sampled=True in TraceHeader and using sys.monitoring event sampling when available). Always test overhead characteristics representative of your workload before full deployment.
Why does the tracer sample receiver types instead of capturing all of them?
Unbounded receiver capture would create memory pressure for long-running processes with high cardinality type spaces—common in plugin systems or generated code scenarios. The TRACE_RECEIVER_SAMPLE_LIMIT constant (default 10) provides sufficient signal for dispatch resolution while bounding memory to O(unique call sites × limit) rather than O(total invocations).
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 →