-
-
Notifications
You must be signed in to change notification settings - Fork 127
feat(metrics): optional metrics layer (off by default, Prometheus/OTLP-ready) #734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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::<crate::handshake::HandshakeError>() | ||
| .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 <success> stanza even if | ||
|
|
@@ -475,6 +479,7 @@ impl Client { | |
| )] | ||
| pub async fn disconnect(self: &Arc<Self>) { | ||
| info!("Disconnecting client intentionally."); | ||
| wacore::telemetry::set_connected(false); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the socket drops through the normal run-loop cleanup path (for example a read loop error, stream error, or auto-reconnect) Useful? React with 👍 / 👎. |
||
| 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<Self>) { | ||
| 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); | ||
|
|
@@ -594,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 _ = | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -208,7 +208,9 @@ impl Client { | |
| where | ||
| F: std::future::Future<Output = Result<(), crate::client::ClientError>>, | ||
| { | ||
| let _t = wacore::telemetry::timer(wacore::telemetry::IQ_DURATION); | ||
| if !self.is_running.load(Ordering::Relaxed) { | ||
| wacore::telemetry::iq("error"); | ||
| return Err(IqError::NotConnected); | ||
| } | ||
|
|
||
|
|
@@ -224,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)), | ||
|
|
@@ -240,7 +244,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 +262,12 @@ impl Client { | |
| self.response_waiters.lock().await.remove(&req_id); | ||
| Err(IqError::NotConnected) | ||
| } | ||
| } | ||
| }; | ||
| wacore::telemetry::iq(match &result { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With the counter only emitted after the Useful? React with 👍 / 👎. |
||
| Ok(_) => "ok", | ||
| Err(IqError::Timeout) => "timeout", | ||
| Err(_) => "error", | ||
| }); | ||
| result | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This records only successful app-state syncs; when
process_app_state_sync_taskultimately returns a non-retryable error (or a DB-lock error after the retry limit), the function returnsErr(e)later without ever callingappstate_sync("fail"). The advertisedwa_appstate_sync_total{outcome}metric therefore has no failure samples, hiding failed syncs from metrics users.Useful? React with 👍 / 👎.