# How Code-Graph-RAG Runtime Call Tracing Works with the Trace CLI

> Explore how Code-Graph-RAG runtime call tracing captures dynamic Python call graphs with its low-overhead trace CLI, recording caller-callee pairs and invocation counts.

- Repository: [Vitali Avagyan/code-graph-rag](https://github.com/vitali87/code-graph-rag)
- Tags: how-to-guide
- Published: 2026-08-16

---

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

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

1. **Scope validation** — verifies both caller and callee filenames are under `repo_root` and not excluded (lines 100-110)【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/trace/tracer.py#L100-L110】
2. **Pair accumulation** — increments a counter in `self._pairs` keyed by `(caller_code, callee_code)` tuples
3. **Workload tagging** — adds the current workload identifier to the pair's `workloads` set if one is active
4. **Receiver sampling** — for the first `TRACE_RECEIVER_SAMPLE_LIMIT` observations, captures the fully-qualified type name of `self` or `cls` to 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` — `FramePoint` objects with `path`, `qualname`, and `line`
- `count` — total invocations observed
- `workloads` — tuple of workload names that exercised this edge
- `receiver_types` — sorted tuple of sampled type strings

The field names follow the trace interchange format defined 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`, etc.)【/cache/repos/github.com/vitali87/code-graph-rag/main/codebase_rag/constants/trace.py#L10-L28】.

```python

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

1. Creates a `TraceHeader` with `language="python"`, `repo_root`, `tracer_name="cgr-trace"`, and `sampled=False`
2. Serializes the header followed by one JSON line per `CallRecord` via `write_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()`:

```python
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`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/trace/tracer.py) | Core `CallGraphTracer` class: event registration, callback handling, record aggregation |
| [`codebase_rag/constants/trace.py`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/constants/trace.py) | Interchange format constants, exclusion patterns, sampling limits |
| [`codebase_rag/trace/records.py`](https://github.com/vitali87/code-graph-rag/blob/main/codebase_rag/trace/records.py) | Data classes (`CallRecord`, `FramePoint`, `TraceHeader`) and serialization helpers |
| [`codebase_rag/graph_loader.py`](https://github.com/vitali87/code-graph-rag/blob/main/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** — `CallGraphTracer` filters by repository root and excluded directories
- **Activation** — `start()` registers `_on_py_start` for `PY_START` events via `sys.monitoring`
- **Collection** — Callbacks validate scope, count invocations, tag workloads, and sample receiver types
- **Aggregation** — `records()` produces structured `CallRecord` objects matching the interchange format
- **Output** — `write()` emits JSON-L files ingestible by `GraphLoader` to 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`](https://github.com/vitali87/code-graph-rag/blob/main/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`](https://github.com/vitali87/code-graph-rag/blob/main/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.