How to Trace Bitcoin Core RPC commands

What happens when you run a Bitcoin RPC command like bitcoin-cli getblockchaininfo? You get an output that details the node's view of the chain: its height, best block hash, difficulty, and more. But between typing the command and seeing that output, a lot of code runs behind the scenes, and for many Bitcoin developers, that code still remains a mystery.
You can solve this mystery through a process called "tracing": following a command from the terminal to the function that handles it, and noting which part of the node it touches. This article gives you a repeatable method for tracing any Bitcoin Core RPC command using only grep, the node's log, and the source code.
I will demonstrate the method on getpeerinfo, and you can use it yourself on getblockchaininfo or any RPC command at the end. To follow along, you need a built Bitcoin Core with its source checked out (I tested on v31.1) and a regtest data directory, which we will set up first. By the end you will be able to watch a request arrive in the node's log, find the function that handles it, and note which part of the node it touches.
Step 1: Set Up
Before you start tracing, you need to set up a safe environment for it. Regtest is Bitcoin Core's private test network, which makes it the best fit: there is no real money, no outside peers, and you can generate blocks on demand. You can also pair it up with a throwaway data directory so anything you do won't touch the real node data.
The first thing is to create the directory if you don't already have it, start the daemon, and make an RPC call to confirm everything works fine.
mkdir -p /tmp/trace
bitcoind -regtest -datadir=/tmp/trace -daemon
bitcoin-cli -regtest -datadir=/tmp/trace -rpcwait getblockchaininfo
-daemon runs the node in the background so you keep your terminal. -rpcwait makes bitcoin-cli wait until the node accepts RPC calls instead of failing while the daemon is still starting. A JSON reply that includes "chain": regtest means you are ready.
Step 2: Watch the request
After your setup you need to confirm that your command reaches the node and see how the node records it. Bitcoin Core gives you two tools to do this.
The first is the getrpcinfo, which lists the RPC commands executing right now:
$ bitcoin-cli -regtest -datadir=/tmp/trace getrpcinfo
{
"active_commands": [
{
"method": "getrpcinfo",
"duration": 6686
}
],
"logpath": "/tmp/trace/regtest/debug.log"
}
getrpcinfo only lists itself because it can only catch commands that are slow enough to overlap with its call. So it helps with long jobs like rescans and not fast RPC calls. For fast commands you can turn on the rpc and http log categories at runtime, make a call, and read the log:
$ bitcoin-cli -regtest -datadir=/tmp/trace logging '["rpc", "http"]'
$ bitcoin-cli -regtest -datadir=/tmp/trace getblockcount
$ grep -E '\[(rpc|http)\]' /tmp/trace/regtest/debug.log | tail -n 2
[http] Received a POST request for / from 127.0.0.1:45394
[rpc] ThreadRPCServer method=getblockcount user=__cookie__
bitcoin-cli prefers JSON-RPC over HTTP, so the HTTP server receives a POST first, then the RPC layer dispatches the named method. user=__cookie__ means the call authenticated with the cookie file in the datadir.
You can include or exclude rpc or http, or both in your logging. The first array list enables and the second array list disables. Both are disabled here:
$ bitcoin-cli -regtest -datadir=/tmp/trace logging '[]' '["rpc", "http"]'
Step 3: Find the Handler
Every RPC name appears in the source as a string, so search for it in quotes. From the root of the Core source tree:
$ grep -rn '"getpeerinfo"' src/rpc src/wallet/rpc
src/rpc/net.cpp:124: "getpeerinfo",
src/rpc/net.cpp:202: HelpExampleCli("getpeerinfo", "")
src/rpc/net.cpp:203: + HelpExampleRpc("getpeerinfo", "")
Searching both directories also tells you the kind of RPC. Node RPCs live in src/rpc/ and wallet RPCs in src/wallet/rpc/. Every hit is in src/rpc/net.cpp, so getpeerinfo is a node command in the networking group.
Open the file at the first hit. Each RPC is an RPCHelpMan block: the name, a description, the argument list (empty here, so getpeerinfo takes no parameters), the result schema, the help examples, and finally a lambda. The lambda is the handler, the code that runs when you call the command.
The quoted search found the definition but not where the command is registered, because registration refers to it by function name. Search the bare name:
$ grep -n 'getpeerinfo' src/rpc/net.cpp
121:static RPCHelpMan getpeerinfo()
...
1208: {"network", &getpeerinfo},
$ grep -n 'RPCCommands' src/rpc/net.cpp
1203:void RegisterNetRPCCommands(CRPCTable& t)
Line 1208 sits inside RegisterNetRPCCommands, and it pairs the category network with a pointer to the function. Every RPC has an entry like this in a Register...RPCCommands function, so that function is the second thing to look for.
The same technique works from the log. Searching for the text of the line you saw in Step 2 leads to src/rpc/request.cpp:243, where the method and user are logged before the parameters are parsed.
Line numbers here are for v31.1. In another version, search by name.
Step 4: Read the handler and name its dependencies
Start at the top of the handler lambda. Its first three lines do no work for the caller. They fetch the parts of the node it needs:
NodeContext& node = EnsureAnyNodeContext(request.context);
const CConnman& connman = EnsureConnman(node);
const PeerManager& peerman = EnsurePeerman(node);
request.context carries a handle to the running node, and EnsureAnyNodeContext unwraps it into a NodeContext. EnsureConnman and EnsurePeerman then pull the connection manager and the peer manager out of it. All three are defined in src/rpc/server_util.cpp.
These lines act as a table of contents for the command. Before reading any logic, you already know getpeerinfo touches two subsystems. The rest of the handler shows how it uses them, in order:
Connection layer. connman.GetNodeStats(vstats) collects raw statistics for every open connection: addresses, bytes sent and received, ping times. Protocol layer. For each connection, peerman.GetNodeStateStats(stats.nodeid, statestats) adds what the peer manager knows about that peer.
A comment in the loop explains a subtle case: a peer can disconnect between the two calls, and then the protocol layer has nothing to report for it, so the handler skips it. After that, the handler copies fields into a JSON object with pushKV.
Use this on any command: list the Ensure... calls, then note what each one is used for.
Each helper fails loudly. If the component is missing, EnsureAnyNodeContext throws an internal error ("Node context not found"), while EnsureConnman and EnsurePeerman throw "Error: Peer-to-peer functionality missing or disabled". The call you see at the top of a handler is therefore both a map of the subsystems it uses and a list of the ways it can fail before doing any work.
Step 5: Follow the calls down and note the locks
Now follow each call into its definition. Search for the function name and print the lines after it:
$ grep -n -A14 'CConnman::GetNodeStats' src/net.cpp
3830:void CConnman::GetNodeStats(std::vector<CNodeStats>& vstats) const
3831-{
3832- vstats.clear();
3833: LOCK(m_nodes_mutex);
Write down every LOCK(...) you pass. A lock stops other threads from changing shared data while this code reads it, and the braces show how long it's held, because it releases at the end of its block. GetNodeStats holds m_nodes_mutex until it returns. GetNodeStateStats takes cs_main inside a small block that closes at line 1802, so it holds it only briefly. An editor with clangd or ctags can jump straight to a definition, but grep is enough for everything here.
Neither the getconnectioncount handler nor the getpeerinfo handler contains a LOCK line. All their locking happens in functions they call, which is why you have to follow the calls. Compare three RPCs from the same file:
| RPC | Subsystems | Locks |
|---|---|---|
getconnectioncount |
connection manager | m_nodes_mutex, briefly, inside GetNodeCount |
getpeerinfo |
connection manager, peer manager | m_nodes_mutex for the whole of GetNodeStats, with m_subver_mutex, cs_vSend, cs_vRecv inside; then, per peer, cs_main (briefly), m_peer_mutex, m_bloom_filter_mutex, m_tx_inventory_mutex, m_headers_sync_mutex |
getnetworkinfo |
connection manager, peer manager, mempool options, local address map | cs_main from the first line to the end, with m_nodes_mutex (three times) and g_maplocalhost_mutex inside |
The same file, the same category, and three very different costs. getconnectioncount is one short lock. getpeerinfo takes many locks in sequence and never holds two of the big ones at once. getnetworkinfo takes cs_main first and keeps it, so anything else that needs cs_main waits until it returns. Calls I did not follow (GetNetworksInfo, GetInfo) may take more locks.
Step 6: Record your trace
Write each trace in the same short form, so you can compare commands later:
Command: getpeerinfo
File: src/rpc/net.cpp (defined at line 121, registered at line 1208)
Key functions: EnsureAnyNodeContext, EnsureConnman, EnsurePeerman
CConnman::GetNodeStats -> CNode::CopyStats
PeerManagerImpl::GetNodeStateStats -> GetPeerRef
Locks: m_nodes_mutex (whole of GetNodeStats), with m_subver_mutex,
cs_vSend, cs_vRecv inside; per peer: cs_main (brief),
m_peer_mutex, m_bloom_filter_mutex, m_tx_inventory_mutex,
m_headers_sync_mutex
State: read only; builds a JSON array and changes nothing
The state line is the quickest to fill in once the rest is done. Here both main callees are const, and the handler holds the connection manager and peer manager as const references, so it can only read.
Practice and caveats
You can trace getblockchaininfo to test your newly acquired tracing skill. Find its file, its registration, and the components it fetches, then follow it calls and note the locks. You can check your trace in two ways. First by turning on the rpc log category and confirming the request shows up under method=getblockchaininfo. Then running the command and matching each field in the reply, such as chain and bestblockhash, to a pushKV line in the handler you found.
Tracing comes with its own caveats. Bitcoin Core moves quickly and the line numbers here are for v31.1, so search by name instead of jumping to a line. And reading source shows which code can run, not what did run, and I followed only some of the calls in each handler, so treat my lock lists as a minimum.
Conclusion
To trace an RPC command you should find the command's name in the source, then find where it is registered. Read the Ensure... calls at the top of the handler to see which parts of the node it uses. Follow those calls down, note every lock you pass, and write the result in the same short template.
In this article you saw that getconnectioncount, getpeerinfo, and getnetworkinfo all live in src/rpc/net.cpp, yet they take very different locks: one short lock, many locks in sequence, and cs_main held across the whole handler. You can't see that difference from the output, only from the code.


