blockyard 0.0.1 → 0.0.9

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.
Files changed (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -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
+ }