# How Code-Graph-RAG Runtime Call Tracing Works: A Deep Dive into Python sys.monitoring

> Explore Code-Graph-RAG's runtime call tracing using Python sys.monitoring. Understand how caller-callee pairs and workload tags are recorded for efficient debugging and performance analysis.

- Repository: [Vitali Avagyan/code-graph-rag](https://github.com/vitali87/code-graph-rag)
- Tags: deep-dive
- Published: 2026-08-15

---

**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](https://github.com/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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/trace/tracer.py) (lines 74-82) registers the tracer with Python's interpreter:

```python
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`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/trace/tracer.py)). This callback executes on every Python call but returns quickly for out-of-scope code:

### Scope Verification

```python
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:

```python
    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

```python

# 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:

```python
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:

```python
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`](https://github.com/vitali87/code-graph-rag/blob/main/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:

```python
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`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/graph_loader.py), which merges dynamic edges into the static code graph.

## Complete Usage Example

```python
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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/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).