diff --git a/.gitignore b/.gitignore index ad67955..44819b8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + + +# Added by cargo + +/target +config.local.toml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a54d23c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1277 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "base64 0.21.7", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "btcnode-metrics" +version = "0.1.0" +dependencies = [ + "corepc-client", + "prometheus", + "serde", + "thiserror 2.0.18", + "toml", + "tracing", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "corepc-client" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755b8b9219b23d166a5897b5e2d8266cbdd0de5861d351b96f6db26bcf415f3" +dependencies = [ + "bitcoin", + "corepc-types", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "corepc-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c22db78b0223b66f82f92b14345f06307078f76d94b18280431ea9bc6cd9cbb6" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minreq" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prom-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "btcnode-metrics", + "clap", + "prometheus", + "serde", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "prometheus" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 2.0.18", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ba00e5e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +members = [ + "src/btcnode-metrics", + "src/prom-api", +] +resolver = "3" + +[workspace.package] +edition = "2024" + +[workspace.dependencies] +corepc-client = { version = "0.10", features = ["client-sync"] } +prometheus = "0.14" +axum = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +clap = { version = "4", features = ["derive"] } +anyhow = "1" diff --git a/config.toml.example b/config.toml.example new file mode 100644 index 0000000..dcdd85b --- /dev/null +++ b/config.toml.example @@ -0,0 +1,7 @@ +[node] +rpc_url = "http://127.0.0.1:8332" +rpc_user = "bitcoinrpc" +rpc_password = "changeme" + +[server] +listen_addr = "0.0.0.0:9332" diff --git a/src/btcnode-metrics/Cargo.toml b/src/btcnode-metrics/Cargo.toml new file mode 100644 index 0000000..e1b0bb0 --- /dev/null +++ b/src/btcnode-metrics/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "btcnode-metrics" +version = "0.1.0" +edition.workspace = true + +[dependencies] +corepc-client.workspace = true +prometheus.workspace = true +serde.workspace = true +toml.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/src/btcnode-metrics/src/collector.rs b/src/btcnode-metrics/src/collector.rs new file mode 100644 index 0000000..af36c7d --- /dev/null +++ b/src/btcnode-metrics/src/collector.rs @@ -0,0 +1,685 @@ +use std::time::Instant; + +use tracing::{info, warn}; + +use crate::metrics::BitcoinMetrics; +use crate::node::NodeClient; + +pub struct MetricsCollector { + node: N, + metrics: BitcoinMetrics, +} + +impl MetricsCollector { + pub fn new(node: N, metrics: BitcoinMetrics) -> Self { + Self { node, metrics } + } + + pub fn metrics(&self) -> &BitcoinMetrics { + &self.metrics + } + + pub fn collect(&self) { + let start = Instant::now(); + let mut had_error = false; + let mut block_height: Option = None; + + // Blockchain info + match self.node.get_blockchain_info() { + Ok(info) => { + self.metrics.blocks.set(info.blocks as f64); + self.metrics.headers.set(info.headers as f64); + self.metrics.difficulty.set(info.difficulty); + self.metrics.verification_progress.set(info.verification_progress); + self.metrics.size_on_disk.set(info.size_on_disk as f64); + self.metrics.initial_block_download.set(if info.initial_block_download { 1.0 } else { 0.0 }); + self.metrics.chain_pruned.set(if info.pruned { 1.0 } else { 0.0 }); + block_height = Some(info.blocks); + info!("Updated blockchain info: blocks={}, headers={}", info.blocks, info.headers); + } + Err(e) => { + warn!("Failed to get blockchain info: {e}"); + had_error = true; + } + } + + // Mempool info + match self.node.get_mempool_info() { + Ok(info) => { + self.metrics.mempool_transactions.set(info.size as f64); + self.metrics.mempool_bytes.set(info.bytes as f64); + self.metrics.mempool_usage.set(info.usage as f64); + self.metrics.mempool_max_bytes.set(info.max_mempool as f64); + self.metrics.mempool_min_fee.set(info.mempool_min_fee); + self.metrics.mempool_total_fee.set(info.total_fee); + self.metrics.mempool_min_relay_tx_fee.set(info.min_relay_tx_fee); + self.metrics.mempool_incremental_relay_fee.set(info.incremental_relay_fee); + self.metrics.mempool_unbroadcast_count.set(info.unbroadcast_count as f64); + self.metrics.mempool_full_rbf.set(if info.full_rbf { 1.0 } else { 0.0 }); + info!("Updated mempool info: txs={}, bytes={}", info.size, info.bytes); + } + Err(e) => { + warn!("Failed to get mempool info: {e}"); + had_error = true; + } + } + + // Network info + match self.node.get_network_info() { + Ok(info) => { + self.metrics.connections.set(info.connections as f64); + self.metrics.connections_in.set(info.connections_in as f64); + self.metrics.connections_out.set(info.connections_out as f64); + self.metrics.network_active.set(if info.network_active { 1.0 } else { 0.0 }); + self.metrics.node_version.set(info.version as f64); + self.metrics.protocol_version.set(info.protocol_version as f64); + self.metrics.time_offset.set(info.time_offset as f64); + self.metrics.relay_fee.set(info.relay_fee); + self.metrics.incremental_fee.set(info.incremental_fee); + info!("Updated network info: connections={}", info.connections); + } + Err(e) => { + warn!("Failed to get network info: {e}"); + had_error = true; + } + } + + // Peer info (aggregated) + match self.node.get_peer_info() { + Ok(peers) => { + let total = peers.0.len(); + let inbound = peers.0.iter().filter(|p| p.inbound).count(); + let outbound = total - inbound; + let total_sent: u64 = peers.0.iter().map(|p| p.bytes_sent).sum(); + let total_recv: u64 = peers.0.iter().map(|p| p.bytes_received).sum(); + let ping_sum: f64 = peers.0.iter().filter_map(|p| p.ping_time).sum(); + let ping_count = peers.0.iter().filter(|p| p.ping_time.is_some()).count(); + let avg_ping = if ping_count > 0 { ping_sum / ping_count as f64 } else { 0.0 }; + + self.metrics.peer_count.set(total as f64); + self.metrics.peers_inbound.set(inbound as f64); + self.metrics.peers_outbound.set(outbound as f64); + self.metrics.peers_total_bytes_sent.set(total_sent as f64); + self.metrics.peers_total_bytes_received.set(total_recv as f64); + self.metrics.peers_avg_ping_seconds.set(avg_ping); + info!("Updated peer info: peers={} (in={}, out={})", total, inbound, outbound); + } + Err(e) => { + warn!("Failed to get peer info: {e}"); + had_error = true; + } + } + + // Mining info + match self.node.get_mining_info() { + Ok(info) => { + self.metrics.network_hash_ps.set(info.network_hash_ps); + self.metrics.mining_pooled_tx.set(info.pooled_tx as f64); + info!("Updated mining info: hashps={}, pooledtx={}", info.network_hash_ps, info.pooled_tx); + } + Err(e) => { + warn!("Failed to get mining info: {e}"); + had_error = true; + } + } + + // Chain tx stats + match self.node.get_chain_tx_stats() { + Ok(info) => { + self.metrics.chain_tx_count.set(info.tx_count as f64); + if let Some(rate) = info.tx_rate { + self.metrics.chain_tx_rate.set(rate); + } + self.metrics.chain_tx_window_block_count.set(info.window_block_count as f64); + if let Some(count) = info.window_tx_count { + self.metrics.chain_tx_window_tx_count.set(count as f64); + } + if let Some(interval) = info.window_interval { + self.metrics.chain_tx_window_interval.set(interval as f64); + } + info!("Updated chain tx stats: total_txs={}, rate={:?}", info.tx_count, info.tx_rate); + } + Err(e) => { + warn!("Failed to get chain tx stats: {e}"); + had_error = true; + } + } + + // Net totals + match self.node.get_net_totals() { + Ok(info) => { + self.metrics.net_total_bytes_received.set(info.total_bytes_received as f64); + self.metrics.net_total_bytes_sent.set(info.total_bytes_sent as f64); + info!("Updated net totals: recv={}, sent={}", info.total_bytes_received, info.total_bytes_sent); + } + Err(e) => { + warn!("Failed to get net totals: {e}"); + had_error = true; + } + } + + // Fee estimation at various confirmation targets + for (target, gauge) in [ + (2, &self.metrics.fee_estimate_2_blocks), + (6, &self.metrics.fee_estimate_6_blocks), + (12, &self.metrics.fee_estimate_12_blocks), + (144, &self.metrics.fee_estimate_144_blocks), + ] { + match self.node.estimate_smart_fee(target) { + Ok(est) => { + if let Some(rate) = est.fee_rate { + gauge.set(rate); + } + } + Err(e) => { + warn!("Failed to estimate smart fee for {target} blocks: {e}"); + had_error = true; + } + } + } + info!("Updated fee estimates"); + + // Chain tips + match self.node.get_chain_tips() { + Ok(tips) => { + self.metrics.chain_tips_count.set(tips.0.len() as f64); + info!("Updated chain tips: count={}", tips.0.len()); + } + Err(e) => { + warn!("Failed to get chain tips: {e}"); + had_error = true; + } + } + + // Uptime + match self.node.uptime() { + Ok(seconds) => { + self.metrics.node_uptime_seconds.set(seconds as f64); + info!("Updated uptime: {}s", seconds); + } + Err(e) => { + warn!("Failed to get uptime: {e}"); + had_error = true; + } + } + + // Latest block stats (requires block height from blockchain info) + if let Some(height) = block_height { + match self.node.get_block_stats_by_height(height as u32) { + Ok(stats) => { + self.metrics.latest_block_txs.set(stats.txs as f64); + self.metrics.latest_block_size.set(stats.total_size as f64); + self.metrics.latest_block_weight.set(stats.total_weight as f64); + self.metrics.latest_block_avg_fee.set(stats.average_fee as f64); + self.metrics.latest_block_avg_fee_rate.set(stats.average_fee_rate as f64); + self.metrics.latest_block_median_fee.set(stats.median_fee as f64); + self.metrics.latest_block_min_fee.set(stats.minimum_fee as f64); + self.metrics.latest_block_max_fee.set(stats.max_fee as f64); + self.metrics.latest_block_min_fee_rate.set(stats.minimum_fee_rate as f64); + self.metrics.latest_block_max_fee_rate.set(stats.max_fee_rate as f64); + self.metrics.latest_block_total_fee.set(stats.total_fee as f64); + self.metrics.latest_block_subsidy.set(stats.subsidy as f64); + self.metrics.latest_block_inputs.set(stats.inputs as f64); + self.metrics.latest_block_outputs.set(stats.outputs as f64); + self.metrics.latest_block_segwit_txs.set(stats.segwit_txs as f64); + self.metrics.latest_block_segwit_total_size.set(stats.segwit_total_size as f64); + self.metrics.latest_block_segwit_total_weight.set(stats.segwit_total_weight as f64); + self.metrics.latest_block_total_out.set(stats.total_out as f64); + self.metrics.latest_block_utxo_increase.set(stats.utxo_increase as f64); + self.metrics.latest_block_fee_rate_10th.set(stats.fee_rate_percentiles[0] as f64); + self.metrics.latest_block_fee_rate_25th.set(stats.fee_rate_percentiles[1] as f64); + self.metrics.latest_block_fee_rate_50th.set(stats.fee_rate_percentiles[2] as f64); + self.metrics.latest_block_fee_rate_75th.set(stats.fee_rate_percentiles[3] as f64); + self.metrics.latest_block_fee_rate_90th.set(stats.fee_rate_percentiles[4] as f64); + info!("Updated latest block stats: height={}, txs={}, total_fee={}", height, stats.txs, stats.total_fee); + } + Err(e) => { + warn!("Failed to get block stats for height {height}: {e}"); + had_error = true; + } + } + } + + let duration = start.elapsed().as_secs_f64(); + self.metrics.scrape_duration_seconds.set(duration); + self.metrics.scrape_error.set(if had_error { 1.0 } else { 0.0 }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + use crate::node::{ChainTxStats, MiningInfo}; + use corepc_client::types::v28::*; + + struct MockNode; + + impl NodeClient for MockNode { + fn get_blockchain_info(&self) -> Result { + Ok(GetBlockchainInfo { + chain: String::from("main"), + blocks: 800000, + headers: 800000, + best_block_hash: String::from( + "0000000000000000000000000000000000000000000000000000000000000000" + ), + difficulty: 53_911_173_001_054.59, + time: 1_700_000_000, + median_time: 1_699_999_000, + verification_progress: 0.9999, + initial_block_download: false, + chain_work: String::new(), + size_on_disk: 600_000_000_000, + pruned: false, + prune_height: None, + automatic_pruning: None, + prune_target_size: None, + softforks: Default::default(), + warnings: vec![], + }) + } + + fn get_mempool_info(&self) -> Result { + Ok(GetMempoolInfo { + loaded: true, + size: 5000, + bytes: 3_000_000, + usage: 10_000_000, + total_fee: 0.5, + max_mempool: 300_000_000, + mempool_min_fee: 0.00001, + min_relay_tx_fee: 0.00001, + incremental_relay_fee: 0.00001, + unbroadcast_count: 3, + full_rbf: false, + }) + } + + fn get_network_info(&self) -> Result { + Ok(GetNetworkInfo { + version: 250000, + subversion: String::from("/Satoshi:25.0.0/"), + protocol_version: 70016, + local_services: String::new(), + local_services_names: vec![], + local_relay: true, + time_offset: -2, + connections: 125, + connections_in: 85, + connections_out: 40, + network_active: true, + networks: vec![], + relay_fee: 0.00001, + incremental_fee: 0.00001, + local_addresses: vec![], + warnings: vec![], + }) + } + + fn get_peer_info(&self) -> Result { + Ok(GetPeerInfo(vec![ + PeerInfo { + id: 1, + address: "1.2.3.4:8333".into(), + address_bind: Some("0.0.0.0:0".into()), + address_local: None, + network: "ipv4".into(), + mapped_as: None, + services: "0000000000000409".into(), + services_names: vec!["NETWORK".into(), "WITNESS".into()], + relay_transactions: true, + last_send: 1_700_000_000, + last_received: 1_700_000_000, + last_transaction: 0, + last_block: 0, + bytes_sent: 50_000, + bytes_received: 100_000, + connection_time: 1_699_900_000, + time_offset: 0, + ping_time: Some(0.05), + minimum_ping: Some(0.02), + ping_wait: None, + version: 70016, + subversion: "/Satoshi:25.0.0/".into(), + inbound: false, + bip152_hb_to: false, + bip152_hb_from: false, + add_node: None, + starting_height: Some(799_990), + presynced_headers: Some(-1), + ban_score: None, + synced_headers: Some(800_000), + synced_blocks: Some(800_000), + inflight: Some(vec![]), + addresses_relay_enabled: None, + addresses_processed: None, + addresses_rate_limited: None, + permissions: vec![], + whitelisted: None, + minimum_fee_filter: 0.00001, + bytes_sent_per_message: Default::default(), + bytes_received_per_message: Default::default(), + connection_type: Some("outbound-full-relay".into()), + transport_protocol_type: "v1".into(), + session_id: String::new(), + }, + PeerInfo { + id: 2, + address: "5.6.7.8:8333".into(), + address_bind: Some("0.0.0.0:0".into()), + address_local: None, + network: "ipv4".into(), + mapped_as: None, + services: "0000000000000409".into(), + services_names: vec!["NETWORK".into(), "WITNESS".into()], + relay_transactions: true, + last_send: 1_700_000_000, + last_received: 1_700_000_000, + last_transaction: 0, + last_block: 0, + bytes_sent: 30_000, + bytes_received: 60_000, + connection_time: 1_699_900_000, + time_offset: 0, + ping_time: Some(0.10), + minimum_ping: Some(0.05), + ping_wait: None, + version: 70016, + subversion: "/Satoshi:25.0.0/".into(), + inbound: true, + bip152_hb_to: false, + bip152_hb_from: false, + add_node: None, + starting_height: Some(799_990), + presynced_headers: Some(-1), + ban_score: None, + synced_headers: Some(800_000), + synced_blocks: Some(800_000), + inflight: Some(vec![]), + addresses_relay_enabled: None, + addresses_processed: None, + addresses_rate_limited: None, + permissions: vec![], + whitelisted: None, + minimum_fee_filter: 0.00001, + bytes_sent_per_message: Default::default(), + bytes_received_per_message: Default::default(), + connection_type: Some("inbound".into()), + transport_protocol_type: "v1".into(), + session_id: String::new(), + }, + ])) + } + + fn get_mining_info(&self) -> Result { + Ok(MiningInfo { + blocks: 800_000, + current_block_weight: Some(3_993_000), + current_block_tx: Some(2_500), + difficulty: 53_911_173_001_054.59, + network_hash_ps: 4.5e17, + pooled_tx: 5000, + chain: "main".into(), + warnings: vec![], + }) + } + + fn get_chain_tx_stats(&self) -> Result { + Ok(ChainTxStats { + time: 1_700_000_000, + tx_count: 900_000_000, + window_final_block_hash: "0000000000000000000000000000000000000000000000000000000000000000".into(), + window_final_block_height: 800_000, + window_block_count: 4032, + window_tx_count: Some(12_000_000), + window_interval: Some(2_419_200), + tx_rate: Some(4.96), + }) + } + + fn get_net_totals(&self) -> Result { + Ok(GetNetTotals { + total_bytes_received: 5_000_000_000, + total_bytes_sent: 3_000_000_000, + time_millis: 1_700_000_000_000, + upload_target: UploadTarget { + timeframe: 86400, + target: 0, + target_reached: false, + serve_historical_blocks: true, + bytes_left_in_cycle: 0, + time_left_in_cycle: 43200, + }, + }) + } + + fn estimate_smart_fee(&self, conf_target: u32) -> Result { + let rate = match conf_target { + 2 => 0.00025, + 6 => 0.00015, + 12 => 0.00010, + 144 => 0.00005, + _ => 0.00010, + }; + Ok(EstimateSmartFee { + fee_rate: Some(rate), + errors: None, + blocks: conf_target, + }) + } + + fn get_chain_tips(&self) -> Result { + Ok(GetChainTips(vec![ + ChainTips { + height: 800_000, + hash: "0000000000000000000000000000000000000000000000000000000000000000".into(), + branch_length: 0, + status: ChainTipsStatus::Active, + }, + ChainTips { + height: 799_998, + hash: "0000000000000000000000000000000000000000000000000000000000000001".into(), + branch_length: 2, + status: ChainTipsStatus::ValidFork, + }, + ])) + } + + fn uptime(&self) -> Result { + Ok(86400) + } + + fn get_block_stats_by_height(&self, _height: u32) -> Result { + Ok(GetBlockStats { + average_fee: 15_000, + average_fee_rate: 25, + average_tx_size: 500, + block_hash: "0000000000000000000000000000000000000000000000000000000000000000".into(), + fee_rate_percentiles: [5, 10, 20, 50, 100], + height: 800_000, + inputs: 6000, + max_fee: 500_000, + max_fee_rate: 200, + max_tx_size: 100_000, + median_fee: 10_000, + median_time: 1_699_999_000, + median_tx_size: 250, + minimum_fee: 500, + minimum_fee_rate: 1, + minimum_tx_size: 150, + outputs: 8000, + subsidy: 625_000_000, + segwit_total_size: 1_500_000, + segwit_total_weight: 3_000_000, + segwit_txs: 2000, + time: 1_700_000_000, + total_out: 500_000_000_000, + total_size: 2_000_000, + total_weight: 3_993_000, + total_fee: 37_500_000, + txs: 2500, + utxo_increase: 500, + utxo_size_increase: 25_000, + utxo_increase_actual: None, + utxo_size_increase_actual: None, + }) + } + } + + #[test] + fn test_collect_updates_gauges() { + let metrics = BitcoinMetrics::new().unwrap(); + let collector = MetricsCollector::new(MockNode, metrics); + + collector.collect(); + + // Blockchain info + assert_eq!(collector.metrics().blocks.get(), 800_000.0); + assert_eq!(collector.metrics().headers.get(), 800_000.0); + assert!(collector.metrics().difficulty.get() > 0.0); + assert_eq!(collector.metrics().initial_block_download.get(), 0.0); + assert_eq!(collector.metrics().chain_pruned.get(), 0.0); + assert_eq!(collector.metrics().size_on_disk.get(), 600_000_000_000.0); + + // Mempool info + assert_eq!(collector.metrics().mempool_transactions.get(), 5000.0); + assert_eq!(collector.metrics().mempool_bytes.get(), 3_000_000.0); + assert_eq!(collector.metrics().mempool_total_fee.get(), 0.5); + assert_eq!(collector.metrics().mempool_unbroadcast_count.get(), 3.0); + assert_eq!(collector.metrics().mempool_full_rbf.get(), 0.0); + + // Network info + assert_eq!(collector.metrics().connections.get(), 125.0); + assert_eq!(collector.metrics().connections_in.get(), 85.0); + assert_eq!(collector.metrics().connections_out.get(), 40.0); + assert_eq!(collector.metrics().network_active.get(), 1.0); + assert_eq!(collector.metrics().protocol_version.get(), 70016.0); + assert_eq!(collector.metrics().time_offset.get(), -2.0); + assert_eq!(collector.metrics().relay_fee.get(), 0.00001); + assert_eq!(collector.metrics().incremental_fee.get(), 0.00001); + + // Peer info + assert_eq!(collector.metrics().peer_count.get(), 2.0); + assert_eq!(collector.metrics().peers_inbound.get(), 1.0); + assert_eq!(collector.metrics().peers_outbound.get(), 1.0); + assert_eq!(collector.metrics().peers_total_bytes_sent.get(), 80_000.0); + assert_eq!(collector.metrics().peers_total_bytes_received.get(), 160_000.0); + assert!((collector.metrics().peers_avg_ping_seconds.get() - 0.075).abs() < 0.001); + + // Mining info + assert_eq!(collector.metrics().network_hash_ps.get(), 4.5e17); + assert_eq!(collector.metrics().mining_pooled_tx.get(), 5000.0); + + // Chain tx stats + assert_eq!(collector.metrics().chain_tx_count.get(), 900_000_000.0); + assert_eq!(collector.metrics().chain_tx_rate.get(), 4.96); + assert_eq!(collector.metrics().chain_tx_window_block_count.get(), 4032.0); + assert_eq!(collector.metrics().chain_tx_window_tx_count.get(), 12_000_000.0); + assert_eq!(collector.metrics().chain_tx_window_interval.get(), 2_419_200.0); + + // Net totals + assert_eq!(collector.metrics().net_total_bytes_received.get(), 5_000_000_000.0); + assert_eq!(collector.metrics().net_total_bytes_sent.get(), 3_000_000_000.0); + + // Fee estimates + assert_eq!(collector.metrics().fee_estimate_2_blocks.get(), 0.00025); + assert_eq!(collector.metrics().fee_estimate_6_blocks.get(), 0.00015); + assert_eq!(collector.metrics().fee_estimate_12_blocks.get(), 0.00010); + assert_eq!(collector.metrics().fee_estimate_144_blocks.get(), 0.00005); + + // Chain tips + assert_eq!(collector.metrics().chain_tips_count.get(), 2.0); + + // Uptime + assert_eq!(collector.metrics().node_uptime_seconds.get(), 86400.0); + + // Latest block stats + assert_eq!(collector.metrics().latest_block_txs.get(), 2500.0); + assert_eq!(collector.metrics().latest_block_size.get(), 2_000_000.0); + assert_eq!(collector.metrics().latest_block_weight.get(), 3_993_000.0); + assert_eq!(collector.metrics().latest_block_avg_fee.get(), 15_000.0); + assert_eq!(collector.metrics().latest_block_avg_fee_rate.get(), 25.0); + assert_eq!(collector.metrics().latest_block_median_fee.get(), 10_000.0); + assert_eq!(collector.metrics().latest_block_min_fee.get(), 500.0); + assert_eq!(collector.metrics().latest_block_max_fee.get(), 500_000.0); + assert_eq!(collector.metrics().latest_block_min_fee_rate.get(), 1.0); + assert_eq!(collector.metrics().latest_block_max_fee_rate.get(), 200.0); + assert_eq!(collector.metrics().latest_block_total_fee.get(), 37_500_000.0); + assert_eq!(collector.metrics().latest_block_subsidy.get(), 625_000_000.0); + assert_eq!(collector.metrics().latest_block_inputs.get(), 6000.0); + assert_eq!(collector.metrics().latest_block_outputs.get(), 8000.0); + assert_eq!(collector.metrics().latest_block_segwit_txs.get(), 2000.0); + assert_eq!(collector.metrics().latest_block_total_out.get(), 500_000_000_000.0); + assert_eq!(collector.metrics().latest_block_utxo_increase.get(), 500.0); + assert_eq!(collector.metrics().latest_block_fee_rate_10th.get(), 5.0); + assert_eq!(collector.metrics().latest_block_fee_rate_25th.get(), 10.0); + assert_eq!(collector.metrics().latest_block_fee_rate_50th.get(), 20.0); + assert_eq!(collector.metrics().latest_block_fee_rate_75th.get(), 50.0); + assert_eq!(collector.metrics().latest_block_fee_rate_90th.get(), 100.0); + + // Meta + assert_eq!(collector.metrics().scrape_error.get(), 0.0); + } + + struct PartialFailNode; + + impl NodeClient for PartialFailNode { + fn get_blockchain_info(&self) -> Result { + MockNode.get_blockchain_info() + } + + fn get_mempool_info(&self) -> Result { + Err(Error::Config("simulated failure".to_string())) + } + + fn get_network_info(&self) -> Result { + MockNode.get_network_info() + } + + fn get_peer_info(&self) -> Result { + MockNode.get_peer_info() + } + + fn get_mining_info(&self) -> Result { + MockNode.get_mining_info() + } + + fn get_chain_tx_stats(&self) -> Result { + MockNode.get_chain_tx_stats() + } + + fn get_net_totals(&self) -> Result { + MockNode.get_net_totals() + } + + fn estimate_smart_fee(&self, conf_target: u32) -> Result { + MockNode.estimate_smart_fee(conf_target) + } + + fn get_chain_tips(&self) -> Result { + MockNode.get_chain_tips() + } + + fn uptime(&self) -> Result { + MockNode.uptime() + } + + fn get_block_stats_by_height(&self, height: u32) -> Result { + MockNode.get_block_stats_by_height(height) + } + } + + #[test] + fn test_partial_failure_sets_error_gauge() { + let metrics = BitcoinMetrics::new().unwrap(); + let collector = MetricsCollector::new(PartialFailNode, metrics); + + collector.collect(); + + // Blockchain info should still be collected + assert_eq!(collector.metrics().blocks.get(), 800_000.0); + // But error gauge should be set + assert_eq!(collector.metrics().scrape_error.get(), 1.0); + } +} diff --git a/src/btcnode-metrics/src/config.rs b/src/btcnode-metrics/src/config.rs new file mode 100644 index 0000000..ab3db37 --- /dev/null +++ b/src/btcnode-metrics/src/config.rs @@ -0,0 +1,48 @@ +use serde::Deserialize; +use std::path::Path; + +use crate::Error; + +#[derive(Debug, Deserialize)] +pub struct AppConfig { + pub node: NodeConfig, + pub server: ServerConfig, +} + +#[derive(Debug, Deserialize)] +pub struct NodeConfig { + pub rpc_url: String, + pub rpc_user: String, + pub rpc_password: String, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + pub listen_addr: String, +} + +impl AppConfig { + pub fn load(path: &Path) -> Result { + let contents = std::fs::read_to_string(path) + .map_err(|e| Error::Config(format!("failed to read config file: {e}")))?; + + let mut config: AppConfig = toml::from_str(&contents) + .map_err(|e| Error::Config(format!("failed to parse config: {e}")))?; + + // Environment variable overrides + if let Ok(val) = std::env::var("BTC_METRICS_RPC_URL") { + config.node.rpc_url = val; + } + if let Ok(val) = std::env::var("BTC_METRICS_RPC_USER") { + config.node.rpc_user = val; + } + if let Ok(val) = std::env::var("BTC_METRICS_RPC_PASSWORD") { + config.node.rpc_password = val; + } + if let Ok(val) = std::env::var("BTC_METRICS_LISTEN_ADDR") { + config.server.listen_addr = val; + } + + Ok(config) + } +} diff --git a/src/btcnode-metrics/src/error.rs b/src/btcnode-metrics/src/error.rs new file mode 100644 index 0000000..fc46341 --- /dev/null +++ b/src/btcnode-metrics/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Bitcoin RPC error: {0}")] + Rpc(#[from] corepc_client::client_sync::Error), + + #[error("Prometheus error: {0}")] + Prometheus(#[from] prometheus::Error), + + #[error("Configuration error: {0}")] + Config(String), +} diff --git a/src/btcnode-metrics/src/lib.rs b/src/btcnode-metrics/src/lib.rs new file mode 100644 index 0000000..d5c57ef --- /dev/null +++ b/src/btcnode-metrics/src/lib.rs @@ -0,0 +1,13 @@ +pub mod collector; +pub mod config; +pub mod error; +pub mod metrics; +pub mod node; +pub mod service; + +pub use config::AppConfig; +pub use error::Error; +pub use metrics::BitcoinMetrics; +pub use node::{BitcoinNode, NodeClient}; +pub use collector::MetricsCollector; +pub use service::MetricsService; diff --git a/src/btcnode-metrics/src/metrics.rs b/src/btcnode-metrics/src/metrics.rs new file mode 100644 index 0000000..52895f8 --- /dev/null +++ b/src/btcnode-metrics/src/metrics.rs @@ -0,0 +1,292 @@ +use prometheus::{Gauge, Registry, Opts}; + +use crate::Error; + +pub struct BitcoinMetrics { + pub registry: Registry, + + // Blockchain info + pub blocks: Gauge, + pub headers: Gauge, + pub difficulty: Gauge, + pub verification_progress: Gauge, + pub size_on_disk: Gauge, + pub initial_block_download: Gauge, + pub chain_pruned: Gauge, + + // Mempool info + pub mempool_transactions: Gauge, + pub mempool_bytes: Gauge, + pub mempool_usage: Gauge, + pub mempool_max_bytes: Gauge, + pub mempool_min_fee: Gauge, + pub mempool_total_fee: Gauge, + pub mempool_min_relay_tx_fee: Gauge, + pub mempool_incremental_relay_fee: Gauge, + pub mempool_unbroadcast_count: Gauge, + pub mempool_full_rbf: Gauge, + + // Network info + pub connections: Gauge, + pub connections_in: Gauge, + pub connections_out: Gauge, + pub network_active: Gauge, + pub node_version: Gauge, + pub protocol_version: Gauge, + pub time_offset: Gauge, + pub relay_fee: Gauge, + pub incremental_fee: Gauge, + + // Peer info (aggregated) + pub peer_count: Gauge, + pub peers_inbound: Gauge, + pub peers_outbound: Gauge, + pub peers_total_bytes_sent: Gauge, + pub peers_total_bytes_received: Gauge, + pub peers_avg_ping_seconds: Gauge, + + // Mining info + pub network_hash_ps: Gauge, + pub mining_pooled_tx: Gauge, + + // Chain tx stats + pub chain_tx_count: Gauge, + pub chain_tx_rate: Gauge, + pub chain_tx_window_block_count: Gauge, + pub chain_tx_window_tx_count: Gauge, + pub chain_tx_window_interval: Gauge, + + // Net totals + pub net_total_bytes_received: Gauge, + pub net_total_bytes_sent: Gauge, + + // Fee estimation (BTC/kvB for various confirmation targets) + pub fee_estimate_2_blocks: Gauge, + pub fee_estimate_6_blocks: Gauge, + pub fee_estimate_12_blocks: Gauge, + pub fee_estimate_144_blocks: Gauge, + + // Chain tips + pub chain_tips_count: Gauge, + + // Uptime + pub node_uptime_seconds: Gauge, + + // Latest block stats + pub latest_block_txs: Gauge, + pub latest_block_size: Gauge, + pub latest_block_weight: Gauge, + pub latest_block_avg_fee: Gauge, + pub latest_block_avg_fee_rate: Gauge, + pub latest_block_median_fee: Gauge, + pub latest_block_min_fee: Gauge, + pub latest_block_max_fee: Gauge, + pub latest_block_min_fee_rate: Gauge, + pub latest_block_max_fee_rate: Gauge, + pub latest_block_total_fee: Gauge, + pub latest_block_subsidy: Gauge, + pub latest_block_inputs: Gauge, + pub latest_block_outputs: Gauge, + pub latest_block_segwit_txs: Gauge, + pub latest_block_segwit_total_size: Gauge, + pub latest_block_segwit_total_weight: Gauge, + pub latest_block_total_out: Gauge, + pub latest_block_utxo_increase: Gauge, + pub latest_block_fee_rate_10th: Gauge, + pub latest_block_fee_rate_25th: Gauge, + pub latest_block_fee_rate_50th: Gauge, + pub latest_block_fee_rate_75th: Gauge, + pub latest_block_fee_rate_90th: Gauge, + + // Collector meta + pub scrape_duration_seconds: Gauge, + pub scrape_error: Gauge, +} + +macro_rules! register_gauge { + ($registry:expr, $name:expr, $help:expr) => {{ + let gauge = Gauge::with_opts(Opts::new($name, $help))?; + $registry.register(Box::new(gauge.clone()))?; + gauge + }}; +} + +impl BitcoinMetrics { + pub fn new() -> Result { + let registry = Registry::new(); + + // Blockchain info + let blocks = register_gauge!(registry, "bitcoin_blocks", "Current block height"); + let headers = register_gauge!(registry, "bitcoin_headers", "Current number of headers"); + let difficulty = register_gauge!(registry, "bitcoin_difficulty", "Current mining difficulty"); + let verification_progress = register_gauge!(registry, "bitcoin_verification_progress", "Estimate of verification progress [0..1]"); + let size_on_disk = register_gauge!(registry, "bitcoin_size_on_disk_bytes", "Estimated size of the block and undo files on disk"); + let initial_block_download = register_gauge!(registry, "bitcoin_initial_block_download", "Whether node is in initial block download (1=true, 0=false)"); + let chain_pruned = register_gauge!(registry, "bitcoin_chain_pruned", "Whether the blockchain is pruned (1=true, 0=false)"); + + // Mempool info + let mempool_transactions = register_gauge!(registry, "bitcoin_mempool_transactions", "Current number of transactions in the mempool"); + let mempool_bytes = register_gauge!(registry, "bitcoin_mempool_bytes", "Sum of all virtual transaction sizes in the mempool"); + let mempool_usage = register_gauge!(registry, "bitcoin_mempool_usage_bytes", "Total memory usage for the mempool"); + let mempool_max_bytes = register_gauge!(registry, "bitcoin_mempool_max_bytes", "Maximum memory usage for the mempool"); + let mempool_min_fee = register_gauge!(registry, "bitcoin_mempool_min_fee_btc_per_kvb", "Minimum fee rate in BTC/kvB for tx to be accepted"); + let mempool_total_fee = register_gauge!(registry, "bitcoin_mempool_total_fee_btc", "Total fees of all transactions in the mempool in BTC"); + let mempool_min_relay_tx_fee = register_gauge!(registry, "bitcoin_mempool_min_relay_tx_fee_btc_per_kvb", "Minimum relay transaction fee in BTC/kvB"); + let mempool_incremental_relay_fee = register_gauge!(registry, "bitcoin_mempool_incremental_relay_fee_btc_per_kvb", "Minimum fee rate increment for mempool limiting or BIP 125 replacement in BTC/kvB"); + let mempool_unbroadcast_count = register_gauge!(registry, "bitcoin_mempool_unbroadcast_count", "Number of transactions that haven't been broadcast yet"); + let mempool_full_rbf = register_gauge!(registry, "bitcoin_mempool_full_rbf", "Whether full replace-by-fee is enabled (1=true, 0=false)"); + + // Network info + let connections = register_gauge!(registry, "bitcoin_connections", "Total number of connections"); + let connections_in = register_gauge!(registry, "bitcoin_connections_in", "Number of inbound connections"); + let connections_out = register_gauge!(registry, "bitcoin_connections_out", "Number of outbound connections"); + let network_active = register_gauge!(registry, "bitcoin_network_active", "Whether p2p networking is active (1=true, 0=false)"); + let node_version = register_gauge!(registry, "bitcoin_version", "Bitcoin node version as integer"); + let protocol_version = register_gauge!(registry, "bitcoin_protocol_version", "Protocol version number"); + let time_offset = register_gauge!(registry, "bitcoin_time_offset_seconds", "Time offset from network median in seconds"); + let relay_fee = register_gauge!(registry, "bitcoin_relay_fee_btc_per_kvb", "Minimum relay fee for transactions in BTC/kvB"); + let incremental_fee = register_gauge!(registry, "bitcoin_incremental_fee_btc_per_kvb", "Minimum fee increment for mempool limiting in BTC/kvB"); + + // Peer info (aggregated) + let peer_count = register_gauge!(registry, "bitcoin_peer_count", "Number of connected peers"); + let peers_inbound = register_gauge!(registry, "bitcoin_peers_inbound", "Number of inbound peers"); + let peers_outbound = register_gauge!(registry, "bitcoin_peers_outbound", "Number of outbound peers"); + let peers_total_bytes_sent = register_gauge!(registry, "bitcoin_peers_total_bytes_sent", "Total bytes sent across all peers"); + let peers_total_bytes_received = register_gauge!(registry, "bitcoin_peers_total_bytes_received", "Total bytes received across all peers"); + let peers_avg_ping_seconds = register_gauge!(registry, "bitcoin_peers_avg_ping_seconds", "Average ping time across all peers in seconds"); + + // Mining info + let network_hash_ps = register_gauge!(registry, "bitcoin_network_hash_per_second", "Estimated network hashes per second"); + let mining_pooled_tx = register_gauge!(registry, "bitcoin_mining_pooled_transactions", "Number of transactions in the mining pool"); + + // Chain tx stats + let chain_tx_count = register_gauge!(registry, "bitcoin_chain_tx_count", "Total number of transactions in the chain"); + let chain_tx_rate = register_gauge!(registry, "bitcoin_chain_tx_rate_per_second", "Average transaction rate per second over the window"); + let chain_tx_window_block_count = register_gauge!(registry, "bitcoin_chain_tx_window_block_count", "Number of blocks in the stats window"); + let chain_tx_window_tx_count = register_gauge!(registry, "bitcoin_chain_tx_window_tx_count", "Number of transactions in the stats window"); + let chain_tx_window_interval = register_gauge!(registry, "bitcoin_chain_tx_window_interval_seconds", "Elapsed time of the stats window in seconds"); + + // Net totals + let net_total_bytes_received = register_gauge!(registry, "bitcoin_net_total_bytes_received", "Total bytes received since node start"); + let net_total_bytes_sent = register_gauge!(registry, "bitcoin_net_total_bytes_sent", "Total bytes sent since node start"); + + // Fee estimation + let fee_estimate_2_blocks = register_gauge!(registry, "bitcoin_fee_estimate_2_blocks_btc_per_kvb", "Estimated fee rate for confirmation within 2 blocks in BTC/kvB"); + let fee_estimate_6_blocks = register_gauge!(registry, "bitcoin_fee_estimate_6_blocks_btc_per_kvb", "Estimated fee rate for confirmation within 6 blocks in BTC/kvB"); + let fee_estimate_12_blocks = register_gauge!(registry, "bitcoin_fee_estimate_12_blocks_btc_per_kvb", "Estimated fee rate for confirmation within 12 blocks in BTC/kvB"); + let fee_estimate_144_blocks = register_gauge!(registry, "bitcoin_fee_estimate_144_blocks_btc_per_kvb", "Estimated fee rate for confirmation within 144 blocks in BTC/kvB"); + + // Chain tips + let chain_tips_count = register_gauge!(registry, "bitcoin_chain_tips_count", "Number of known chain tips (forks)"); + + // Uptime + let node_uptime_seconds = register_gauge!(registry, "bitcoin_node_uptime_seconds", "Node uptime in seconds"); + + // Latest block stats + let latest_block_txs = register_gauge!(registry, "bitcoin_latest_block_transactions", "Number of transactions in the latest block"); + let latest_block_size = register_gauge!(registry, "bitcoin_latest_block_size_bytes", "Total size of the latest block in bytes"); + let latest_block_weight = register_gauge!(registry, "bitcoin_latest_block_weight", "Total weight of the latest block"); + let latest_block_avg_fee = register_gauge!(registry, "bitcoin_latest_block_avg_fee_sat", "Average fee per transaction in the latest block in satoshis"); + let latest_block_avg_fee_rate = register_gauge!(registry, "bitcoin_latest_block_avg_fee_rate_sat_per_vb", "Average fee rate in the latest block in sat/vB"); + let latest_block_median_fee = register_gauge!(registry, "bitcoin_latest_block_median_fee_sat", "Median fee in the latest block in satoshis"); + let latest_block_min_fee = register_gauge!(registry, "bitcoin_latest_block_min_fee_sat", "Minimum fee in the latest block in satoshis"); + let latest_block_max_fee = register_gauge!(registry, "bitcoin_latest_block_max_fee_sat", "Maximum fee in the latest block in satoshis"); + let latest_block_min_fee_rate = register_gauge!(registry, "bitcoin_latest_block_min_fee_rate_sat_per_vb", "Minimum fee rate in the latest block in sat/vB"); + let latest_block_max_fee_rate = register_gauge!(registry, "bitcoin_latest_block_max_fee_rate_sat_per_vb", "Maximum fee rate in the latest block in sat/vB"); + let latest_block_total_fee = register_gauge!(registry, "bitcoin_latest_block_total_fee_sat", "Total fees in the latest block in satoshis"); + let latest_block_subsidy = register_gauge!(registry, "bitcoin_latest_block_subsidy_sat", "Block subsidy (reward) of the latest block in satoshis"); + let latest_block_inputs = register_gauge!(registry, "bitcoin_latest_block_inputs", "Number of inputs in the latest block (excluding coinbase)"); + let latest_block_outputs = register_gauge!(registry, "bitcoin_latest_block_outputs", "Number of outputs in the latest block"); + let latest_block_segwit_txs = register_gauge!(registry, "bitcoin_latest_block_segwit_transactions", "Number of segwit transactions in the latest block"); + let latest_block_segwit_total_size = register_gauge!(registry, "bitcoin_latest_block_segwit_total_size_bytes", "Total size of segwit transactions in the latest block"); + let latest_block_segwit_total_weight = register_gauge!(registry, "bitcoin_latest_block_segwit_total_weight", "Total weight of segwit transactions in the latest block"); + let latest_block_total_out = register_gauge!(registry, "bitcoin_latest_block_total_out_sat", "Total output value in the latest block in satoshis (excluding coinbase)"); + let latest_block_utxo_increase = register_gauge!(registry, "bitcoin_latest_block_utxo_increase", "Change in UTXO count from the latest block"); + let latest_block_fee_rate_10th = register_gauge!(registry, "bitcoin_latest_block_fee_rate_10th_percentile_sat_per_vb", "10th percentile fee rate in the latest block in sat/vB"); + let latest_block_fee_rate_25th = register_gauge!(registry, "bitcoin_latest_block_fee_rate_25th_percentile_sat_per_vb", "25th percentile fee rate in the latest block in sat/vB"); + let latest_block_fee_rate_50th = register_gauge!(registry, "bitcoin_latest_block_fee_rate_50th_percentile_sat_per_vb", "50th percentile (median) fee rate in the latest block in sat/vB"); + let latest_block_fee_rate_75th = register_gauge!(registry, "bitcoin_latest_block_fee_rate_75th_percentile_sat_per_vb", "75th percentile fee rate in the latest block in sat/vB"); + let latest_block_fee_rate_90th = register_gauge!(registry, "bitcoin_latest_block_fee_rate_90th_percentile_sat_per_vb", "90th percentile fee rate in the latest block in sat/vB"); + + // Collector meta + let scrape_duration_seconds = register_gauge!(registry, "bitcoin_collector_last_scrape_duration_seconds", "Duration of the last metrics collection in seconds"); + let scrape_error = register_gauge!(registry, "bitcoin_collector_last_scrape_error", "Whether the last scrape had an error (1=error, 0=ok)"); + + Ok(Self { + registry, + blocks, + headers, + difficulty, + verification_progress, + size_on_disk, + initial_block_download, + chain_pruned, + mempool_transactions, + mempool_bytes, + mempool_usage, + mempool_max_bytes, + mempool_min_fee, + mempool_total_fee, + mempool_min_relay_tx_fee, + mempool_incremental_relay_fee, + mempool_unbroadcast_count, + mempool_full_rbf, + connections, + connections_in, + connections_out, + network_active, + node_version, + protocol_version, + time_offset, + relay_fee, + incremental_fee, + peer_count, + peers_inbound, + peers_outbound, + peers_total_bytes_sent, + peers_total_bytes_received, + peers_avg_ping_seconds, + network_hash_ps, + mining_pooled_tx, + chain_tx_count, + chain_tx_rate, + chain_tx_window_block_count, + chain_tx_window_tx_count, + chain_tx_window_interval, + net_total_bytes_received, + net_total_bytes_sent, + fee_estimate_2_blocks, + fee_estimate_6_blocks, + fee_estimate_12_blocks, + fee_estimate_144_blocks, + chain_tips_count, + node_uptime_seconds, + latest_block_txs, + latest_block_size, + latest_block_weight, + latest_block_avg_fee, + latest_block_avg_fee_rate, + latest_block_median_fee, + latest_block_min_fee, + latest_block_max_fee, + latest_block_min_fee_rate, + latest_block_max_fee_rate, + latest_block_total_fee, + latest_block_subsidy, + latest_block_inputs, + latest_block_outputs, + latest_block_segwit_txs, + latest_block_segwit_total_size, + latest_block_segwit_total_weight, + latest_block_total_out, + latest_block_utxo_increase, + latest_block_fee_rate_10th, + latest_block_fee_rate_25th, + latest_block_fee_rate_50th, + latest_block_fee_rate_75th, + latest_block_fee_rate_90th, + scrape_duration_seconds, + scrape_error, + }) + } +} diff --git a/src/btcnode-metrics/src/node.rs b/src/btcnode-metrics/src/node.rs new file mode 100644 index 0000000..810248c --- /dev/null +++ b/src/btcnode-metrics/src/node.rs @@ -0,0 +1,126 @@ +use corepc_client::client_sync::{v28::Client, Auth}; +use corepc_client::types::v28::{ + EstimateSmartFee, GetBlockStats, GetBlockchainInfo, GetChainTips, GetMempoolInfo, GetNetTotals, + GetNetworkInfo, GetPeerInfo, +}; +use serde::Deserialize; + +use crate::Error; +use crate::config::NodeConfig; + +/// Custom type for `getmininginfo` that fixes `network_hash_ps` from `i64` to `f64`. +/// +/// Bitcoin Core returns `networkhashps` as a floating-point number (e.g. `1.02e+21`) +/// but the upstream `corepc-types` crate incorrectly declares the field as `i64`, +/// which causes deserialization to fail on mainnet. +#[derive(Clone, Debug, Deserialize)] +pub struct MiningInfo { + pub blocks: u64, + #[serde(rename = "currentblockweight")] + pub current_block_weight: Option, + #[serde(rename = "currentblocktx")] + pub current_block_tx: Option, + pub difficulty: f64, + #[serde(rename = "networkhashps")] + pub network_hash_ps: f64, + #[serde(rename = "pooledtx")] + pub pooled_tx: i64, + pub chain: String, + pub warnings: Vec, +} + +/// Custom type for `getchaintxstats` that fixes `tx_rate` from `Option` to `Option`. +/// +/// Bitcoin Core returns `txrate` as a floating-point number (e.g. `4.56`) +/// but the upstream `corepc-types` crate incorrectly declares the field as `Option`, +/// which causes deserialization to fail. +#[derive(Clone, Debug, Deserialize)] +pub struct ChainTxStats { + pub time: i64, + #[serde(rename = "txcount")] + pub tx_count: i64, + pub window_final_block_hash: String, + pub window_final_block_height: i64, + pub window_block_count: i64, + pub window_tx_count: Option, + pub window_interval: Option, + #[serde(rename = "txrate")] + pub tx_rate: Option, +} + +pub trait NodeClient: Send + Sync { + fn get_blockchain_info(&self) -> Result; + fn get_mempool_info(&self) -> Result; + fn get_network_info(&self) -> Result; + fn get_peer_info(&self) -> Result; + fn get_mining_info(&self) -> Result; + fn get_chain_tx_stats(&self) -> Result; + fn get_net_totals(&self) -> Result; + fn estimate_smart_fee(&self, conf_target: u32) -> Result; + fn get_chain_tips(&self) -> Result; + fn uptime(&self) -> Result; + fn get_block_stats_by_height(&self, height: u32) -> Result; +} + +pub struct BitcoinNode { + client: Client, +} + +impl BitcoinNode { + pub fn new(config: &NodeConfig) -> Result { + let auth = Auth::UserPass(config.rpc_user.clone(), config.rpc_password.clone()); + let client = Client::new_with_auth(&config.rpc_url, auth) + .map_err(|e| Error::Config(format!("failed to create RPC client: {e}")))?; + Ok(Self { client }) + } +} + +impl NodeClient for BitcoinNode { + fn get_blockchain_info(&self) -> Result { + Ok(self.client.get_blockchain_info()?) + } + + fn get_mempool_info(&self) -> Result { + Ok(self.client.get_mempool_info()?) + } + + fn get_network_info(&self) -> Result { + Ok(self.client.get_network_info()?) + } + + fn get_peer_info(&self) -> Result { + Ok(self.client.get_peer_info()?) + } + + fn get_mining_info(&self) -> Result { + // Bypass upstream GetMiningInfo (which declares network_hash_ps as i64) + // and deserialize directly into our corrected MiningInfo type. + Ok(self.client.call::("getmininginfo", &[])?) + } + + fn get_chain_tx_stats(&self) -> Result { + // Bypass upstream GetChainTxStats (which declares tx_rate as Option) + // and deserialize directly into our corrected ChainTxStats type. + Ok(self.client.call::("getchaintxstats", &[])?) + } + + fn get_net_totals(&self) -> Result { + Ok(self.client.get_net_totals()?) + } + + fn estimate_smart_fee(&self, conf_target: u32) -> Result { + Ok(self.client.estimate_smart_fee(conf_target)?) + } + + fn get_chain_tips(&self) -> Result { + Ok(self.client.get_chain_tips()?) + } + + fn uptime(&self) -> Result { + Ok(self.client.uptime()?) + } + + fn get_block_stats_by_height(&self, height: u32) -> Result { + Ok(self.client.get_block_stats_by_height(height)?) + } +} diff --git a/src/btcnode-metrics/src/service.rs b/src/btcnode-metrics/src/service.rs new file mode 100644 index 0000000..a7041f4 --- /dev/null +++ b/src/btcnode-metrics/src/service.rs @@ -0,0 +1,23 @@ +use crate::{MetricsCollector, NodeClient}; +use prometheus::Encoder; +use prometheus::TextEncoder; + +pub struct MetricsService { + collector: MetricsCollector, +} + +impl MetricsService { + pub fn new(collector: MetricsCollector) -> Self { + Self { collector } + } + + pub fn scrape(&self) -> String { + self.collector.collect(); + + let encoder = TextEncoder::new(); + let metric_families = self.collector.metrics().registry.gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).expect("encoding metrics should not fail"); + String::from_utf8(buffer).expect("prometheus text format is valid UTF-8") + } +} diff --git a/src/prom-api/Cargo.toml b/src/prom-api/Cargo.toml new file mode 100644 index 0000000..271879b --- /dev/null +++ b/src/prom-api/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "prom-api" +version = "0.1.0" +edition.workspace = true + +[dependencies] +btcnode-metrics = { path = "../btcnode-metrics" } +axum.workspace = true +tokio.workspace = true +prometheus.workspace = true +serde.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +clap.workspace = true +toml.workspace = true +anyhow.workspace = true diff --git a/src/prom-api/src/handlers.rs b/src/prom-api/src/handlers.rs new file mode 100644 index 0000000..7025b69 --- /dev/null +++ b/src/prom-api/src/handlers.rs @@ -0,0 +1,26 @@ +use axum::extract::State; +use axum::http::{StatusCode, header}; +use axum::response::IntoResponse; + +use crate::state::AppState; + +pub async fn metrics_handler(State(state): State) -> impl IntoResponse { + let service = state.service.clone(); + match tokio::task::spawn_blocking(move || service.scrape()).await { + Ok(body) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")], + body, + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("metrics collection failed: {e}"), + ) + .into_response(), + } +} + +pub async fn health_handler() -> impl IntoResponse { + (StatusCode::OK, "ok") +} diff --git a/src/prom-api/src/main.rs b/src/prom-api/src/main.rs new file mode 100644 index 0000000..1006c20 --- /dev/null +++ b/src/prom-api/src/main.rs @@ -0,0 +1,63 @@ +mod handlers; +mod state; + +use std::path::PathBuf; +use std::sync::Arc; + +use axum::Router; +use axum::routing::get; +use clap::Parser; +use tokio::net::TcpListener; +use tracing::info; +use tracing_subscriber::EnvFilter; + +use btcnode_metrics::{AppConfig, BitcoinMetrics, BitcoinNode, MetricsCollector, MetricsService}; + +use crate::state::AppState; + +#[derive(Parser)] +#[command(name = "btc-metrics", about = "Bitcoin node metrics exporter for Prometheus")] +struct Cli { + #[arg(short, long, default_value = "config.toml")] + config: PathBuf, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .init(); + + let cli = Cli::parse(); + let config = AppConfig::load(&cli.config)?; + + info!(rpc_url = %config.node.rpc_url, "Connecting to Bitcoin node"); + + let node = BitcoinNode::new(&config.node)?; + let metrics = BitcoinMetrics::new()?; + let collector = MetricsCollector::new(node, metrics); + let service = Arc::new(MetricsService::new(collector)); + + let state = AppState { service }; + + let app = Router::new() + .route("/metrics", get(handlers::metrics_handler)) + .route("/health", get(handlers::health_handler)) + .with_state(state); + + let listener = TcpListener::bind(&config.server.listen_addr).await?; + info!(addr = %config.server.listen_addr, "Listening for Prometheus scrapes"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn shutdown_signal() { + tokio::signal::ctrl_c() + .await + .expect("failed to install CTRL+C signal handler"); + info!("Shutdown signal received"); +} diff --git a/src/prom-api/src/state.rs b/src/prom-api/src/state.rs new file mode 100644 index 0000000..c5d357f --- /dev/null +++ b/src/prom-api/src/state.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use btcnode_metrics::{BitcoinNode, MetricsService}; + +pub struct AppState { + pub service: Arc>, +} + +impl Clone for AppState { + fn clone(&self) -> Self { + Self { + service: Arc::clone(&self.service), + } + } +}