blockyard 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +929 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +191 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +41 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1577 -0
- package/docs/ARCHITECTURE.md +1394 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +847 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +205 -0
- package/docs/INSTALL.md +547 -0
- package/docs/MEASUREMENTS.md +1401 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +212 -0
- package/docs/TROUBLESHOOTING.md +332 -0
- package/docs/USER-GUIDE.md +1262 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +2009 -0
- package/public/donate-qr.png +0 -0
- package/public/index.html +1085 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +1141 -0
- package/public/js/app.js +1386 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2830 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +315 -0
- package/public/js/details3d.js +4342 -0
- package/public/js/doom.js +31 -0
- package/public/js/dosaudio.js +48 -0
- package/public/js/dosgame.js +389 -0
- package/public/js/dosio.js +186 -0
- package/public/js/dospc.js +1353 -0
- package/public/js/dosworker.js +196 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +88 -0
- package/public/js/markets.js +395 -0
- package/public/js/mining.js +1416 -0
- package/public/js/panels.js +970 -0
- package/public/js/pricechart.js +189 -0
- package/public/js/quake.js +20 -0
- package/public/js/settings.js +1096 -0
- package/public/js/soundcard.js +459 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/js/theme.js +235 -0
- package/public/js/wolf3d.js +22 -0
- package/public/js/x86.js +1978 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +23 -0
- package/scripts/dos-bench.js +56 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +432 -0
- package/scripts/shots.mjs +278 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/tls.js +31 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +210 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2545 -0
- package/server/collect/network.js +295 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +644 -0
- package/server/http/api.js +1319 -0
- package/server/http/explorer.js +418 -0
- package/server/http/games.js +77 -0
- package/server/http/server.js +420 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +673 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/tls/selfsigned.js +160 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +102 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
// Pure parsers for the node's own log lines. No I/O here, so the formats can be
|
|
2
|
+
// unit-tested against frozen real lines (test/fixtures/log-samples.txt).
|
|
3
|
+
//
|
|
4
|
+
// Why parse logs at all when there is RPC: three things this node only exposes
|
|
5
|
+
// in its log, each confirmed against the live file --
|
|
6
|
+
// 1. bandwidth. getnettotals() answers {totalbytesrecv:0,totalbytessent:0} in
|
|
7
|
+
// this deployment, because the peer byte counters live in the forked
|
|
8
|
+
// download worker. The `[dlc] -- network recv this tick ... --` line is the
|
|
9
|
+
// real number.
|
|
10
|
+
// 2. per-peer transaction relay. getpeerinfo() answers [] in this deployment
|
|
11
|
+
// while getconnectioncount() says 13. The `[txrelay] ... via legs
|
|
12
|
+
// [0:136.38.88.88:8333 +70, ...]` line names the peers and their counts.
|
|
13
|
+
// 3. which peer served which block (`[block] stored ... (via IP:port)`).
|
|
14
|
+
// Every rule below is deliberately tolerant: an unrecognised line still becomes
|
|
15
|
+
// a generic event, because an unexpected format is information, not noise.
|
|
16
|
+
|
|
17
|
+
const TS_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\.(\d{3}) /;
|
|
18
|
+
const TAG_RE = /^\[[a-z0-9_]+(?::\d+)?\]\s*/;
|
|
19
|
+
|
|
20
|
+
// "1.0KB", "32.0 KB/s", "0.0B", "2.3MB", "128 KB". The node prints decimal
|
|
21
|
+
// units (4096 bytes renders as "4.0KB"), so decode the same way back.
|
|
22
|
+
const SIZE_UNITS = { B: 1, KB: 1e3, MB: 1e6, GB: 1e9, TB: 1e12, PB: 1e15 };
|
|
23
|
+
export function parseSize(text) {
|
|
24
|
+
if (text == null) return null;
|
|
25
|
+
const m = String(text).trim().match(/^([0-9]*\.?[0-9]+)\s*(B|KB|MB|GB|TB|PB)(?:\/s)?$/i);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const unit = m[2].toUpperCase();
|
|
28
|
+
return Math.round(parseFloat(m[1]) * SIZE_UNITS[unit]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseRate(text) {
|
|
32
|
+
if (text == null) return null;
|
|
33
|
+
const m = String(text).trim().match(/^([0-9]*\.?[0-9]+)\s*(B|KB|MB|GB|TB)\/s$/i);
|
|
34
|
+
if (!m) return null;
|
|
35
|
+
return parseFloat(m[1]) * SIZE_UNITS[m[2].toUpperCase()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// DD:HH:MM:SS as printed by the heartbeat.
|
|
39
|
+
export function parseUptime(text) {
|
|
40
|
+
const m = String(text || '').match(/^(\d+):(\d{2}):(\d{2}):(\d{2})$/);
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
const [, d, h, mi, s] = m;
|
|
43
|
+
return ((+d * 24 + +h) * 60 + +mi) * 60 * 1000 + +s * 1000;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The same node prints two different clock widths depending on the line, both
|
|
47
|
+
// seen on 2026-09-08 in the bench log: `elapsed 1:22:01` (H:MM:SS, 3 fields) and
|
|
48
|
+
// `eta 00:07:54:48` (DD:HH:MM:SS, 4 fields). Rightmost field is always seconds,
|
|
49
|
+
// so decode by counting fields instead of assuming a width -- guessing DD would
|
|
50
|
+
// have read the 1h22m elapsed as 1 day 22 minutes.
|
|
51
|
+
export function parseClock(text) {
|
|
52
|
+
const parts = String(text || '').trim().split(':');
|
|
53
|
+
if (parts.length < 2 || parts.length > 4) return null;
|
|
54
|
+
if (!parts.every((p) => /^\d+$/.test(p))) return null;
|
|
55
|
+
const [s, mi, h, d] = parts.reverse();
|
|
56
|
+
return (((+(d || 0) * 24 + +h) * 60 + +mi) * 60 + +s) * 1000;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Split an address like "1.2.3.4:8333" or a CJDNS/I2P form without a port.
|
|
60
|
+
function addrParts(s) {
|
|
61
|
+
if (!s) return null;
|
|
62
|
+
const m = s.match(/^(.+):(\d{2,5})$/);
|
|
63
|
+
if (m) return { host: m[1], port: Number(m[2]), addr: s };
|
|
64
|
+
return { host: s, port: null, addr: s };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const RULES = [
|
|
68
|
+
// [dlc] -- network recv this tick: 4.0KB (405.0B/s) | total recv: 2.3MB || disk write this tick: 0.0B (0.0B/s) | total written: 1.9MB --
|
|
69
|
+
{
|
|
70
|
+
name: 'bandwidth',
|
|
71
|
+
re: /\[dlc\]\s*--\s*network recv this tick:\s*(\S+)\s*\(([^)]+)\)\s*\|\s*total recv:\s*(\S+)\s*\|\|\s*disk write this tick:\s*(\S+)\s*\(([^)]+)\)\s*\|\s*total written:\s*(\S+)\s*--/,
|
|
72
|
+
apply(m) {
|
|
73
|
+
return {
|
|
74
|
+
kind: 'bandwidth',
|
|
75
|
+
netThisTick: parseSize(m[1]),
|
|
76
|
+
netRate: parseRate(m[2]) ?? parseSize(m[2]),
|
|
77
|
+
netTotal: parseSize(m[3]),
|
|
78
|
+
diskThisTick: parseSize(m[4]),
|
|
79
|
+
diskRate: parseRate(m[5]) ?? parseSize(m[5]),
|
|
80
|
+
diskTotal: parseSize(m[6]),
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
// [dlc] -- average since start: 0.0B/s recv, 0.0B/s write --
|
|
85
|
+
{
|
|
86
|
+
name: 'bwAverage',
|
|
87
|
+
re: /\[dlc\]\s*--\s*average since start:\s*([^\s]+)\s+recv,\s*([^\s]+)\s+write\s*--/,
|
|
88
|
+
apply(m) { return { kind: 'bw_average', avgRecv: parseRate(m[1]), avgWrite: parseRate(m[2]) }; },
|
|
89
|
+
},
|
|
90
|
+
// [dlc] -- dead-weight floor this tick: 32.0 KB/s (pool median 0.0 KB/s, absolute 32.0 KB/s) --
|
|
91
|
+
{
|
|
92
|
+
name: 'deadweight',
|
|
93
|
+
re: /\[dlc\]\s*--\s*dead-weight floor this tick:\s*([^\s]+(?:\s?[KMG]?B\/s)?)\s*\(pool median\s*([^,]+),\s*absolute\s*([^)]+)\)/,
|
|
94
|
+
apply(m) {
|
|
95
|
+
return { kind: 'deadweight', floor: parseRate(m[1].trim()), poolMedian: parseRate(m[2].trim()), absolute: parseRate(m[3].trim()) };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
// [dlc] -- peers banned: 0 of 109 --
|
|
99
|
+
{ name: 'banned', re: /\[dlc\]\s*--\s*peers banned(?: this run)?:\s*(\d+)\s+of\s*(\d+)\s*--/, apply: (m) => ({ kind: 'ban_count', banned: +m[1], of: +m[2], severity: +m[1] > 0 ? 'warn' : 'info' }) },
|
|
100
|
+
// [dlc] ranked 116 live peer(s) by a 2000-header sample in 44.6s: 39 answered, best 94 KB/s, median 63 KB/s, slowest answering 48 KB/s; the 77 silent rank last
|
|
101
|
+
{
|
|
102
|
+
name: 'ranking',
|
|
103
|
+
re: /\[dlc\]\s*ranked\s*(\d+)\s*live peer\(s\)[^.]*in\s*([\d.]+)s:\s*(\d+)\s*answered,\s*best\s*([^,]+),\s*median\s*([^,]+),\s*slowest answering\s*([^;]+);\s*the\s*(\d+)\s*silent/,
|
|
104
|
+
apply(m) {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'peer_ranking', live: +m[1], sampleSecs: +m[2], answered: +m[3],
|
|
107
|
+
best: parseRate(m[4].trim()), median: parseRate(m[5].trim()), slowest: parseRate(m[6].trim()), silent: +m[7],
|
|
108
|
+
// Warn only when the pool is mostly silent -- the phrase 'silent rank last'
|
|
109
|
+
// appears in healthy runs too, so a keyword match would cry wolf.
|
|
110
|
+
severity: (+m[3] / Math.max(1, +m[1])) < 0.4 ? 'warn' : 'info',
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
// [dl] heartbeat: tip=965993 peers=12/16 txouts=165335351 uptime=00:14:15:21 sync_failing=10
|
|
115
|
+
{
|
|
116
|
+
name: 'heartbeat',
|
|
117
|
+
re: /\[dl\]\s*heartbeat:\s*tip=(\d+)\s+peers=(\d+)\/(\d+)\s+txouts=(\d+)(?:\s+uptime=(\S+))?(?:\s+sync_failing=(\d+))?/,
|
|
118
|
+
apply(m) {
|
|
119
|
+
return {
|
|
120
|
+
kind: 'heartbeat', tip: +m[1], peersInUse: +m[2], peersWanted: +m[3],
|
|
121
|
+
txouts: +m[4], uptime: parseUptime(m[5]), syncFailing: m[6] == null ? null : +m[6],
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
// [dl] new block: height=965993 hash=0000... (+2)
|
|
126
|
+
{ name: 'newBlock', re: /\[dl\]\s*new block:\s*height=(\d+)\s+hash=([0-9a-f]{6,})(?:\s+\(\+(\d+)\))?/, apply: (m) => ({ kind: 'new_block', height: +m[1], hashPrefix: m[2], jump: m[3] == null ? 1 : +m[3] }) },
|
|
127
|
+
// [dl] announced tip height=965993 to 12/13 legs
|
|
128
|
+
{ name: 'announce', re: /\[dl\]\s*announced tip height=(\d+)\s+to\s*(\d+)\/(\d+)\s+legs/, apply: (m) => ({ kind: 'tip_announce', height: +m[1], legsReached: +m[2], legsTotal: +m[3] }) },
|
|
129
|
+
// [dl] parallel downloader wrote 7 block(s); archive now 965993
|
|
130
|
+
{ name: 'archive', re: /\[dl\]\s*parallel downloader wrote\s*(\d+)\s*block\(s\);\s*archive now\s*(\d+)/, apply: (m) => ({ kind: 'archive_write', blocks: +m[1], archiveHeight: +m[2] }) },
|
|
131
|
+
// [block] stored height=965923 hash=0000000000000000.. bytes=1464177 tx=6866 (via 193.223.81.8:8333)
|
|
132
|
+
{
|
|
133
|
+
name: 'blockStored',
|
|
134
|
+
re: /\[block\]\s*stored\s+height=(\d+)\s+hash=([0-9a-f.]+)\s+bytes=(\d+)\s+tx=(\d+)(?:\s*\(via\s*([^\)]+)\))?/,
|
|
135
|
+
apply(m) {
|
|
136
|
+
const via = addrParts(m[5]);
|
|
137
|
+
return { kind: 'block_stored', height: +m[1], hashPrefix: m[2].replace(/\.$/, ''), bytes: +m[3], txs: +m[4], via: via?.addr ?? null, viaHost: via?.host ?? null };
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
// [mempool] block 965993: removed 175 pool tx (confirmed/conflicted)
|
|
141
|
+
{ name: 'mempoolBlock', re: /\[mempool\]\s*block\s*(\d+):\s*removed\s*(\d+)\s*pool tx/, apply: (m) => ({ kind: 'mempool_block_drain', height: +m[1], removed: +m[2] }) },
|
|
142
|
+
// [tx_accept] last 30s: +77 accepted (mempool 4033) | rejected: 333 missing-inputs, 0 invalid, 18 policy | 0 already confirmed
|
|
143
|
+
{
|
|
144
|
+
name: 'txAccept',
|
|
145
|
+
re: /\[tx_accept\]\s*last\s*(\d+)s:\s*\+(\d+)\s*accepted\s*\(mempool\s*(\d+)\)\s*\|\s*rejected:\s*(\d+)\s*missing-inputs,\s*(\d+)\s*invalid,\s*(\d+)\s*policy\s*\|\s*(\d+)\s*already confirmed/,
|
|
146
|
+
apply(m) {
|
|
147
|
+
return {
|
|
148
|
+
kind: 'tx_accept', windowSec: +m[1], accepted: +m[2], mempool: +m[3],
|
|
149
|
+
rejectMissingInputs: +m[4], rejectInvalid: +m[5], rejectPolicy: +m[6], alreadyConfirmed: +m[7],
|
|
150
|
+
acceptRate: +(+m[2] / Math.max(1, +m[1])).toFixed(2),
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
// [txrelay] last 60s: +195 tx accepted via legs [0:83.106.166.127:8333 +39, 4:136.38.88.88:8333 +114] (mempool 9624)
|
|
155
|
+
{
|
|
156
|
+
name: 'txRelay',
|
|
157
|
+
re: /\[txrelay\]\s*last\s*(\d+)s:\s*\+(\d+)\s*tx accepted via legs\s*\[([^\]]*)\]\s*\(mempool\s*(\d+)\)/,
|
|
158
|
+
apply(m) {
|
|
159
|
+
const legs = [];
|
|
160
|
+
for (const part of m[3].split(',')) {
|
|
161
|
+
const pm = part.trim().match(/^(\d+):(\S+?)\s*\+(\d+)$/);
|
|
162
|
+
if (pm) {
|
|
163
|
+
const a = addrParts(pm[2]);
|
|
164
|
+
legs.push({ leg: +pm[1], addr: a?.addr ?? pm[2], host: a?.host ?? pm[2], accepted: +pm[3] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { kind: 'tx_relay', windowSec: +m[1], accepted: +m[2], legs, mempool: +m[4], relayRate: +(+m[2] / Math.max(1, +m[1])).toFixed(2) };
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
// [txrelay] last 60s: +5 tx accepted (mempool 12) -- the no-legs form
|
|
171
|
+
{ name: 'txRelayBare', re: /\[txrelay\]\s*last\s*(\d+)s:\s*\+(\d+)\s*tx accepted(?! via)\s*\(mempool\s*(\d+)\)/, apply: (m) => ({ kind: 'tx_relay', windowSec: +m[1], accepted: +m[2], legs: [], mempool: +m[3] }) },
|
|
172
|
+
// [txrelay] orphans: 0 held, 71004 parked, 16288 resolved, 54716 dropped; 1p1c: 2 accepted, 0 failed
|
|
173
|
+
{
|
|
174
|
+
name: 'orphans',
|
|
175
|
+
re: /\[txrelay\]\s*orphans:\s*(\d+)\s*held,\s*(\d+)\s*parked,\s*(\d+)\s*resolved,\s*(\d+)\s*dropped(?:;\s*1p1c:\s*(\d+)\s*accepted,\s*(\d+)\s*failed)?/,
|
|
176
|
+
apply(m) {
|
|
177
|
+
return { kind: 'orphans', held: +m[1], parked: +m[2], resolved: +m[3], dropped: +m[4], oneP1C: m[5] == null ? null : { accepted: +m[5], failed: +m[6] } };
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
// [txrelay] orphan drops: 54714 ttl, 0 evicted, 2 rejected | parents requested 76410, notfound 60971, re-requested after timeout 16714, retried on another peer 532026 (gave up 132632, in flight 379), sync deferred 15150
|
|
181
|
+
{
|
|
182
|
+
name: 'orphanDetail',
|
|
183
|
+
re: /\[txrelay\]\s*orphan drops:\s*(\d+)\s*ttl,\s*(\d+)\s*evicted,\s*(\d+)\s*rejected\s*\|\s*parents requested\s*(\d+),\s*notfound\s*(\d+),\s*re-requested after timeout\s*(\d+),\s*retried on another peer\s*(\d+)\s*\(gave up\s*(\d+),\s*in flight\s*(\d+)\)(?:,\s*sync deferred\s*(\d+))?/,
|
|
184
|
+
apply(m) {
|
|
185
|
+
return {
|
|
186
|
+
kind: 'orphan_detail', ttl: +m[1], evicted: +m[2], rejected: +m[3], requested: +m[4],
|
|
187
|
+
notfound: +m[5], reRequested: +m[6], retriedOtherPeer: +m[7], gaveUp: +m[8], inFlight: +m[9],
|
|
188
|
+
syncDeferred: m[10] == null ? null : +m[10],
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
// [dial] 208.161.116.211:8333 connected over v2
|
|
193
|
+
{ name: 'dialOk', re: /\[dial\]\s*(\S+?)\s+connected over\s*(v\d)/, apply: (m) => ({ kind: 'peer_connect', addr: m[1], host: addrParts(m[1])?.host, transport: m[2], reason: 'connected' }) },
|
|
194
|
+
// [dial] 172.233.47.67:8333 lacks NODE_WITNESS (services=0xc05) -- dropping...
|
|
195
|
+
{ name: 'dialReject', re: /\[dial\]\s*(\S+?)\s+lacks NODE_WITNESS \(services=(\S+?)\)/, apply: (m) => ({ kind: 'peer_reject', addr: m[1], host: addrParts(m[1])?.host, reason: 'lacks NODE_WITNESS', services: m[2] }) },
|
|
196
|
+
// [dial] 154.5.180.120:8333: dialing as block-relay-only (1 of 2)
|
|
197
|
+
{ name: 'dialBlockRelay', re: /\[dial\]\s*(\S+?):\s*dialing as block-relay-only\s*\((\d+) of (\d+)\)/, apply: (m) => ({ kind: 'peer_dial', addr: m[1], host: addrParts(m[1])?.host, reason: 'block-relay-only' }) },
|
|
198
|
+
// [mux:7] leg replaced: connected next pool peer 208.161.116.211:8333 (fd 266) addrv2=1
|
|
199
|
+
{ name: 'legReplaced', re: /\[mux:(\d+)\]\s*leg replaced:\s*connected next pool peer\s*(\S+)\s*\(fd\s*(\d+)\)\s*addrv2=(\d)/, apply: (m) => ({ kind: 'peer_connect', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, fd: +m[3], addrv2: m[4] === '1', reason: 'leg replaced' }) },
|
|
200
|
+
// [mux:9] next peer 86.147.78.44:8333 unreachable: connect: Operation now in progress (leg stays down)
|
|
201
|
+
{ name: 'legDown', re: /\[mux:(\d+)\]\s*next peer\s*(\S+)\s+unreachable:\s*(.+?)\s*\(leg stays down\)/, apply: (m) => ({ kind: 'peer_unreachable', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, reason: m[3].trim() }) },
|
|
202
|
+
// [dl:7] 209.38.162.73:8333 connection dropped (revents 0x11); re-dialing
|
|
203
|
+
{ name: 'legDropped', re: /\[dl:(\d+)\]\s*(\S+?)\s+connection dropped\s*\(revents\s*(\S+?)\)(?:;\s*(\S+))?/, apply: (m) => ({ kind: 'peer_drop', leg: +m[1], addr: m[2], host: addrParts(m[2])?.host, revents: m[3], follow: m[4] || null }) },
|
|
204
|
+
// [net] feeler 47.232.103.88:8333 -> dead
|
|
205
|
+
{ name: 'feeler', re: /\[net\]\s*feeler\s*(\S+?)\s*->\s*dead/, apply: (m) => ({ kind: 'feeler_dead', addr: m[1], host: addrParts(m[1])?.host }) },
|
|
206
|
+
// [check] block data is NOT laid out monotonically (first break at height 964924) -- ...
|
|
207
|
+
{ name: 'archiveHole', re: /\[check\]\s*block data is NOT laid out monotonically\s*\(first break at height\s*(\d+)\)/, apply: (m) => ({ kind: 'archive_hole', height: +m[1], severity: 'warn' }) },
|
|
208
|
+
// [config] conns: max=256 outbound=11 (full=8 blockrelay=2 feeler=1) inbound=245 feeler_every=120s
|
|
209
|
+
{
|
|
210
|
+
name: 'connBudget',
|
|
211
|
+
re: /\[config\]\s*conns:\s*max=(\d+)\s+outbound=(\d+)\s*\(full=(\d+) blockrelay=(\d+) feeler=(\d+)\)\s*inbound=(\d+)(?:\s+feeler_every=(\d+)s)?/,
|
|
212
|
+
apply(m) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'conn_budget', max: +m[1], outbound: +m[2], fullRelay: +m[3],
|
|
215
|
+
blockRelay: +m[4], feeler: +m[5], inboundCap: +m[6], feelerEverySec: m[7] == null ? null : +m[7],
|
|
216
|
+
};
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
// [mempool] recent-rejects filter: 128 KB shared (...)
|
|
220
|
+
{ name: 'rejectFilter', re: /\[mempool\]\s*recent-rejects filter:\s*([^\s]+)\s*(?:KB|MB|B)?\s+shared/, apply: (m) => ({ kind: 'reject_filter', size: parseSize(m[1] + (/\s?(KB|MB|B)$/i.test(m[1]) ? '' : 'KB')) }) },
|
|
221
|
+
|
|
222
|
+
// ---- the 2026-09-08 bench build (v0.0.1, built 03:02) rewrote these lines ----
|
|
223
|
+
// Measured the same day against that node's own log: of 1,702 lines the rules
|
|
224
|
+
// above matched 30, and of its 1,006 `[dlc]` lines exactly 1 matched. The same
|
|
225
|
+
// facts are still there, in a new grammar:
|
|
226
|
+
// prod [dlc] -- network recv this tick: 4.0KB (405.0B/s) | total recv: 2.3MB ||
|
|
227
|
+
// disk write this tick: 0.0B (0.0B/s) | total written: 1.9MB --
|
|
228
|
+
// bench [dlc] -- recv 11.2MB/s (avg 10.5MB/s) | write 11.2MB/s (avg 10.7MB/s) |
|
|
229
|
+
// floor 32.0 KB/s (median 499.6) | banned 6/121 | events 0 rot 0 ...
|
|
230
|
+
// It decodes to the SAME `bandwidth` kind, so nothing downstream has to know
|
|
231
|
+
// which build it is talking to. The new line has no running totals, so
|
|
232
|
+
// netTotal/diskTotal are absent (rendered `–`), never zero.
|
|
233
|
+
//
|
|
234
|
+
// `(median 499.6)` is printed WITHOUT a unit. It is left in `poolMedianText`
|
|
235
|
+
// and `poolMedian` stays null: if it is KB/s it is a peer speed, if it is B/s
|
|
236
|
+
// it is 2000x smaller, and the log does not say which. Rule 3 -- no invented
|
|
237
|
+
// unit to fill a gap.
|
|
238
|
+
{
|
|
239
|
+
name: 'bandwidthTick',
|
|
240
|
+
re: /\[dlc\]\s*--\s*recv\s*([^\s(]+)\s*\(avg\s*([^)]+)\)\s*\|\s*write\s*([^\s(]+)\s*\(avg\s*([^)]+)\)\s*\|\s*floor\s*([^()]+?)\s*\(median\s*([^)]+)\)\s*\|\s*banned\s*(\d+)\/(\d+)(?:\s*\|\s*(events.*?))?\s*--/,
|
|
241
|
+
apply(m) {
|
|
242
|
+
const counters = {};
|
|
243
|
+
for (const [, k, v] of (m[9] || '').matchAll(/\b(events|rot|wait|help|fail)\s+(\d+)/g)) counters[k] = +v;
|
|
244
|
+
return {
|
|
245
|
+
kind: 'bandwidth',
|
|
246
|
+
netRate: parseRate(m[1]),
|
|
247
|
+
avgNetRate: parseRate(m[2]),
|
|
248
|
+
diskRate: parseRate(m[3]),
|
|
249
|
+
avgDiskRate: parseRate(m[4]),
|
|
250
|
+
floor: parseRate((m[5] || '').trim()),
|
|
251
|
+
poolMedianText: (m[6] || '').trim() || null,
|
|
252
|
+
banned: +m[7],
|
|
253
|
+
bannedOf: +m[8],
|
|
254
|
+
worker: Object.keys(counters).length ? counters : null,
|
|
255
|
+
workerText: (m[9] || '').trim() || null,
|
|
256
|
+
// Explicit severity: the generic classifier warns on the word "banned",
|
|
257
|
+
// which would mark every 10-second tick as a warning even at banned=0.
|
|
258
|
+
severity: +m[7] > 0 ? 'warn' : 'info',
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
// The same tick line, decoded field by field instead of end to end. Written
|
|
263
|
+
// because the rigid version above died twice in two hours: at 05:42 the node
|
|
264
|
+
// added `staged 1` and 26 of 26 tick lines matched nothing; by 06:14 it had added
|
|
265
|
+
// a second one (`staged 38 commit 6288`). A labelled line is now scanned one
|
|
266
|
+
// labelled field at a time, so an added, removed or reordered field costs that
|
|
267
|
+
// field and not the measurement. Fields no rule knows about are kept verbatim in
|
|
268
|
+
// `extraValues` and named in `extraFields` -- a new field becomes visible data
|
|
269
|
+
// rather than a silently unparsed line, and nobody has to guess what `staged`
|
|
270
|
+
// means before the numbers work again.
|
|
271
|
+
//
|
|
272
|
+
// It requires two recognised fields to claim the line, so the other
|
|
273
|
+
// `[dlc] -- … --` banners (`peer status`, `peers banned: N of M`, `dead-weight
|
|
274
|
+
// floor this tick:`, `average since start:`) stay with their own rules.
|
|
275
|
+
{
|
|
276
|
+
name: 'bandwidthTickFields',
|
|
277
|
+
re: /\[dlc\]\s*--\s*(.+?)\s*--/,
|
|
278
|
+
apply(m) {
|
|
279
|
+
const out = { kind: 'bandwidth', extraFields: [], extraValues: null };
|
|
280
|
+
let claims = 0;
|
|
281
|
+
for (const seg of m[1].split('|').map((s) => s.trim()).filter(Boolean)) {
|
|
282
|
+
let r;
|
|
283
|
+
if ((r = /^recv\s+(\S+)(?:\s*\(avg\s+([^)]+)\))?/.exec(seg))) {
|
|
284
|
+
out.netRate = parseRate(r[1]); if (r[2]) out.avgNetRate = parseRate(r[2]); claims += 1;
|
|
285
|
+
} else if ((r = /^write\s+(\S+)(?:\s*\(avg\s+([^)]+)\))?/.exec(seg))) {
|
|
286
|
+
out.diskRate = parseRate(r[1]); if (r[2]) out.avgDiskRate = parseRate(r[2]); claims += 1;
|
|
287
|
+
} else if ((r = /^floor\s+(\S+\s*(?:[KMGTP]?B\/s)?)(?:\s*\(median\s+([^)]+)\))?/i.exec(seg))) {
|
|
288
|
+
out.floor = parseRate(r[1].trim());
|
|
289
|
+
// Still no unit printed for the median, so still no unit invented.
|
|
290
|
+
if (r[2]) out.poolMedianText = r[2].trim();
|
|
291
|
+
claims += 1;
|
|
292
|
+
} else if ((r = /^banned\s+(\d+)\/(\d+)/.exec(seg))) {
|
|
293
|
+
out.banned = +r[1]; out.bannedOf = +r[2]; claims += 1;
|
|
294
|
+
} else if (/^events\b/.test(seg)) {
|
|
295
|
+
const counters = {};
|
|
296
|
+
for (const [, k, v] of seg.matchAll(/\b(events|rot|wait|help|fail)\s+(\d+)/g)) counters[k] = +v;
|
|
297
|
+
if (Object.keys(counters).length) out.worker = counters;
|
|
298
|
+
out.workerText = seg;
|
|
299
|
+
claims += 1;
|
|
300
|
+
} else {
|
|
301
|
+
// One segment can carry more than one new field (`staged 26 commit 6484`),
|
|
302
|
+
// so name each `label number` pair inside it. Half-parsing a new field is
|
|
303
|
+
// still better than losing the line: the name is what tells you the node
|
|
304
|
+
// changed, and the value is what lets you decide later what it means.
|
|
305
|
+
const pairs = [...seg.matchAll(/\b([a-z][a-z0-9_]*)\s+(-?\d+(?:\.\d+)?)/g)];
|
|
306
|
+
if (pairs.length) {
|
|
307
|
+
for (const [, label, value] of pairs) {
|
|
308
|
+
(out.extraFields ||= []).push(label);
|
|
309
|
+
(out.extraValues ||= {})[label] = value;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
const label = /^([a-z][a-z0-9_]*)\b/.exec(seg)?.[1];
|
|
313
|
+
if (label) {
|
|
314
|
+
(out.extraFields ||= []).push(label);
|
|
315
|
+
(out.extraValues ||= {})[label] = seg.slice(label.length).trim();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (claims < 2) return null;
|
|
321
|
+
if (out.extraFields?.length) out.unexpected = `tick line carries fields no rule knows yet: ${out.extraFields.join(', ')}`;
|
|
322
|
+
out.severity = (out.banned ?? 0) > 0 ? 'warn' : 'info';
|
|
323
|
+
return out;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
// Field-scanned progress line. The rigid rule above died on 2026-09-08 when the
|
|
327
|
+
// parenthetical changed shape and 185 of 185 progress lines matched nothing:
|
|
328
|
+
// (oldest gap 0s at 388878, 99.93% landed) -> (no gap, 100.00% landed)
|
|
329
|
+
// and `eta` can print `--:--:--:--` when the node has no estimate. Decoded field
|
|
330
|
+
// by field: an unknown eta is null, an unknown gap phrasing is kept verbatim, and
|
|
331
|
+
// the stored/applied figures survive whatever the node does to the prose.
|
|
332
|
+
{
|
|
333
|
+
name: 'dlcProgressFields',
|
|
334
|
+
re: /\[dlc\]\s*==\s*(.+?)\s*==/,
|
|
335
|
+
apply(m) {
|
|
336
|
+
const out = { kind: 'dlc_progress', extraFields: [] };
|
|
337
|
+
let claims = 0;
|
|
338
|
+
for (const seg of m[1].split('|').map((s) => s.trim()).filter(Boolean)) {
|
|
339
|
+
let r;
|
|
340
|
+
if ((r = /^elapsed\s+(\S+)/.exec(seg))) { out.elapsedMs = parseClock(r[1]); claims += 1; }
|
|
341
|
+
else if ((r = /^eta\s+(\S+)/.exec(seg))) {
|
|
342
|
+
// `--:--:--:--` is the node saying it has no estimate. parseClock returns
|
|
343
|
+
// null for it, which is exactly right: absent, not zero, not a huge number.
|
|
344
|
+
out.nodeEtaMs = parseClock(r[1]);
|
|
345
|
+
out.etaText = r[1];
|
|
346
|
+
claims += 1;
|
|
347
|
+
} else if ((r = /^overall:\s*(\d+)\/(\d+)\s*stored\s*(?:\((\d+\.?\d*)%\s*of\s*real tip\))?/.exec(seg))) {
|
|
348
|
+
out.stored = +r[1]; out.storedOf = +r[2]; if (r[3] != null) out.storedPct = +r[3]; claims += 1;
|
|
349
|
+
} else if ((r = /^in flight\s+(\d+)(?:\s*of\s*window\s*(\d+))?(?:\s*through\s*(\d+))?\s*(?:\((.*)\))?/.exec(seg))) {
|
|
350
|
+
out.inFlight = +r[1];
|
|
351
|
+
if (r[2] != null) out.windowSize = +r[2];
|
|
352
|
+
if (r[3] != null) out.throughHeight = +r[3];
|
|
353
|
+
const paren = (r[4] || '').trim();
|
|
354
|
+
if (paren) {
|
|
355
|
+
out.gapText = paren;
|
|
356
|
+
const landed = /([\d.]+)%\s*landed/.exec(paren);
|
|
357
|
+
if (landed) out.landedPct = +landed[1];
|
|
358
|
+
const gap = /oldest gap\s*(\d+)s\s*at\s*(\d+)/.exec(paren);
|
|
359
|
+
if (gap) { out.oldestGapSec = +gap[1]; out.oldestGapAtHeight = +gap[2]; }
|
|
360
|
+
else if (/^no gap/i.test(paren)) out.oldestGapSec = 0;
|
|
361
|
+
}
|
|
362
|
+
claims += 1;
|
|
363
|
+
} else if ((r = /^applied=(\d+)\s+lag=(\d+)/.exec(seg))) {
|
|
364
|
+
out.applied = +r[1]; out.appliedLag = +r[2]; claims += 1;
|
|
365
|
+
} else {
|
|
366
|
+
const pairs = [...seg.matchAll(/\b([a-z][a-z0-9_]*)\s+(-?\d+(?:\.\d+)?)/g)];
|
|
367
|
+
for (const [, label, value] of pairs) {
|
|
368
|
+
(out.extraFields ||= []).push(label);
|
|
369
|
+
(out.extraValues ||= {})[label] = value;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (claims < 2) return null;
|
|
374
|
+
if (out.extraFields?.length) out.unexpected = `progress line carries fields no rule knows yet: ${out.extraFields.join(', ')}`;
|
|
375
|
+
return out;
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
// [serve] inbound 127.0.0.1:34184 accepted -> child pid 3425259 (1/245 inbound)
|
|
379
|
+
// The node forks a child per inbound connection, so this is the inbound half of
|
|
380
|
+
// the peer table -- which matters most when the handshake then fails, because
|
|
381
|
+
// getpeerinfo never sees the connection at all. 646 of these arrived in half an
|
|
382
|
+
// hour on the production node, all from 127.0.0.1, all failing v2.
|
|
383
|
+
{
|
|
384
|
+
name: 'serveInboundAccept',
|
|
385
|
+
re: /\[serve\]\s*inbound\s*(\S+)\s*accepted ->\s*child pid\s*(\d+)\s*\((\d+)\/(\d+) inbound\)/,
|
|
386
|
+
apply(m) {
|
|
387
|
+
const a = addrParts(m[1]);
|
|
388
|
+
return {
|
|
389
|
+
kind: 'peer_connect', direction: 'inbound', addr: a?.addr ?? m[1], host: a?.host ?? m[1],
|
|
390
|
+
childPid: +m[2], inboundCount: +m[3], inboundCap: +m[4], reason: 'inbound accepted',
|
|
391
|
+
};
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
// [serve] inbound 127.0.0.1:34184 v2 handshake failed -- dropping
|
|
395
|
+
// The peer is gone before the protocol version is known, so no RPC ever learns
|
|
396
|
+
// it existed. 323 accepts, 323 failures: whatever is probing the node's P2P port
|
|
397
|
+
// locally cannot speak BIP324.
|
|
398
|
+
{
|
|
399
|
+
name: 'serveHandshakeFail',
|
|
400
|
+
re: /\[serve\]\s*inbound\s*(\S+)\s*(v\d) handshake failed\s*--+\s*dropping/,
|
|
401
|
+
apply(m) {
|
|
402
|
+
const a = addrParts(m[1]);
|
|
403
|
+
return {
|
|
404
|
+
kind: 'peer_reject', addr: a?.addr ?? m[1], host: a?.host ?? m[1],
|
|
405
|
+
transport: m[2], reason: `${m[2]} handshake failed`, severity: 'warn',
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
// [serve] shutting down (signal 15): tip=965914 outbound_legs=0
|
|
410
|
+
// The monitor's RPC goes away a moment later, so a restart that is not in the log
|
|
411
|
+
// looks like a network fault. This line says what actually happened, including
|
|
412
|
+
// the tip the node last had -- useful when the question is "did it lose blocks".
|
|
413
|
+
{
|
|
414
|
+
name: 'serveShutdown',
|
|
415
|
+
re: /\[serve\]\s*shutting down\s*\(signal (\d+)\):\s*tip=(\d+)(?:\s+outbound_legs=(\d+))?/,
|
|
416
|
+
apply(m) {
|
|
417
|
+
return { kind: 'node_shutdown', signal: +m[1], tipAtShutdown: +m[2], outboundLegs: m[3] == null ? null : +m[3], severity: 'warn' };
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
// [dlc] w1 84.215.4.221:8333 chunks=54 blocks=2160 (+10 blk/s, 941.2KB/s)
|
|
421
|
+
// [dlc] w2 164.90.253.129:8333 chunks=0 blocks=0 (+0 blk/s, 0.0B/s) [early-kill, last 0.0B/s, peer BANNED]
|
|
422
|
+
// [dlc] w3 13.41.145.246:8333 chunks=0 blocks=0 (+0 blk/s, 0.0B/s) (Dragging: 1 of 3)
|
|
423
|
+
//
|
|
424
|
+
// Per-peer DOWNLOAD RATE for one named peer. Nothing in the RPC offers this:
|
|
425
|
+
// getpeerinfo on the production build answers [] and on the bench build answers
|
|
426
|
+
// rows whose bytessent/bytesrecv sum to getnettotals -- 3 KB of a ~47 GB run
|
|
427
|
+
// (MEASUREMENTS 3). This line is the only per-peer throughput that exists.
|
|
428
|
+
// blk/s can be negative: the node prints `+-46 blk/s` when a worker is being
|
|
429
|
+
// re-windowed, and that is decoded as negative rather than dropped.
|
|
430
|
+
{
|
|
431
|
+
name: 'dlcWorkerPeer',
|
|
432
|
+
re: /\[dlc\]\s*w(\d+)\s+(\S+?)\s+chunks=(\d+)\s+blocks=(\d+)\s*\(\+(-?\d+) blk\/s,\s*([^)]+)\)(?:\s*[\[(]([^\])]*?)[\])])?/,
|
|
433
|
+
apply(m) {
|
|
434
|
+
const a = addrParts(m[2]);
|
|
435
|
+
const note = (m[7] || '').trim() || null;
|
|
436
|
+
const banned = /banned/i.test(note || '');
|
|
437
|
+
return {
|
|
438
|
+
kind: 'peer_throughput', worker: +m[1],
|
|
439
|
+
addr: a?.addr ?? m[2], host: a?.host ?? m[2],
|
|
440
|
+
chunks: +m[3], blocks: +m[4], blkPerSec: +m[5], rate: parseRate(m[6].trim()),
|
|
441
|
+
note, banned,
|
|
442
|
+
severity: banned || /early-kill/i.test(note || '') ? 'warn' : 'info',
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
// [dlc] #1 57.132.130.217:8333 1270 KB/s (a row of the ranking table)
|
|
447
|
+
{
|
|
448
|
+
name: 'dlcRankRow',
|
|
449
|
+
re: /\[dlc\]\s*#(\d+)\s+(\S+?)\s+([0-9.]+\s*[KMG]?B\/s)\s*$/,
|
|
450
|
+
apply(m) {
|
|
451
|
+
const a = addrParts(m[2]);
|
|
452
|
+
return { kind: 'peer_speed', rank: +m[1], addr: a?.addr ?? m[2], host: a?.host ?? m[2], rate: parseRate(m[3].trim()) };
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
// [dlc] -- peer status (16/16 worker(s) active) --
|
|
456
|
+
{ name: 'dlcWorkerStatus', re: /\[dlc\]\s*--\s*peer status\s*\((\d+)\/(\d+) worker\(s\) active\)/, apply: (m) => ({ kind: 'worker_status', active: +m[1], total: +m[2], severity: +m[1] < +m[2] ? 'warn' : 'info' }) },
|
|
457
|
+
// [dlc] -- 1 peer(s) dropped for lacking NODE_WITNESS; 0 redial(s) skipped since --
|
|
458
|
+
{ name: 'dlcPeerDropCount', re: /\[dlc\]\s*--\s*(\d+) peer\(s\) dropped for lacking NODE_WITNESS;\s*(\d+) redial\(s\) skipped since\s*--/, apply: (m) => ({ kind: 'peer_drop_count', dropped: +m[1], redialsSkipped: +m[2], severity: +m[1] > 0 ? 'warn' : 'info' }) },
|
|
459
|
+
// [dlc] headers +3 from 57.132.130.217:8333 (total 966011)
|
|
460
|
+
{ name: 'dlcHeaders', re: /\[dlc\]\s*headers\s*\+(\d+)\s*from\s*(\S+?)\s*\(total\s*(\d+)\)/, apply: (m) => { const a = addrParts(m[2]); return { kind: 'headers_from', headers: +m[1], addr: a?.addr ?? m[2], host: a?.host ?? m[2], total: +m[3] }; } },
|
|
461
|
+
// [dlc] == elapsed 1:22:12 | eta 00:07:54:27 | overall: 391483/966011 stored (40.53% of real tip)
|
|
462
|
+
// | in flight 279 of window 4096 through 391761 (oldest gap 0s at 388878, 99.93% landed)
|
|
463
|
+
// | applied=388876 lag=1 ==
|
|
464
|
+
// The node's OWN sync arithmetic, including its own ETA -- kept as a separate
|
|
465
|
+
// labelled figure and never merged with the monitor's measured rate (rules 4 and
|
|
466
|
+
// 9). Stored, not streamed: 424 of these arrive per 80 minutes of IBD.
|
|
467
|
+
//
|
|
468
|
+
// Parsed by `dlcProgressFields` above, which subsumes the rigid rule this used to
|
|
469
|
+
// have: an end-anchored pattern here matched 185 of 185 progress lines on the old
|
|
470
|
+
// grammar and then 0 of 185 when the parenthetical became `(no gap, 100.00%
|
|
471
|
+
// landed)`. Two code paths for one line is how that happens.
|
|
472
|
+
// [utxo_live] catchup progress: height=135639/966010 (14.0%) 23.3 blk/s (avg 23.3)
|
|
473
|
+
// eta 00:09:55:05 | read 0% idx 0% verify 70% ... (42.40 ms/blk over 1)
|
|
474
|
+
// A third rate, from the state-applying thread rather than the download worker.
|
|
475
|
+
// Again: stored beside the others, not averaged with them.
|
|
476
|
+
{
|
|
477
|
+
name: 'catchupProgress',
|
|
478
|
+
re: /\[utxo_live\]\s*catchup progress:\s*height=(\d+)\/(\d+)\s*\((\d+\.?\d*)%\)\s*([\d.]+)\s*blk\/s\s*\(avg\s*([\d.]+)\)\s*eta\s*(\S+)\s*\|\s*(.*?)\s*\(([\d.]+)\s*ms\/blk over (\d+)\)/,
|
|
479
|
+
apply(m) {
|
|
480
|
+
// Groups: 1 height 2 of 3 pct 4 blk/s 5 avg 6 eta 7 phase text 8 ms/blk 9 samples.
|
|
481
|
+
// An earlier cut of this apply() read 8/9/10 and returned msPerBlk 155 for a
|
|
482
|
+
// line printing "15.90 ms/blk" -- caught by decoding a real line, not by the
|
|
483
|
+
// test I would have written around my own assumption.
|
|
484
|
+
const phases = {};
|
|
485
|
+
for (const [, name, pct] of (m[7] || '').matchAll(/\b([a-z0-9_]+)\s+(\d+(?:\.\d+)?)%/g)) phases[name] = +pct;
|
|
486
|
+
return {
|
|
487
|
+
kind: 'catchup_progress',
|
|
488
|
+
height: +m[1], of: +m[2], pct: +m[3], blkPerSec: +m[4], avgBlkPerSec: +m[5],
|
|
489
|
+
nodeEtaMs: parseClock(m[6]), phases: Object.keys(phases).length ? phases : null,
|
|
490
|
+
msPerBlk: +m[8], samples: +m[9],
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
// [utxo_live] compaction done in 0.3s (12 run(s) [0..12) of 12, mid-catchup, full merge;
|
|
495
|
+
// started at height 138932): manifest_n 12 -> 1, merged into run 36, 0 flushed meanwhile,
|
|
496
|
+
// 12 input run(s) unlinked; apply never waited
|
|
497
|
+
{
|
|
498
|
+
name: 'utxoCompaction',
|
|
499
|
+
re: /\[utxo_live\]\s*compaction done in\s*([\d.]+)s\s*\((\d+) run\(s\)\s*\[[^\]]*\)\s*of\s*(\d+)[^;]*;\s*started at height\s*(\d+)\)?:\s*manifest_n\s*(\d+)\s*->\s*(\d+),\s*merged into run\s*(\d+),\s*(\d+) flushed meanwhile,\s*(\d+) input run\(s\) unlinked;\s*apply\s*(never waited|waited[^;,]*)/,
|
|
500
|
+
apply(m) {
|
|
501
|
+
const waited = m[10] !== 'never waited';
|
|
502
|
+
return {
|
|
503
|
+
kind: 'utxo_compaction',
|
|
504
|
+
secs: +m[1], runsMerged: +m[2], runsTotal: +m[3], startedAtHeight: +m[4],
|
|
505
|
+
manifestFrom: +m[5], manifestTo: +m[6], runId: +m[7],
|
|
506
|
+
flushedMeanwhile: +m[8], inputsUnlinked: +m[9],
|
|
507
|
+
applyWaited: waited, detail: m[10],
|
|
508
|
+
// "apply waited" is the one clause that says UTXO compaction stalled
|
|
509
|
+
// block validation, which is a thing an operator needs to see unprompted.
|
|
510
|
+
severity: waited ? 'warn' : 'info',
|
|
511
|
+
};
|
|
512
|
+
},
|
|
513
|
+
},
|
|
514
|
+
// [check] checklevel=3 over 6 block(s) [136156..136161]: 1 examined, 5 hole(s), 1 problem(s)
|
|
515
|
+
{
|
|
516
|
+
name: 'checkLevel',
|
|
517
|
+
re: /\[check\]\s*checklevel=(\d+)\s*over\s*(\d+) block\(s\)\s*\[(\d+)\.\.(\d+)\]:\s*(\d+) examined,\s*(\d+) hole\(s\),\s*(\d+) problem\(s\)/,
|
|
518
|
+
apply(m) {
|
|
519
|
+
const problems = +m[7];
|
|
520
|
+
return {
|
|
521
|
+
kind: 'checklevel', level: +m[1], blocks: +m[2], from: +m[3], to: +m[4],
|
|
522
|
+
examined: +m[5], holes: +m[6], problems,
|
|
523
|
+
severity: problems > 0 ? 'warn' : 'info',
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
// [dl] outbound 4 = 24.9.164.99:8333 (fd 70) proto=70016 services=0xc49 ua="/Satoshi:31.0.0/" height=966010 addrv2=1
|
|
528
|
+
// Peer identity. On the production build getpeerinfo answers [] and on the
|
|
529
|
+
// bench build it answers 5 rows covering 3 KB of a ~47 GB run (MEASUREMENTS 3),
|
|
530
|
+
// so the log remains the only complete list of who we are talking to.
|
|
531
|
+
{
|
|
532
|
+
name: 'peerIdentify',
|
|
533
|
+
re: /\[dl\]\s*(outbound|inbound)\s*(\d+)\s*=\s*(\S+?)\s*\(fd\s*(\d+)\)\s*proto=(\d+)\s*services=(\S+?)\s*ua="([^"]*)"\s*height=(\d+)(?:\s+addrv2=(\d))?/,
|
|
534
|
+
apply(m) {
|
|
535
|
+
const a = addrParts(m[3]);
|
|
536
|
+
return {
|
|
537
|
+
kind: 'peer_identify', direction: m[1], index: +m[2],
|
|
538
|
+
addr: a?.addr ?? m[3], host: a?.host ?? m[3], fd: +m[4], proto: +m[5],
|
|
539
|
+
services: m[6], userAgent: m[7], peerHeight: +m[8], addrv2: m[9] == null ? null : m[9] === '1',
|
|
540
|
+
};
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
// [dl] archive at 136161, peers announce 966010: 829849 blocks behind -- running the parallel downloader (16 workers)
|
|
544
|
+
{
|
|
545
|
+
name: 'ibdBehind',
|
|
546
|
+
re: /\[dl\]\s*archive at\s*(\d+),\s*peers announce\s*(\d+):\s*(\d+) blocks behind\s*--+\s*running the parallel downloader\s*\((\d+) workers\)/,
|
|
547
|
+
apply: (m) => ({ kind: 'ibd_behind', archiveHeight: +m[1], announcedTip: +m[2], behind: +m[3], workers: +m[4] }),
|
|
548
|
+
},
|
|
549
|
+
// [dl] connected 5/8 peer(s); downloading across them...
|
|
550
|
+
{ name: 'dlConnected', re: /\[dl\]\s*connected\s*(\d+)\/(\d+)\s*peer\(s\);\s*downloading/, apply: (m) => ({ kind: 'dl_connected', connected: +m[1], wanted: +m[2] }) },
|
|
551
|
+
// [dl] per-block lines and tip announcements are off while the tip is older than maxtipage
|
|
552
|
+
// (initial block download; Core relays no blocks in IBD) -- they resume at the tip
|
|
553
|
+
//
|
|
554
|
+
// Kept as a rule because it is the *reason* the per-peer panels are empty during
|
|
555
|
+
// IBD. When a panel has no data, this line is the answer, and an unexplained
|
|
556
|
+
// blank is what rule 3 is about.
|
|
557
|
+
{
|
|
558
|
+
name: 'relayPaused',
|
|
559
|
+
re: /\[dl\]\s*per-block lines and tip announcements are off\s*while the tip is older than maxtipage\s*\(([^)]*)\)/,
|
|
560
|
+
apply: (m) => ({ kind: 'relay_paused', reason: m[1].trim(), resumes: 'at the tip' }),
|
|
561
|
+
},
|
|
562
|
+
// [dlc] discovered +0 peers (book now 339) | 304 candidate peer(s) in pool
|
|
563
|
+
// | 121 confirmed-live peer(s) (1 probe round(s))
|
|
564
|
+
{ name: 'peerDiscovery', re: /\[dlc\]\s*discovered\s*([-+]\d+)\s*peers\s*\(book now\s*(\d+)\)/, apply: (m) => ({ kind: 'peer_discovery', delta: +m[1], book: +m[2] }) },
|
|
565
|
+
{ name: 'peerCandidates', re: /\[dlc\]\s*(\d+) candidate peer\(s\) in pool/, apply: (m) => ({ kind: 'peer_candidates', candidates: +m[1] }) },
|
|
566
|
+
{ name: 'peerLiveProbe', re: /\[dlc\]\s*(\d+) confirmed-live peer\(s\)\s*\((\d+) probe round\(s\)\)/, apply: (m) => ({ kind: 'peer_live_probe', live: +m[1], probeRounds: +m[2] }) },
|
|
567
|
+
// [txrelay] addrv2 gossip: +3 address(es) to the book
|
|
568
|
+
// 418 occurrences on production, median 22 s apart. This is the chatter that made
|
|
569
|
+
// the corpus parse ratio fall to 73.4% today while costing no measurement at all --
|
|
570
|
+
// parsed so the ratio means something again, and so book growth is a figure.
|
|
571
|
+
{ name: 'addrGossip', re: /\[txrelay\]\s*addrv2 gossip:\s*\+(\d+)\s*address\(es\)\s*to the book/, apply: (m) => ({ kind: 'addr_gossip', added: +m[1] }) },
|
|
572
|
+
// [dl] outbound top-up: 4 dial(s) failed, first 172.104.174.241:8333: peer lacks NODE_WITNESS
|
|
573
|
+
// The reason is the finding, so it is kept verbatim: 'peer lacks NODE_WITNESS' and
|
|
574
|
+
// 'handshake failed (rc=0)' are different problems. Aggregate in the monitor --
|
|
575
|
+
// median 59 s, p95 393 s apart on production.
|
|
576
|
+
{
|
|
577
|
+
name: 'dialTopUpFails',
|
|
578
|
+
re: /\[dl\]\s*outbound top-up:\s*(\d+)\s*dial\(s\) failed(?:,\s*first\s+(\S+):\s*(.+?))?\s*$/,
|
|
579
|
+
apply(m) {
|
|
580
|
+
const a = m[2] ? addrParts(m[2]) : null;
|
|
581
|
+
return {
|
|
582
|
+
kind: 'dial_failures', failed: +m[1], firstHost: a?.host ?? null, firstAddr: a?.addr ?? null,
|
|
583
|
+
reason: (m[3] || '').trim() || null,
|
|
584
|
+
severity: +m[1] > 0 ? 'warn' : 'info',
|
|
585
|
+
};
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
// [dl] updating utxo: applied 2 block(s), now at height 966001, live=165338542 (0.34s)
|
|
589
|
+
// Validation throughput from the thread that applies blocks: a third rate, and it
|
|
590
|
+
// stays a third rate. The download rate, `[utxo_live] catchup progress` and this one
|
|
591
|
+
// measure different things and are never averaged (rule 9).
|
|
592
|
+
{
|
|
593
|
+
name: 'utxoApply',
|
|
594
|
+
re: /\[dl\]\s*updating utxo:\s*applied\s*(\d+)\s*block\(s\),\s*now at height\s*(\d+),\s*live=(\d+)\s*\(([\d.]+)s\)/,
|
|
595
|
+
apply(m) {
|
|
596
|
+
const blocks = +m[1], secs = +m[4];
|
|
597
|
+
return {
|
|
598
|
+
kind: 'utxo_apply', blocks, height: +m[2], utxoCount: +m[3], secs,
|
|
599
|
+
blocksPerSec: secs > 0 ? +(blocks / secs).toFixed(2) : null,
|
|
600
|
+
};
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
// [dl] header mirror +1 from the archive (now 966055, archive tip 966054)
|
|
604
|
+
// How far the header chain is running ahead of what has been applied.
|
|
605
|
+
{
|
|
606
|
+
name: 'headerMirror',
|
|
607
|
+
re: /\[dl\]\s*header mirror\s*\+(\d+)\s*from the archive\s*\(now\s*(\d+),\s*archive tip\s*(\d+)\)/,
|
|
608
|
+
apply(m) {
|
|
609
|
+
return { kind: 'header_mirror', added: +m[1], headersNow: +m[2], archiveTip: +m[3], gap: +m[2] - +m[3] };
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
// [dial] 31.47.202.112:8333: dialing in the background (ipv4)
|
|
613
|
+
// Aggregate only: 52 of these in the sampled window would be a feed of nothing
|
|
614
|
+
// but dial attempts.
|
|
615
|
+
{
|
|
616
|
+
name: 'dialAttempt',
|
|
617
|
+
re: /\[dial\]\s*(\S+?):\s*dialing in the background\s*(?:\((\w+)\))?/,
|
|
618
|
+
apply(m) {
|
|
619
|
+
const a = addrParts(m[1]);
|
|
620
|
+
return { kind: 'dial_attempt', addr: a?.addr ?? m[1], host: a?.host ?? m[1], family: m[2] ?? null };
|
|
621
|
+
},
|
|
622
|
+
},
|
|
623
|
+
// [dial] 172.104.174.241:8333: background dial failed: peer lacks NODE_WITNESS
|
|
624
|
+
// One per host with a reason, at ~20 per window: that volume belongs in the peer
|
|
625
|
+
// event list, which is what it is for.
|
|
626
|
+
{
|
|
627
|
+
name: 'dialBackgroundFail',
|
|
628
|
+
re: /\[dial\]\s*(\S+?):\s*background dial failed:\s*(.+?)\s*$/,
|
|
629
|
+
apply(m) {
|
|
630
|
+
const a = addrParts(m[1]);
|
|
631
|
+
return { kind: 'peer_reject', direction: 'outbound', addr: a?.addr ?? m[1], host: a?.host ?? m[1], reason: m[2].trim(), severity: 'warn' };
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
// [dial] no global IPv6 route on this host: ipv6 peers are unreachable (cjdns unaffected)
|
|
635
|
+
// A capability the host lacks, not a peer problem, and it explains an ipv6 peer
|
|
636
|
+
// count of zero better than the count does.
|
|
637
|
+
{
|
|
638
|
+
name: 'noIPv6',
|
|
639
|
+
re: /\[dial\]\s*no global IPv6 route on this host:\s*(ipv6 peers are unreachable)\s*(\(([^)]*)\))?/,
|
|
640
|
+
apply(m) {
|
|
641
|
+
return { kind: 'network_note', note: m[1], caveat: (m[3] || '').trim() || null, severity: 'info' };
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
// Which measurements must keep arriving, and how long a silence counts as a format
|
|
647
|
+
// change rather than a quiet phase.
|
|
648
|
+
//
|
|
649
|
+
// Why this exists: four grammars changed on 2026-09-08 and the corpus-wide parse
|
|
650
|
+
// ratio flagged none of them, because a ratio over every line measures the corpus
|
|
651
|
+
// and not the rule (rule 16). The ratio then cut the other way an hour later --
|
|
652
|
+
// production dropped to 73.4% purely because `addrv2 gossip` chatter grew, costing
|
|
653
|
+
// nothing.
|
|
654
|
+
//
|
|
655
|
+
// Gates are p95 x 8, clamped to [10 min, 30 min], from cadences measured on both
|
|
656
|
+
// live nodes the same day (production: 355-640 occurrences per shape):
|
|
657
|
+
//
|
|
658
|
+
// shape median p95 max gate
|
|
659
|
+
// heartbeat 65s 152s 1657s 20 min
|
|
660
|
+
// relay legs 64s 120s 1842s 16 min
|
|
661
|
+
// orphans 65s 152s 1657s 20 min
|
|
662
|
+
// accepts and rejects 33s 86s 1652s 12 min
|
|
663
|
+
// address gossip 22s 87s 902s 12 min
|
|
664
|
+
// bandwidth tick 10s 17s 17s 10 min (IBD only)
|
|
665
|
+
// download progress 10s 17s 17s 10 min (IBD only)
|
|
666
|
+
//
|
|
667
|
+
// Left unwatched, with the numbers that justify it: `[dl] updating utxo`
|
|
668
|
+
// (460/1868/3669 s) and `header mirror` (502/2054/3669 s) are bursty, and
|
|
669
|
+
// `ban_count`, `relay_paused`, `ibd_behind` and the discovery lines are one-shot or
|
|
670
|
+
// phase-dependent. Watching an irregular line produces a warning that is wrong often
|
|
671
|
+
// enough to be ignored, which is worse than no warning.
|
|
672
|
+
//
|
|
673
|
+
// A shape is watched only after it has matched at least once (armed). That is how
|
|
674
|
+
// build differences are handled without a build table: the bench build never emits
|
|
675
|
+
// `heartbeat`, and production emits a bandwidth tick twice in several hours because
|
|
676
|
+
// it is synced. Neither should be a warning.
|
|
677
|
+
export const SHAPES = [
|
|
678
|
+
{ shape: 'bandwidth rate', rules: ['bandwidthTick', 'bandwidthTickFields'], gateMs: 600_000, ibdOnly: true },
|
|
679
|
+
{ shape: 'download progress', rules: ['dlcProgressFields'], gateMs: 600_000, ibdOnly: true },
|
|
680
|
+
{ shape: 'heartbeat', rules: ['heartbeat'], gateMs: 1_200_000 },
|
|
681
|
+
{ shape: 'relay legs', rules: ['txRelay', 'txRelayBare'], gateMs: 960_000 },
|
|
682
|
+
{ shape: 'accepts and rejects', rules: ['txAccept'], gateMs: 720_000 },
|
|
683
|
+
{ shape: 'orphans', rules: ['orphans'], gateMs: 1_200_000 },
|
|
684
|
+
{ shape: 'address gossip', rules: ['addrGossip'], gateMs: 720_000 },
|
|
685
|
+
];
|
|
686
|
+
|
|
687
|
+
// The shape a rule belongs to, for the liveness bookkeeping in the monitor.
|
|
688
|
+
// Built once: rule name -> shape label.
|
|
689
|
+
export const RULE_TO_SHAPE = new Map(
|
|
690
|
+
SHAPES.flatMap((s) => s.rules.map((r) => [r, s.shape])),
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
// Coarse severity so the UI can colour the feed without understanding each tag.
|
|
694
|
+
function classify(tag, msg) {
|
|
695
|
+
if (/\b(error|failed|failure|unreachable|refus|denied|dropped|not laid out|abort|fatal|panic|corrupt|OOM|killed)\b/i.test(msg)) return 'warn';
|
|
696
|
+
if (/\b(warn|warning|stall|banned|silent rank last)\b/i.test(msg)) return 'warn';
|
|
697
|
+
if (/\b(connected|accepted|stored|wrote|resolved|done|synced|bound|listening)\b/i.test(msg)) return 'info';
|
|
698
|
+
return 'info';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The node logs local time with no UTC offset, so assemble the date in local
|
|
702
|
+
// time. Date.parse of a non-ISO string is implementation-defined; explicit
|
|
703
|
+
// fields are not.
|
|
704
|
+
function localFromLogTs(dateTime, ms) {
|
|
705
|
+
const m = dateTime.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/);
|
|
706
|
+
if (!m) return Date.now();
|
|
707
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
708
|
+
return new Date(+y, +mo - 1, +d, +h, +mi, +s, +ms).getTime();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// One line in, one event out (or null for a continuation/blank line).
|
|
712
|
+
export function parseLine(line) {
|
|
713
|
+
if (!line) return null;
|
|
714
|
+
const trimmed = line.replace(/\r$/, '');
|
|
715
|
+
if (!trimmed.trim()) return null;
|
|
716
|
+
|
|
717
|
+
let ts = Date.now();
|
|
718
|
+
let rest = trimmed;
|
|
719
|
+
const tsm = trimmed.match(TS_RE);
|
|
720
|
+
if (tsm) {
|
|
721
|
+
ts = localFromLogTs(tsm[1], tsm[2]);
|
|
722
|
+
rest = trimmed.slice(tsm[0].length);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let tag = null;
|
|
726
|
+
const tagm = rest.match(TAG_RE);
|
|
727
|
+
if (tagm) { tag = tagm[0].replace(/[\[\]\s]/g, ''); rest = rest.slice(tagm[0].length); }
|
|
728
|
+
const tagBase = tag ? tag.split(':')[0] : null;
|
|
729
|
+
|
|
730
|
+
for (const rule of RULES) {
|
|
731
|
+
const m = rest.match(rule.re) ?? trimmed.match(rule.re);
|
|
732
|
+
if (m) {
|
|
733
|
+
const out = rule.apply(m);
|
|
734
|
+
if (out) {
|
|
735
|
+
out.ts = ts;
|
|
736
|
+
out.tag = tag;
|
|
737
|
+
out.tagBase = tagBase;
|
|
738
|
+
out.text = rest.trim();
|
|
739
|
+
out.rule = rule.name;
|
|
740
|
+
out.severity = out.severity ?? classify(tagBase, rest);
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
kind: 'raw',
|
|
748
|
+
ts,
|
|
749
|
+
tag,
|
|
750
|
+
tagBase,
|
|
751
|
+
text: rest.trim(),
|
|
752
|
+
severity: classify(tagBase, rest),
|
|
753
|
+
rule: null,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Lines arrive in chunks; keep the trailing partial line for the next round.
|
|
758
|
+
export function splitLines(buf, state = { carry: '' }) {
|
|
759
|
+
const text = state.carry + buf;
|
|
760
|
+
const parts = text.split('\n');
|
|
761
|
+
state.carry = parts.pop() ?? '';
|
|
762
|
+
const out = [];
|
|
763
|
+
for (const p of parts) { const e = parseLine(p); if (e) out.push(e); }
|
|
764
|
+
return out;
|
|
765
|
+
}
|