From bd29059c9aab15e0f3db979bdf275e67130c8a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 6 Jun 2026 14:41:30 -0300 Subject: [PATCH 1/2] feat(metrics): optional metrics layer (off by default, Prometheus/OTLP-ready) Adds an opt-in `metrics` feature that emits wa_* metrics through the `metrics` facade for rates and latency percentiles, complementing the tracing spans (PR #733). Off by default: no dependency and zero overhead - every emit is an inlined no-op and the duration Timer is a zero-sized type that reads no clock. The library only emits; the application installs a recorder (Prometheus, OTLP, ...). See examples/metrics.rs. wacore::telemetry provides typed helpers (counters/gauges plus a record-on-drop Timer), so call sites stay clean and labels are strictly low-cardinality categorical values (outcome/kind/result/reason) - never a JID, phone number or message id, which would explode the backend and leak PII. Counters: wa_recv_total{outcome}, wa_send_total{kind}, wa_retry_receipt_total{reason}, wa_iq_total{result}, wa_reconnect_total, wa_stream_error_total, wa_connect_total{outcome}, wa_appstate_sync_total{outcome}, wa_identity_change_total, wa_prekey_upload_total{outcome}. Histograms: wa_iq_duration_seconds, wa_connect_duration_seconds, wa_decrypt_duration_seconds, wa_send_duration_seconds, wa_appstate_sync_duration_seconds. Gauges: wa_connected (plus wa_pending_retries helper). Emitted at the same boundaries as the wa.* spans (connect/reconnect, recv/decrypt, send, IQ, app-state, retry, identity, prekey). Durations use the pluggable wacore::time::Instant so WASM/deterministic builds are unaffected. RetryReason gained a stable as_str() for the reason label. Verified: clippy --all-targets -- -D warnings clean both with and without --features metrics; fmt clean; cargo test --workspace --exclude e2e-tests green (1992 passed, 0 failed; additive and cfg-gated, no behavior change). --- Cargo.lock | 507 ++++++++++++++++++++++++++++++++++- Cargo.toml | 16 +- examples/metrics.rs | 38 +++ src/client/app_state.rs | 6 +- src/client/lifecycle.rs | 6 + src/client/node_io.rs | 1 + src/handlers/notification.rs | 1 + src/lib.rs | 2 + src/message/dispatch.rs | 1 + src/message/receive.rs | 1 + src/message/retry.rs | 2 + src/prekeys.rs | 4 +- src/request.rs | 11 +- src/send.rs | 6 + wacore/Cargo.toml | 5 +- wacore/src/lib.rs | 1 + wacore/src/protocol/retry.rs | 22 ++ wacore/src/telemetry.rs | 214 +++++++++++++++ 18 files changed, 823 insertions(+), 21 deletions(-) create mode 100644 examples/metrics.rs create mode 100644 wacore/src/telemetry.rs diff --git a/Cargo.lock b/Cargo.lock index b33d5a1df..5d0ab690e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,12 +117,40 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -256,6 +284,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -299,6 +329,15 @@ dependencies = [ "inout", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -329,6 +368,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -606,6 +655,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "e2e-tests" version = "0.0.0" @@ -741,6 +796,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -840,6 +901,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -849,7 +922,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", @@ -871,6 +944,25 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -947,12 +1039,41 @@ dependencies = [ "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 = "hybrid-array" version = "0.4.12" @@ -962,6 +1083,64 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iai-callgrind" version = "0.16.1" @@ -1056,6 +1235,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itertools" version = "0.14.0" @@ -1071,6 +1256,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1154,6 +1349,53 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "migrations_internals" version = "2.3.0" @@ -1267,6 +1509,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1348,6 +1596,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1438,6 +1695,21 @@ dependencies = [ "prost", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quote" version = "1.0.45" @@ -1447,6 +1719,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1464,6 +1742,16 @@ dependencies = [ "scheduled-thread-pool", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.1" @@ -1475,18 +1763,64 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1546,7 +1880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1589,6 +1923,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -1598,6 +1933,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1613,6 +1960,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1630,6 +1978,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scheduled-thread-pool" version = "0.2.7" @@ -1645,6 +2002,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1771,6 +2151,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "slab" version = "0.4.12" @@ -1870,13 +2256,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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", + "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]] @@ -1998,7 +2404,7 @@ dependencies = [ "futures-sink", "http", "httparse", - "rand", + "rand 0.10.1", "ring", "rustls-pki-types", "simdutf8", @@ -2038,6 +2444,12 @@ dependencies = [ "winnow 1.0.3", ] +[[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" @@ -2099,6 +2511,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typed-builder" version = "0.23.2" @@ -2247,16 +2665,17 @@ dependencies = [ "itoa", "log", "md5", + "metrics", "portable-atomic", "prost", - "rand", + "rand 0.10.1", "serde", "serde-big-array", "serde_json", "sha1", "sha2", "subtle", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "typed-builder", @@ -2282,7 +2701,7 @@ dependencies = [ "serde-big-array", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.18", "wacore-binary", "wacore-libsignal", "waproto", @@ -2336,12 +2755,12 @@ dependencies = [ "iai-callgrind", "log", "prost", - "rand", + "rand 0.10.1", "serde", "sha1", "sha2", "subtle", - "thiserror", + "thiserror 2.0.18", "uuid", "waproto", "x25519-dalek", @@ -2356,14 +2775,23 @@ dependencies = [ "hkdf", "log", "prost", - "rand", + "rand 0.10.1", "sha2", - "thiserror", + "thiserror 2.0.18", "wacore-binary", "wacore-libsignal", "waproto", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "waproto" version = "0.6.0" @@ -2476,6 +2904,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -2508,15 +2946,16 @@ dependencies = [ "hmac", "itoa", "log", + "metrics-exporter-prometheus", "moka", "portable-atomic", "prost", - "rand", + "rand 0.10.1", "scopeguard", "serde", "serde_json", "sha2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -2577,6 +3016,28 @@ dependencies = [ "wacore", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2859,6 +3320,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 71c879b7e..48ffc4d75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,8 +69,8 @@ hmac = { version = "0.13.0", default-features = false } iai-callgrind = "0.16" itoa = "1" log = "0.4" +metrics = "0.24" portable-atomic = { version = "1", default-features = false, features = ["fallback"] } -tracing = { version = "0.1", default-features = false, features = ["attributes"] } prost = { version = "0.14.3", default-features = false, features = ["std"] } prost-build = { version = "0.14.3", default-features = false } rand = "0.10" @@ -82,6 +82,7 @@ sha2 = { version = "0.11.0", default-features = false } subtle = { version = "2.6", default-features = false } thiserror = "2.0.17" tokio = { version = "1.48.0", default-features = false } +tracing = { version = "0.1", default-features = false, features = ["attributes"] } uuid = { version = "1", default-features = false } # Internal workspace crates @@ -101,6 +102,10 @@ debug-snapshots = ["wacore/debug-snapshots"] # Emits tracing spans/events only; the application installs the subscriber # (and any OpenTelemetry bridge). See examples/observability.rs. tracing = ["dep:tracing", "wacore/tracing"] +# Optional metrics (counters/histograms/gauges via the `metrics` facade). Off by +# default: no dependency, zero overhead. Emits only; the application installs a +# recorder (e.g. metrics-exporter-prometheus). See examples/metrics.rs. +metrics = ["wacore/metrics"] # Render raw phone numbers in tracing fields instead of the redacted `pn#`. # Local debugging only; never enable in production. tracing-pii = ["wacore/tracing-pii", "wacore-binary/tracing-pii"] @@ -139,7 +144,6 @@ futures = { workspace = true, features = ["std"] } hex = { workspace = true } itoa = { workspace = true } log = { workspace = true } -tracing = { workspace = true, optional = true } moka = { version = "0.12.12", features = ["future"], optional = true } portable-atomic = { workspace = true } prost = { workspace = true } @@ -149,6 +153,7 @@ serde = { workspace = true } serde_json = { workspace = true, features = ["std"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"], optional = true } +tracing = { workspace = true, optional = true } wacore = { workspace = true } wacore-binary = { workspace = true } waproto = { workspace = true } @@ -171,9 +176,10 @@ cbc = { version = "0.2", features = ["alloc", "block-padding"] } flate2 = { workspace = true } hkdf = { workspace = true } hmac = { workspace = true } +metrics-exporter-prometheus = "0.16" sha2 = { workspace = true } -uuid = { workspace = true, features = ["v4"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { workspace = true, features = ["v4"] } wacore-noise = { path = "./wacore/noise", features = [ "test-util", "danger-skip-cert-chain-verify", @@ -190,6 +196,10 @@ required-features = ["danger-skip-tls-verify"] name = "observability" required-features = ["tracing"] +[[example]] +name = "metrics" +required-features = ["metrics"] + [profile.release] opt-level = 3 debug = false diff --git a/examples/metrics.rs b/examples/metrics.rs new file mode 100644 index 000000000..f3580f9ad --- /dev/null +++ b/examples/metrics.rs @@ -0,0 +1,38 @@ +//! Wiring metrics for `whatsapp-rust`. +//! +//! Run with: +//! cargo run --example metrics --features metrics +//! +//! The library only *emits* metrics through the `metrics` facade (the `wa_*` +//! counters/histograms/gauges in `whatsapp_rust::telemetry`). It never installs a +//! recorder or depends on Prometheus/OTLP; the application does, as shown here. +//! With the `metrics` feature off there is no dependency and every emit is a +//! zero-cost no-op. +//! +//! Metric labels are strictly categorical (outcome, kind, namespace, ...); JIDs, +//! phone numbers and message ids are never used as labels. + +fn main() { + // Install a Prometheus recorder. `install_recorder()` sets the global recorder + // and returns a handle you can render from your own HTTP endpoint. Use + // `PrometheusBuilder::install()` instead (inside a Tokio runtime) to also serve + // `/metrics` on 0.0.0.0:9000 automatically. + let handle = metrics_exporter_prometheus::PrometheusBuilder::new() + .install_recorder() + .expect("install prometheus recorder"); + + // Register units/help for the wa_* metrics (optional, improves the output). + whatsapp_rust::telemetry::describe(); + + // From here you would build and run a `whatsapp_rust::Client` as usual; every + // wa_* metric is recorded into the recorder above. A couple of demo emits: + whatsapp_rust::telemetry::connect("ok"); + whatsapp_rust::telemetry::recv("decrypted"); + { + let _t = whatsapp_rust::telemetry::timer(whatsapp_rust::telemetry::IQ_DURATION); + // ... the IQ round-trip would happen here; the timer records on drop. + } + + // Scrape this from your HTTP `/metrics` handler. + println!("{}", handle.render()); +} diff --git a/src/client/app_state.rs b/src/client/app_state.rs index 24877a219..10d9db04b 100644 --- a/src/client/app_state.rs +++ b/src/client/app_state.rs @@ -62,6 +62,7 @@ impl Client { } async fn fetch_app_state_with_retry_inner(&self, name: WAPatchName) -> anyhow::Result<()> { + let _t = wacore::telemetry::timer(wacore::telemetry::APPSTATE_SYNC_DURATION); let mut attempt = 0u32; loop { attempt += 1; @@ -70,7 +71,10 @@ impl Client { // Matches WA Web which only requests snapshot when version is undefined. let res = self.process_app_state_sync_task(name, false).await; match res { - Ok(()) => return Ok(()), + Ok(()) => { + wacore::telemetry::appstate_sync("ok"); + return Ok(()); + } Err(e) => { if e.downcast_ref::() .is_some_and(|ase| { diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index 4a90a73b7..a40b8e4f0 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -61,6 +61,7 @@ impl Client { /// Dispatch the Connected event and notify waiters. pub(crate) fn dispatch_connected(&self) { self.is_ready.store(true, Ordering::Relaxed); + wacore::telemetry::set_connected(true); self.core .event_bus .dispatch(Event::Connected(crate::types::events::Connected)); @@ -277,6 +278,7 @@ impl Client { self.expected_disconnect.store(false, Ordering::Relaxed); if let Err(connect_err) = self.connect().await { + wacore::telemetry::connect("fail"); let is_transient = connect_err .downcast_ref::() .is_some_and(|e| e.is_transient()); @@ -286,6 +288,7 @@ impl Client { error!("Failed to connect: {connect_err:#}. Will retry..."); } } else { + wacore::telemetry::connect("ok"); let unexpected_disconnect = if self.read_messages_loop().await.is_err() { // Check intentional_reconnect AFTER read loop exits — reconnect() // sets this flag while the loop is running, so it must be read here. @@ -362,6 +365,7 @@ impl Client { if self.is_connected() { return Err(ClientError::AlreadyConnected.into()); } + let _t = wacore::telemetry::timer(wacore::telemetry::CONNECT_DURATION); // Reset login state for new connection attempt. This ensures that // handle_success will properly process the stanza even if @@ -475,6 +479,7 @@ impl Client { )] pub async fn disconnect(self: &Arc) { info!("Disconnecting client intentionally."); + wacore::telemetry::set_connected(false); self.expected_disconnect.store(true, Ordering::Relaxed); self.is_running.store(false, Ordering::Relaxed); self.shutdown_notifier.notify(); @@ -523,6 +528,7 @@ impl Client { )] pub async fn reconnect(self: &Arc) { info!("Reconnecting: dropping transport for auto-reconnect."); + wacore::telemetry::reconnect(); self.intentional_reconnect.store(true, Ordering::Relaxed); self.auto_reconnect_errors .store(Self::RECONNECT_BACKOFF_STEP, Ordering::Relaxed); diff --git a/src/client/node_io.rs b/src/client/node_io.rs index f3a428e4f..060f5e778 100644 --- a/src/client/node_io.rs +++ b/src/client/node_io.rs @@ -968,6 +968,7 @@ impl Client { tracing::instrument(name = "wa.conn.stream_error", level = "debug", skip_all) )] pub(crate) async fn handle_stream_error(&self, node: &wacore_binary::NodeRef<'_>) { + wacore::telemetry::stream_error(); // is_logged_in handling: opt-in branches (515/516/401/409/conflict) clear it // in the disconnect block below; 429/503 clear it inline because the server // explicitly rejected the session and outgoing sends should bail fast; the diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index c702cd6ed..1347d9e3f 100755 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -309,6 +309,7 @@ fn handle_digest_key(client: &Arc) { tracing::instrument(name = "wa.notif.identity_change", level = "debug", skip_all) )] async fn handle_identity_change(client: &Arc, node: &NodeRef<'_>) { + wacore::telemetry::identity_change(); let from_jid = crate::require_from_jid!(node, "Identity change notification"); // Only primary device identity changes matter diff --git a/src/lib.rs b/src/lib.rs index 77f37e129..3c71c0330 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ pub use wacore::appstate::schemas; pub use wacore::client_profile::ClientProfile; +/// Optional metrics emission (the `metrics` feature). No-op when the feature is off. +pub use wacore::telemetry; pub use wacore::{ iq::privacy as privacy_settings, proto_helpers, sticker_pack, store::traits, webp, }; diff --git a/src/message/dispatch.rs b/src/message/dispatch.rs index 92a477937..350222ab8 100644 --- a/src/message/dispatch.rs +++ b/src/message/dispatch.rs @@ -11,6 +11,7 @@ impl Client { info: &Arc, ) { use wacore::proto_helpers::MessageExt; + wacore::telemetry::recv("decrypted"); let mut info = Arc::clone(info); if info.ephemeral_expiration.is_none() diff --git a/src/message/receive.rs b/src/message/receive.rs index 36bbb44c4..0298bd667 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -509,6 +509,7 @@ impl Client { if payloads.is_empty() { return SessionBatchOutcome::default(); } + let _t = wacore::telemetry::timer(wacore::telemetry::DECRYPT_DURATION); // Acquire a per-sender session lock to prevent race conditions when // multiple messages from the same sender are processed concurrently. diff --git a/src/message/retry.rs b/src/message/retry.rs index 7981c3df6..d1cc09918 100644 --- a/src/message/retry.rs +++ b/src/message/retry.rs @@ -32,6 +32,7 @@ impl Client { .await; let was_fresh = fresh.load(std::sync::atomic::Ordering::Acquire); if was_fresh { + wacore::telemetry::recv("undecryptable"); self.core.event_bus.dispatch(Event::UndecryptableMessage( crate::types::events::UndecryptableMessage { info, @@ -214,6 +215,7 @@ impl Client { info: &Arc, reason: RetryReason, ) -> bool { + wacore::telemetry::retry_receipt(reason.as_str()); let cache_key = self .make_retry_cache_key(&info.source.chat, &info.id, &info.source.sender) .await; diff --git a/src/prekeys.rs b/src/prekeys.rs index 226eaa31b..b80ebca04 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -159,7 +159,9 @@ impl Client { log::debug!("Server has {server_count} pre-keys, uploading."); } - self.upload_pre_keys_inner().await + let r = self.upload_pre_keys_inner().await; + wacore::telemetry::prekey_upload(if r.is_ok() { "ok" } else { "fail" }); + r } /// Allocate the next one-time prekey id from the persistent monotonic `NEXT_PK_ID` counter diff --git a/src/request.rs b/src/request.rs index 80f69c6f1..ef682a9f7 100644 --- a/src/request.rs +++ b/src/request.rs @@ -208,6 +208,7 @@ impl Client { where F: std::future::Future>, { + let _t = wacore::telemetry::timer(wacore::telemetry::IQ_DURATION); if !self.is_running.load(Ordering::Relaxed) { return Err(IqError::NotConnected); } @@ -240,7 +241,7 @@ impl Client { } let request_utils = self.get_request_utils(); - futures::select! { + let result = futures::select! { result = rt_timeout(&*self.runtime, timeout, rx).fuse() => { match result { Ok(Ok(response_node)) => match request_utils.parse_iq_response(response_node.get()) { @@ -258,6 +259,12 @@ impl Client { self.response_waiters.lock().await.remove(&req_id); Err(IqError::NotConnected) } - } + }; + wacore::telemetry::iq(match &result { + Ok(_) => "ok", + Err(IqError::Timeout) => "timeout", + Err(_) => "error", + }); + result } } diff --git a/src/send.rs b/src/send.rs index 4eb873a87..b9f298873 100644 --- a/src/send.rs +++ b/src/send.rs @@ -396,6 +396,12 @@ impl Client { mut message: wa::Message, options: SendOptions, ) -> Result { + let _t = wacore::telemetry::timer(wacore::telemetry::SEND_DURATION); + wacore::telemetry::send(match to.server { + wacore_binary::Server::Group => "group", + wacore_binary::Server::Broadcast => "status", + _ => "dm", + }); if let Some(exp) = options.ephemeral_expiration && exp > 0 { diff --git a/wacore/Cargo.toml b/wacore/Cargo.toml index 2f3a8266c..e5077698f 100644 --- a/wacore/Cargo.toml +++ b/wacore/Cargo.toml @@ -14,6 +14,8 @@ debug-diagnostics = [] debug-snapshots = [] # Optional observability: emit tracing spans/events. Off by default (no dep). tracing = ["dep:tracing"] +# Optional metrics via the `metrics` facade. Off by default (no dep). +metrics = ["dep:metrics"] # Render raw phone numbers in tracing fields instead of redacted tokens (debug only). tracing-pii = ["wacore-binary/tracing-pii"] # Disables XEdDSA verification of the server's Noise cert chain. Required @@ -43,7 +45,7 @@ hmac = { workspace = true } itoa = { workspace = true } log = { workspace = true } md5 = "0.8.0" -tracing = { workspace = true, optional = true } +metrics = { workspace = true, optional = true } portable-atomic = { workspace = true } prost = { workspace = true } rand = { workspace = true } @@ -54,6 +56,7 @@ sha1 = { workspace = true } sha2 = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true, optional = true } typed-builder = "0.23" wacore-appstate = { workspace = true } wacore-binary = { workspace = true, features = ["serde"] } diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 14a0adbe2..97acc9018 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -42,6 +42,7 @@ pub mod sticker_pack; pub mod store; pub mod sync_marker; +pub mod telemetry; pub mod time; pub mod types; pub mod upload; diff --git a/wacore/src/protocol/retry.rs b/wacore/src/protocol/retry.rs index 84a63589e..5866b0929 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -61,6 +61,28 @@ pub enum RetryReason { StatusRevokeDelay = 13, } +impl RetryReason { + /// Stable, low-cardinality label for metrics and logs. + pub fn as_str(&self) -> &'static str { + match self { + Self::UnknownError => "unknown", + Self::NoSession => "no_session", + Self::InvalidKey => "invalid_key", + Self::InvalidKeyId => "invalid_key_id", + Self::InvalidMessage => "invalid_message", + Self::InvalidSignature => "invalid_signature", + Self::FutureMessage => "future_message", + Self::BadMac => "bad_mac", + Self::InvalidSession => "invalid_session", + Self::InvalidMsgKey => "invalid_msg_key", + Self::BadBroadcastEphemeralSetting => "bad_broadcast_ephemeral", + Self::UnknownCompanionNoPrekey => "unknown_companion", + Self::AdvFailure => "adv_failure", + Self::StatusRevokeDelay => "status_revoke_delay", + } + } +} + /// Helper to extract bytes content from a Node. pub fn get_bytes_content(node: &Node) -> Option<&[u8]> { match &node.content { diff --git a/wacore/src/telemetry.rs b/wacore/src/telemetry.rs new file mode 100644 index 000000000..2050e01e5 --- /dev/null +++ b/wacore/src/telemetry.rs @@ -0,0 +1,214 @@ +//! Optional metrics emission via the [`metrics`](https://docs.rs/metrics) facade. +//! +//! Off by default: the `metrics` cargo feature pulls the dependency. With it off +//! every function here is an empty `#[inline]` no-op and [`Timer`] is a zero-sized +//! type that reads no clock, so there is no dependency and no runtime cost. +//! +//! The library only *emits*; the application installs a recorder (Prometheus, +//! OTLP, ...). See `examples/metrics.rs`. +//! +//! Labels are strictly low-cardinality categorical values (outcome, kind, +//! namespace, ...). Never put a JID, phone number or message id in a label: it +//! would explode the metrics backend and leak PII. Durations are unlabeled +//! histograms (the matching `_total` counter carries the categorical breakdown). + +/// Histogram metric names, used with [`timer`]. +pub const IQ_DURATION: &str = "wa_iq_duration_seconds"; +pub const CONNECT_DURATION: &str = "wa_connect_duration_seconds"; +pub const DECRYPT_DURATION: &str = "wa_decrypt_duration_seconds"; +pub const SEND_DURATION: &str = "wa_send_duration_seconds"; +pub const APPSTATE_SYNC_DURATION: &str = "wa_appstate_sync_duration_seconds"; + +#[cfg(feature = "metrics")] +mod imp { + use metrics::{ + counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram, + }; + + /// Inbound message by decrypt outcome (`decrypted`/`duplicate`/`undecryptable`/`skmsg`). + pub fn recv(outcome: &'static str) { + counter!("wa_recv_total", "outcome" => outcome).increment(1); + } + /// Outgoing send attempt by kind (`dm`/`group`/`status`). + pub fn send(kind: &'static str) { + counter!("wa_send_total", "kind" => kind).increment(1); + } + /// Retry receipt sent, by reason. + pub fn retry_receipt(reason: &'static str) { + counter!("wa_retry_receipt_total", "reason" => reason).increment(1); + } + /// IQ request completed, by result (`ok`/`timeout`/`error`). Emitted at the + /// single request chokepoint, so it covers both raw and spec-based IQs. + pub fn iq(result: &'static str) { + counter!("wa_iq_total", "result" => result).increment(1); + } + pub fn reconnect() { + counter!("wa_reconnect_total").increment(1); + } + pub fn stream_error() { + counter!("wa_stream_error_total").increment(1); + } + /// Connection attempt completed, by outcome (`ok`/`fail`). + pub fn connect(outcome: &'static str) { + counter!("wa_connect_total", "outcome" => outcome).increment(1); + } + /// App-state collection sync completed, by outcome (`ok`/`fail`). + pub fn appstate_sync(outcome: &'static str) { + counter!("wa_appstate_sync_total", "outcome" => outcome).increment(1); + } + pub fn appstate_mutations(n: u64) { + counter!("wa_appstate_mutations_total").increment(n); + } + pub fn identity_change() { + counter!("wa_identity_change_total").increment(1); + } + /// Pre-key upload completed, by outcome (`ok`/`fail`). + pub fn prekey_upload(outcome: &'static str) { + counter!("wa_prekey_upload_total", "outcome" => outcome).increment(1); + } + /// Connected state (1 while connected, 0 otherwise). + pub fn set_connected(on: bool) { + gauge!("wa_connected").set(if on { 1.0 } else { 0.0 }); + } + pub fn set_pending_retries(n: u64) { + gauge!("wa_pending_retries").set(n as f64); + } + + /// Records elapsed seconds into its histogram on drop. + pub struct Timer { + start: crate::time::Instant, + name: &'static str, + } + impl Drop for Timer { + fn drop(&mut self) { + histogram!(self.name).record(self.start.elapsed().as_secs_f64()); + } + } + /// Start a duration timer for one of the `*_DURATION` histograms; it records + /// on drop. Hold the returned guard for the scope of the operation. + pub fn timer(name: &'static str) -> Timer { + Timer { + start: crate::time::Instant::now(), + name, + } + } + + /// Register descriptions/units for all metrics. Optional; call once at startup. + pub fn describe() { + use metrics::Unit; + describe_counter!( + "wa_recv_total", + Unit::Count, + "Inbound messages by decrypt outcome" + ); + describe_counter!( + "wa_send_total", + Unit::Count, + "Outgoing send attempts by kind" + ); + describe_counter!( + "wa_retry_receipt_total", + Unit::Count, + "Retry receipts sent, by reason" + ); + describe_counter!( + "wa_iq_total", + Unit::Count, + "IQ requests by result (ok/timeout/error)" + ); + describe_counter!("wa_reconnect_total", Unit::Count, "Reconnect attempts"); + describe_counter!( + "wa_stream_error_total", + Unit::Count, + "Stream errors received" + ); + describe_counter!( + "wa_connect_total", + Unit::Count, + "Connection attempts by outcome" + ); + describe_counter!( + "wa_appstate_sync_total", + Unit::Count, + "App-state syncs by outcome" + ); + describe_counter!( + "wa_appstate_mutations_total", + Unit::Count, + "App-state mutations applied" + ); + describe_counter!( + "wa_identity_change_total", + Unit::Count, + "Peer identity changes handled" + ); + describe_counter!( + "wa_prekey_upload_total", + Unit::Count, + "Pre-key uploads by outcome" + ); + describe_histogram!(IQ_DURATION, Unit::Seconds, "IQ request round-trip time"); + describe_histogram!( + CONNECT_DURATION, + Unit::Seconds, + "Connection establishment time" + ); + describe_histogram!( + DECRYPT_DURATION, + Unit::Seconds, + "Inbound session-decrypt batch time" + ); + describe_histogram!(SEND_DURATION, Unit::Seconds, "Outgoing send time"); + describe_histogram!(APPSTATE_SYNC_DURATION, Unit::Seconds, "App-state sync time"); + describe_gauge!( + "wa_connected", + Unit::Count, + "1 while the client is connected" + ); + describe_gauge!("wa_pending_retries", Unit::Count, "Messages awaiting retry"); + } + + use super::{ + APPSTATE_SYNC_DURATION, CONNECT_DURATION, DECRYPT_DURATION, IQ_DURATION, SEND_DURATION, + }; +} + +#[cfg(not(feature = "metrics"))] +mod imp { + #[inline] + pub fn recv(_outcome: &'static str) {} + #[inline] + pub fn send(_kind: &'static str) {} + #[inline] + pub fn retry_receipt(_reason: &'static str) {} + #[inline] + pub fn iq(_result: &'static str) {} + #[inline] + pub fn reconnect() {} + #[inline] + pub fn stream_error() {} + #[inline] + pub fn connect(_outcome: &'static str) {} + #[inline] + pub fn appstate_sync(_outcome: &'static str) {} + #[inline] + pub fn appstate_mutations(_n: u64) {} + #[inline] + pub fn identity_change() {} + #[inline] + pub fn prekey_upload(_outcome: &'static str) {} + #[inline] + pub fn set_connected(_on: bool) {} + #[inline] + pub fn set_pending_retries(_n: u64) {} + /// Zero-sized no-op timer (reads no clock, records nothing). + pub struct Timer; + #[inline] + pub fn timer(_name: &'static str) -> Timer { + Timer + } + #[inline] + pub fn describe() {} +} + +pub use imp::*; From 045426457ef5b007ab69d222137de425fa16912d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 6 Jun 2026 15:06:08 -0300 Subject: [PATCH 2/2] fix(metrics): align emit sites with metric names/docs (PR #734 review) Addresses the Codex/CodeRabbit/Claude review: several counters recorded a duration but no outcome, or counted attempts instead of completed operations. - wa_connected: cleared in cleanup_connection_state() so it drops on every disconnect (run-loop drop / reconnect), not only the explicit disconnect() API. - wa_iq_total: emit "error" on the three early-return paths (NotConnected, send failure) so the counter matches the duration histogram on every exit. - wa_appstate_sync_total: emit "fail" on the terminal Err so the success rate isn't pinned at 100%; also wire wa_appstate_mutations_total at the two mutation-dispatch sites (it was declared but never emitted). - wa_retry_receipt_total: move the emit into the send-success branch so it counts receipts actually sent, not capped-out attempts (which send a PDO) or failed sends. Matches the "Retry receipt sent" docstring. - wa_send_total: add a "newsletter" arm (was bucketed as "dm") and count status posts in send_status_message. Deliberately not instrumenting send_message_impl: it is shared with internal protocol traffic (PDO, app-state key requests) that must not inflate send counts. - wa_prekey_upload_total: emit once per logical operation (the retry wrapper's final outcome + the login path) instead of once per retry attempt. - wa_identity_change_total: count past the companion/self/no-prior-identity gates so it reflects actual session resets, not every push received; describe updated. - wa_decrypt_duration_seconds: start the timer after the per-sender session lock so it measures crypto time, not lock/queue contention. - Drop the unused wa_pending_retries gauge (high-churn transient state, no call site). - Add an exhaustive RetryReason::as_str() stability test (label drift guard). Verified: clippy --all-targets -- -D warnings clean with and without --features metrics; fmt clean; cargo test --workspace --exclude e2e-tests green. --- src/client/app_state.rs | 3 +++ src/client/lifecycle.rs | 4 ++++ src/handlers/notification.rs | 4 +++- src/message/receive.rs | 4 +++- src/message/retry.rs | 2 +- src/prekeys.rs | 12 ++++++++---- src/request.rs | 3 +++ src/send.rs | 5 +++++ wacore/src/protocol/retry.rs | 27 +++++++++++++++++++++++++++ wacore/src/telemetry.rs | 10 +++------- 10 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/client/app_state.rs b/src/client/app_state.rs index 10d9db04b..041e4e0c0 100644 --- a/src/client/app_state.rs +++ b/src/client/app_state.rs @@ -113,6 +113,7 @@ impl Client { self.runtime.sleep(backoff).await; continue; } + wacore::telemetry::appstate_sync("fail"); return Err(e); } } @@ -334,6 +335,7 @@ impl Client { // (version was 0 before sync). This prevents server_sync-triggered // incremental syncs from being incorrectly marked as full syncs. let full_sync = was_snapshot.contains(&name); + wacore::telemetry::appstate_mutations(mutations.len() as u64); for m in mutations { self.dispatch_app_state_mutation(&m, full_sync).await; } @@ -520,6 +522,7 @@ impl Client { }; self.request_missing_keys_with_dedup(missing).await; + wacore::telemetry::appstate_mutations(mutations.len() as u64); for m in mutations { debug!(target: "Client/AppState", "Dispatching mutation kind={} index_len={} full_sync={}", m.index.first().map(|s| s.as_str()).unwrap_or(""), m.index.len(), full_sync); self.dispatch_app_state_mutation(&m, full_sync).await; diff --git a/src/client/lifecycle.rs b/src/client/lifecycle.rs index a40b8e4f0..3a90751a7 100644 --- a/src/client/lifecycle.rs +++ b/src/client/lifecycle.rs @@ -600,6 +600,10 @@ impl Client { // is_connected==true with a cleared socket. send_node() independently // checks the socket, but this ordering avoids a confusing state window. self.is_connected.store(false, Ordering::Release); + // Authoritative point for the gauge: every disconnect (intentional or a + // run-loop drop/reconnect) funnels through here, so disconnect()'s early + // set is just a prompt redundant signal. + wacore::telemetry::set_connected(false); // Presence doesn't survive reconnects: demote presence-driven active // receipts (1 -> 0), leaving a forced value (2) untouched. let _ = diff --git a/src/handlers/notification.rs b/src/handlers/notification.rs index 1347d9e3f..6d3a7b39d 100755 --- a/src/handlers/notification.rs +++ b/src/handlers/notification.rs @@ -309,7 +309,6 @@ fn handle_digest_key(client: &Arc) { tracing::instrument(name = "wa.notif.identity_change", level = "debug", skip_all) )] async fn handle_identity_change(client: &Arc, node: &NodeRef<'_>) { - wacore::telemetry::identity_change(); let from_jid = crate::require_from_jid!(node, "Identity change notification"); // Only primary device identity changes matter @@ -416,6 +415,9 @@ async fn handle_identity_change(client: &Arc, node: &NodeRef<'_>) { return; } + // Counted here, past the companion/self/no-prior gates, so it reflects actual + // session resets rather than every identity-change push received. + wacore::telemetry::identity_change(); info!( "Identity change for {} (had_prior_identity=true): resetting session", from_jid.user diff --git a/src/message/receive.rs b/src/message/receive.rs index 0298bd667..078e994ba 100644 --- a/src/message/receive.rs +++ b/src/message/receive.rs @@ -509,7 +509,6 @@ impl Client { if payloads.is_empty() { return SessionBatchOutcome::default(); } - let _t = wacore::telemetry::timer(wacore::telemetry::DECRYPT_DURATION); // Acquire a per-sender session lock to prevent race conditions when // multiple messages from the same sender are processed concurrently. @@ -524,6 +523,9 @@ impl Client { let mut session_guard: Option> = Some(session_mutex.lock_arc().await); + // Started after the lock so the histogram is crypto-only, not lock/queue wait. + let _t = wacore::telemetry::timer(wacore::telemetry::DECRYPT_DURATION); + let mut adapter = self.signal_adapter().await; let mut rng = rand::make_rng::(); let mut outcome = SessionBatchOutcome::default(); diff --git a/src/message/retry.rs b/src/message/retry.rs index d1cc09918..e1413210c 100644 --- a/src/message/retry.rs +++ b/src/message/retry.rs @@ -215,7 +215,6 @@ impl Client { info: &Arc, reason: RetryReason, ) -> bool { - wacore::telemetry::retry_receipt(reason.as_str()); let cache_key = self .make_retry_cache_key(&info.source.chat, &info.id, &info.source.sender) .await; @@ -246,6 +245,7 @@ impl Client { let retry_sent = match self.send_retry_receipt(info, retry_count, reason).await { Ok(()) => { + wacore::telemetry::retry_receipt(reason.as_str()); debug!( "Sent retry receipt #{} for message {} in chat {} from {} [{:?}]", retry_count, diff --git a/src/prekeys.rs b/src/prekeys.rs index b80ebca04..af068801b 100644 --- a/src/prekeys.rs +++ b/src/prekeys.rs @@ -134,7 +134,10 @@ impl Client { } log::info!("Server missing prekeys (persisted flag), uploading."); - self.upload_pre_keys_inner().await + // Operation-level outcome (the login path skips the retry wrapper). + let r = self.upload_pre_keys_inner().await; + wacore::telemetry::prekey_upload(if r.is_ok() { "ok" } else { "fail" }); + r } /// Ensure the server has enough pre-keys, uploading if below threshold. @@ -159,9 +162,7 @@ impl Client { log::debug!("Server has {server_count} pre-keys, uploading."); } - let r = self.upload_pre_keys_inner().await; - wacore::telemetry::prekey_upload(if r.is_ok() { "ok" } else { "fail" }); - r + self.upload_pre_keys_inner().await } /// Allocate the next one-time prekey id from the persistent monotonic `NEXT_PK_ID` counter @@ -342,6 +343,8 @@ impl Client { match self.upload_pre_keys(force).await { Ok(()) => { log::info!("Pre-key upload succeeded"); + // Operation-level outcome: one emit per logical upload, not per attempt. + wacore::telemetry::prekey_upload("ok"); return Ok(()); } Err(e) => { @@ -354,6 +357,7 @@ impl Client { // Bail if disconnected during retry wait if !self.is_logged_in.load(Ordering::Relaxed) { + wacore::telemetry::prekey_upload("fail"); return Err(anyhow::anyhow!( "Connection lost during pre-key upload retry" )); diff --git a/src/request.rs b/src/request.rs index ef682a9f7..135a83b79 100644 --- a/src/request.rs +++ b/src/request.rs @@ -210,6 +210,7 @@ impl Client { { let _t = wacore::telemetry::timer(wacore::telemetry::IQ_DURATION); if !self.is_running.load(Ordering::Relaxed) { + wacore::telemetry::iq("error"); return Err(IqError::NotConnected); } @@ -225,11 +226,13 @@ impl Client { if !self.is_running.load(Ordering::Acquire) { self.response_waiters.lock().await.remove(&req_id); + wacore::telemetry::iq("error"); return Err(IqError::NotConnected); } if let Err(e) = send_fn.await { self.response_waiters.lock().await.remove(&req_id); + wacore::telemetry::iq("error"); return match e { ClientError::Socket(s_err) => Err(IqError::Socket(s_err)), ClientError::EncryptSend(es_err) => Err(IqError::EncryptSend(es_err)), diff --git a/src/send.rs b/src/send.rs index b9f298873..433eabb5d 100644 --- a/src/send.rs +++ b/src/send.rs @@ -400,6 +400,7 @@ impl Client { wacore::telemetry::send(match to.server { wacore_binary::Server::Group => "group", wacore_binary::Server::Broadcast => "status", + wacore_binary::Server::Newsletter => "newsletter", _ => "dm", }); if let Some(exp) = options.ephemeral_expiration @@ -491,6 +492,10 @@ impl Client { return Err(anyhow!("Cannot send status with no recipients")); } + // Status posts don't go through send_message_with_options, so count them here. + let _t = wacore::telemetry::timer(wacore::telemetry::SEND_DURATION); + wacore::telemetry::send("status"); + let to = Jid::status_broadcast(); let request_id = self.generate_message_id().await; diff --git a/wacore/src/protocol/retry.rs b/wacore/src/protocol/retry.rs index 5866b0929..58bf1aba6 100644 --- a/wacore/src/protocol/retry.rs +++ b/wacore/src/protocol/retry.rs @@ -339,4 +339,31 @@ mod tests { signed_prekey.public_key.public_key_bytes() ); } + + // These strings are metric label values; drift silently breaks dashboards. + #[test] + fn retry_reason_as_str_is_stable() { + let cases = [ + (RetryReason::UnknownError, "unknown"), + (RetryReason::NoSession, "no_session"), + (RetryReason::InvalidKey, "invalid_key"), + (RetryReason::InvalidKeyId, "invalid_key_id"), + (RetryReason::InvalidMessage, "invalid_message"), + (RetryReason::InvalidSignature, "invalid_signature"), + (RetryReason::FutureMessage, "future_message"), + (RetryReason::BadMac, "bad_mac"), + (RetryReason::InvalidSession, "invalid_session"), + (RetryReason::InvalidMsgKey, "invalid_msg_key"), + ( + RetryReason::BadBroadcastEphemeralSetting, + "bad_broadcast_ephemeral", + ), + (RetryReason::UnknownCompanionNoPrekey, "unknown_companion"), + (RetryReason::AdvFailure, "adv_failure"), + (RetryReason::StatusRevokeDelay, "status_revoke_delay"), + ]; + for (reason, expected) in cases { + assert_eq!(reason.as_str(), expected); + } + } } diff --git a/wacore/src/telemetry.rs b/wacore/src/telemetry.rs index 2050e01e5..de0ca3ed5 100644 --- a/wacore/src/telemetry.rs +++ b/wacore/src/telemetry.rs @@ -59,6 +59,8 @@ mod imp { pub fn appstate_mutations(n: u64) { counter!("wa_appstate_mutations_total").increment(n); } + /// Peer identity change that triggered a session reset (past the + /// companion/self/no-prior-identity gates). pub fn identity_change() { counter!("wa_identity_change_total").increment(1); } @@ -70,9 +72,6 @@ mod imp { pub fn set_connected(on: bool) { gauge!("wa_connected").set(if on { 1.0 } else { 0.0 }); } - pub fn set_pending_retries(n: u64) { - gauge!("wa_pending_retries").set(n as f64); - } /// Records elapsed seconds into its histogram on drop. pub struct Timer { @@ -140,7 +139,7 @@ mod imp { describe_counter!( "wa_identity_change_total", Unit::Count, - "Peer identity changes handled" + "Peer identity changes that triggered a session reset" ); describe_counter!( "wa_prekey_upload_total", @@ -165,7 +164,6 @@ mod imp { Unit::Count, "1 while the client is connected" ); - describe_gauge!("wa_pending_retries", Unit::Count, "Messages awaiting retry"); } use super::{ @@ -199,8 +197,6 @@ mod imp { pub fn prekey_upload(_outcome: &'static str) {} #[inline] pub fn set_connected(_on: bool) {} - #[inline] - pub fn set_pending_retries(_n: u64) {} /// Zero-sized no-op timer (reads no clock, records nothing). pub struct Timer; #[inline]