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 in codebase_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 CallRecord per 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 _pairs dictionary is thread-safe under CPython's GIL; concurrent access uses per-key hash contention only
  • Lazy stringification: FramePoint objects defer path and qualname string conversion until records() is called
  • Bounded sampling: Receiver type collection stops after TRACE_RECEIVER_SAMPLE_LIMIT per 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.py ensures 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →