mirror of https://github.com/bitcoin/bitcoin
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
373 lines
13 KiB
373 lines
13 KiB
#!/usr/bin/env python3
|
|
# Copyright (c) 2022 The Bitcoin Core developers
|
|
# Distributed under the MIT software license, see the accompanying
|
|
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
|
|
|
""" Example logging Bitcoin Core mempool events using the mempool:added,
|
|
mempool:removed, mempool:replaced, and mempool:rejected tracepoints. """
|
|
|
|
import curses
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
|
|
from bcc import BPF, USDT
|
|
|
|
# BCC: The C program to be compiled to an eBPF program (by BCC) and loaded into
|
|
# a sandboxed Linux kernel VM.
|
|
PROGRAM = """
|
|
# include <uapi/linux/ptrace.h>
|
|
|
|
// The longest rejection reason is 118 chars and is generated in case of SCRIPT_ERR_EVAL_FALSE by
|
|
// strprintf("mandatory-script-verify-flag-failed (%s)", ScriptErrorString(check.GetScriptError()))
|
|
#define MAX_REJECT_REASON_LENGTH 118
|
|
// The longest string returned by RemovalReasonToString() is 'sizelimit'
|
|
#define MAX_REMOVAL_REASON_LENGTH 9
|
|
#define HASH_LENGTH 32
|
|
|
|
struct added_event
|
|
{
|
|
u8 hash[HASH_LENGTH];
|
|
s32 vsize;
|
|
s64 fee;
|
|
};
|
|
|
|
struct removed_event
|
|
{
|
|
u8 hash[HASH_LENGTH];
|
|
char reason[MAX_REMOVAL_REASON_LENGTH];
|
|
s32 vsize;
|
|
s64 fee;
|
|
u64 entry_time;
|
|
};
|
|
|
|
struct rejected_event
|
|
{
|
|
u8 hash[HASH_LENGTH];
|
|
char reason[MAX_REJECT_REASON_LENGTH];
|
|
};
|
|
|
|
struct replaced_event
|
|
{
|
|
u8 replaced_hash[HASH_LENGTH];
|
|
s32 replaced_vsize;
|
|
s64 replaced_fee;
|
|
u64 replaced_entry_time;
|
|
u8 replacement_hash[HASH_LENGTH];
|
|
s32 replacement_vsize;
|
|
s64 replacement_fee;
|
|
};
|
|
|
|
// BPF perf buffer to push the data to user space.
|
|
BPF_PERF_OUTPUT(added_events);
|
|
BPF_PERF_OUTPUT(removed_events);
|
|
BPF_PERF_OUTPUT(rejected_events);
|
|
BPF_PERF_OUTPUT(replaced_events);
|
|
|
|
int trace_added(struct pt_regs *ctx) {
|
|
struct added_event added = {};
|
|
|
|
bpf_usdt_readarg_p(1, ctx, &added.hash, HASH_LENGTH);
|
|
bpf_usdt_readarg(2, ctx, &added.vsize);
|
|
bpf_usdt_readarg(3, ctx, &added.fee);
|
|
|
|
added_events.perf_submit(ctx, &added, sizeof(added));
|
|
return 0;
|
|
}
|
|
|
|
int trace_removed(struct pt_regs *ctx) {
|
|
struct removed_event removed = {};
|
|
|
|
bpf_usdt_readarg_p(1, ctx, &removed.hash, HASH_LENGTH);
|
|
bpf_usdt_readarg_p(2, ctx, &removed.reason, MAX_REMOVAL_REASON_LENGTH);
|
|
bpf_usdt_readarg(3, ctx, &removed.vsize);
|
|
bpf_usdt_readarg(4, ctx, &removed.fee);
|
|
bpf_usdt_readarg(5, ctx, &removed.entry_time);
|
|
|
|
removed_events.perf_submit(ctx, &removed, sizeof(removed));
|
|
return 0;
|
|
}
|
|
|
|
int trace_rejected(struct pt_regs *ctx) {
|
|
struct rejected_event rejected = {};
|
|
|
|
bpf_usdt_readarg_p(1, ctx, &rejected.hash, HASH_LENGTH);
|
|
bpf_usdt_readarg_p(2, ctx, &rejected.reason, MAX_REJECT_REASON_LENGTH);
|
|
|
|
rejected_events.perf_submit(ctx, &rejected, sizeof(rejected));
|
|
return 0;
|
|
}
|
|
|
|
int trace_replaced(struct pt_regs *ctx) {
|
|
struct replaced_event replaced = {};
|
|
|
|
bpf_usdt_readarg_p(1, ctx, &replaced.replaced_hash, HASH_LENGTH);
|
|
bpf_usdt_readarg(2, ctx, &replaced.replaced_vsize);
|
|
bpf_usdt_readarg(3, ctx, &replaced.replaced_fee);
|
|
bpf_usdt_readarg(4, ctx, &replaced.replaced_entry_time);
|
|
bpf_usdt_readarg_p(5, ctx, &replaced.replacement_hash, HASH_LENGTH);
|
|
bpf_usdt_readarg(6, ctx, &replaced.replacement_vsize);
|
|
bpf_usdt_readarg(7, ctx, &replaced.replacement_fee);
|
|
|
|
replaced_events.perf_submit(ctx, &replaced, sizeof(replaced));
|
|
return 0;
|
|
}
|
|
"""
|
|
|
|
|
|
def main(bitcoind_path):
|
|
bitcoind_with_usdts = USDT(path=str(bitcoind_path))
|
|
|
|
# attaching the trace functions defined in the BPF program
|
|
# to the tracepoints
|
|
bitcoind_with_usdts.enable_probe(probe="mempool:added", fn_name="trace_added")
|
|
bitcoind_with_usdts.enable_probe(probe="mempool:removed", fn_name="trace_removed")
|
|
bitcoind_with_usdts.enable_probe(probe="mempool:replaced", fn_name="trace_replaced")
|
|
bitcoind_with_usdts.enable_probe(probe="mempool:rejected", fn_name="trace_rejected")
|
|
bpf = BPF(text=PROGRAM, usdt_contexts=[bitcoind_with_usdts])
|
|
|
|
events = []
|
|
|
|
def get_timestamp():
|
|
return datetime.now(timezone.utc)
|
|
|
|
def handle_added(_, data, size):
|
|
event = bpf["added_events"].event(data)
|
|
events.append((get_timestamp(), "added", event))
|
|
|
|
def handle_removed(_, data, size):
|
|
event = bpf["removed_events"].event(data)
|
|
events.append((get_timestamp(), "removed", event))
|
|
|
|
def handle_rejected(_, data, size):
|
|
event = bpf["rejected_events"].event(data)
|
|
events.append((get_timestamp(), "rejected", event))
|
|
|
|
def handle_replaced(_, data, size):
|
|
event = bpf["replaced_events"].event(data)
|
|
events.append((get_timestamp(), "replaced", event))
|
|
|
|
bpf["added_events"].open_perf_buffer(handle_added)
|
|
# By default, open_perf_buffer uses eight pages for a buffer, making for a total
|
|
# buffer size of 32k on most machines. In practice, this size is insufficient:
|
|
# Each `mempool:removed` event takes up 57 bytes in the buffer (32 bytes for txid,
|
|
# 9 bytes for removal reason, and 8 bytes each for vsize and fee). Full blocks
|
|
# contain around 2k transactions, requiring a buffer size of around 114kB. To cover
|
|
# this amount, 32 4k pages are required.
|
|
bpf["removed_events"].open_perf_buffer(handle_removed, page_cnt=32)
|
|
bpf["rejected_events"].open_perf_buffer(handle_rejected)
|
|
bpf["replaced_events"].open_perf_buffer(handle_replaced)
|
|
|
|
curses.wrapper(loop, bpf, events)
|
|
|
|
|
|
def loop(screen, bpf, events):
|
|
dashboard = Dashboard(screen)
|
|
while True:
|
|
try:
|
|
bpf.perf_buffer_poll(timeout=50)
|
|
dashboard.render(events)
|
|
except KeyboardInterrupt:
|
|
exit()
|
|
|
|
|
|
class Dashboard:
|
|
"""Visualization of mempool state using ncurses."""
|
|
|
|
INFO_WIN_HEIGHT = 2
|
|
EVENT_WIN_HEIGHT = 7
|
|
|
|
def __init__(self, screen):
|
|
screen.nodelay(True)
|
|
curses.curs_set(False)
|
|
self._screen = screen
|
|
self._time_started = datetime.now(timezone.utc)
|
|
self._timestamps = {"added": [], "removed": [], "rejected": [], "replaced": []}
|
|
self._event_history = {"added": 0, "removed": 0, "rejected": 0, "replaced": 0}
|
|
self._init_windows()
|
|
|
|
def _init_windows(self):
|
|
"""Initialize all windows."""
|
|
self._init_info_win()
|
|
self._init_event_count_win()
|
|
self._init_event_rate_win()
|
|
self._init_event_log_win()
|
|
|
|
@staticmethod
|
|
def create_win(x, y, height, width, title=None):
|
|
"""Helper function to create generic windows and decorate them with box and title if requested."""
|
|
win = curses.newwin(height, width, x, y)
|
|
if title:
|
|
win.box()
|
|
win.addstr(0, 2, title, curses.A_BOLD)
|
|
return win
|
|
|
|
def _init_info_win(self):
|
|
"""Create and populate the info window."""
|
|
self._info_win = Dashboard.create_win(
|
|
x=0, y=1, height=Dashboard.INFO_WIN_HEIGHT, width=22
|
|
)
|
|
self._info_win.addstr(0, 0, "Mempool Monitor", curses.A_REVERSE)
|
|
self._info_win.addstr(1, 0, "Press CTRL-C to stop.", curses.A_NORMAL)
|
|
self._info_win.refresh()
|
|
|
|
def _init_event_count_win(self):
|
|
"""Create and populate the event count window."""
|
|
self._event_count_win = Dashboard.create_win(
|
|
x=3, y=1, height=Dashboard.EVENT_WIN_HEIGHT, width=37, title="Event count"
|
|
)
|
|
header = " {:<8} {:>8} {:>7} {:>7} "
|
|
self._event_count_win.addstr(
|
|
1, 1, header.format("Event", "total", "1 min", "10 min"), curses.A_UNDERLINE
|
|
)
|
|
self._event_count_win.refresh()
|
|
|
|
def _init_event_rate_win(self):
|
|
"""Create and populate the event rate window."""
|
|
self._event_rate_win = Dashboard.create_win(
|
|
x=3, y=40, height=Dashboard.EVENT_WIN_HEIGHT, width=42, title="Event rate"
|
|
)
|
|
header = " {:<8} {:>9} {:>9} {:>9} "
|
|
self._event_rate_win.addstr(
|
|
1, 1, header.format("Event", "total", "1 min", "10 min"), curses.A_UNDERLINE
|
|
)
|
|
self._event_rate_win.refresh()
|
|
|
|
def _init_event_log_win(self):
|
|
"""Create windows showing event log. This comprises a dummy boxed window and an
|
|
inset window so line breaks don't overwrite box."""
|
|
# dummy boxed window
|
|
num_rows, num_cols = self._screen.getmaxyx()
|
|
space_above = Dashboard.INFO_WIN_HEIGHT + 1 + Dashboard.EVENT_WIN_HEIGHT + 1
|
|
box_win_height = num_rows - space_above
|
|
box_win_width = num_cols - 2
|
|
win_box = Dashboard.create_win(
|
|
x=space_above,
|
|
y=1,
|
|
height=box_win_height,
|
|
width=box_win_width,
|
|
title="Event log",
|
|
)
|
|
# actual logging window
|
|
log_lines = box_win_height - 2 # top and bottom box lines
|
|
log_line_len = box_win_width - 2 - 1 # box lines and left padding
|
|
win = win_box.derwin(log_lines, log_line_len, 1, 2)
|
|
win.idlok(True)
|
|
win.scrollok(True)
|
|
win_box.refresh()
|
|
win.refresh()
|
|
self._event_log_win_box = win_box
|
|
self._event_log_win = win
|
|
|
|
def calculate_metrics(self, events):
|
|
"""Calculate count and rate metrics."""
|
|
count, rate = {}, {}
|
|
for event_ts, event_type, event_data in events:
|
|
self._timestamps[event_type].append(event_ts)
|
|
for event_type, ts in self._timestamps.items():
|
|
# remove timestamps older than ten minutes but keep track of their
|
|
# count for the 'total' metric
|
|
#
|
|
self._event_history[event_type] += len(
|
|
[t for t in ts if Dashboard.timestamp_age(t) >= 600]
|
|
)
|
|
ts = [t for t in ts if Dashboard.timestamp_age(t) < 600]
|
|
self._timestamps[event_type] = ts
|
|
# count metric
|
|
count_1m = len([t for t in ts if Dashboard.timestamp_age(t) < 60])
|
|
count_10m = len(ts)
|
|
count_total = self._event_history[event_type] + len(ts)
|
|
count[event_type] = (count_total, count_1m, count_10m)
|
|
# rate metric
|
|
runtime = Dashboard.timestamp_age(self._time_started)
|
|
rate_1m = count_1m / min(60, runtime)
|
|
rate_10m = count_10m / min(600, runtime)
|
|
rate_total = count_total / runtime
|
|
rate[event_type] = (rate_total, rate_1m, rate_10m)
|
|
return count, rate
|
|
|
|
def _update_event_count(self, count):
|
|
"""Update the event count window."""
|
|
w = self._event_count_win
|
|
row_format = " {:<8} {:>6}tx {:>5}tx {:>5}tx "
|
|
for line, metric in enumerate(["added", "removed", "replaced", "rejected"]):
|
|
w.addstr(2 + line, 1, row_format.format(metric, *count[metric]))
|
|
w.refresh()
|
|
|
|
def _update_event_rate(self, rate):
|
|
"""Update the event rate window."""
|
|
w = self._event_rate_win
|
|
row_format = " {:<8} {:>5.1f}tx/s {:>5.1f}tx/s {:>5.1f}tx/s "
|
|
for line, metric in enumerate(["added", "removed", "replaced", "rejected"]):
|
|
w.addstr(2 + line, 1, row_format.format(metric, *rate[metric]))
|
|
w.refresh()
|
|
|
|
def _update_event_log(self, events):
|
|
"""Update the event log window."""
|
|
w = self._event_log_win
|
|
for event in events:
|
|
w.addstr(Dashboard.parse_event(event) + "\n")
|
|
w.refresh()
|
|
|
|
def render(self, events):
|
|
"""Render the dashboard."""
|
|
count, rate = self.calculate_metrics(events)
|
|
self._update_event_count(count)
|
|
self._update_event_rate(rate)
|
|
self._update_event_log(events)
|
|
events.clear()
|
|
|
|
@staticmethod
|
|
def parse_event(event):
|
|
"""Converts events into human-readable messages"""
|
|
|
|
ts_dt, type_, data = event
|
|
ts = ts_dt.strftime("%H:%M:%SZ")
|
|
if type_ == "added":
|
|
return (
|
|
f"{ts} added {bytes(data.hash)[::-1].hex()}"
|
|
f" with feerate {data.fee/data.vsize:.2f} sat/vB"
|
|
f" ({data.fee} sat, {data.vsize} vbytes)"
|
|
)
|
|
|
|
if type_ == "removed":
|
|
return (
|
|
f"{ts} removed {bytes(data.hash)[::-1].hex()}"
|
|
f" with feerate {data.fee/data.vsize:.2f} sat/vB"
|
|
f" ({data.fee} sat, {data.vsize} vbytes)"
|
|
f" received {ts_dt.timestamp()-data.entry_time:.1f} seconds ago"
|
|
f": {data.reason.decode('UTF-8')}"
|
|
)
|
|
|
|
if type_ == "rejected":
|
|
return (
|
|
f"{ts} rejected {bytes(data.hash)[::-1].hex()}"
|
|
f": {data.reason.decode('UTF-8')}"
|
|
)
|
|
|
|
if type_ == "replaced":
|
|
return (
|
|
f"{ts} replaced {bytes(data.replaced_hash)[::-1].hex()}"
|
|
f" with feerate {data.replaced_fee/data.replaced_vsize:.2f} sat/vB"
|
|
f" received {ts_dt.timestamp()-data.replaced_entry_time:.1f} seconds ago"
|
|
f" ({data.replaced_fee} sat, {data.replaced_vsize} vbytes)"
|
|
f" with {bytes(data.replacement_hash)[::-1].hex()}"
|
|
f" with feerate {data.replacement_fee/data.replacement_vsize:.2f} sat/vB"
|
|
f" ({data.replacement_fee} sat, {data.replacement_vsize} vbytes)"
|
|
)
|
|
|
|
raise NotImplementedError("Unsupported event type: {type_}")
|
|
|
|
@staticmethod
|
|
def timestamp_age(timestamp):
|
|
"""Return age of timestamp in seconds."""
|
|
return (datetime.now(timezone.utc) - timestamp).total_seconds()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("USAGE: ", sys.argv[0], "path/to/bitcoind")
|
|
exit(1)
|
|
|
|
path = sys.argv[1]
|
|
main(path)
|