Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ iai-callgrind = "0.16"
itoa = "1"
log = "0.4"
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"
Expand All @@ -96,6 +97,13 @@ yoke = { version = "0.8", features = ["derive"] }
[features]
debug-diagnostics = ["wacore/debug-diagnostics"]
debug-snapshots = ["wacore/debug-snapshots"]
# Optional observability. Off by default: no `tracing` dep, zero overhead.
# Emits tracing spans/events only; the application installs the subscriber
# (and any OpenTelemetry bridge). See examples/observability.rs.
tracing = ["dep:tracing", "wacore/tracing"]
# Render raw phone numbers in tracing fields instead of the redacted `pn#<hash>`.
# Local debugging only; never enable in production.
tracing-pii = ["wacore/tracing-pii", "wacore-binary/tracing-pii"]
danger-skip-tls-verify = ["whatsapp-rust-tokio-transport?/danger-skip-tls-verify"]
danger-skip-cert-chain-verify = ["wacore/danger-skip-cert-chain-verify"]
default = [
Expand Down Expand Up @@ -131,6 +139,7 @@ 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 }
Expand Down Expand Up @@ -164,6 +173,7 @@ hkdf = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
wacore-noise = { path = "./wacore/noise", features = [
"test-util",
"danger-skip-cert-chain-verify",
Expand All @@ -176,6 +186,10 @@ workspace = true
name = "benchmark"
required-features = ["danger-skip-tls-verify"]

[[example]]
name = "observability"
required-features = ["tracing"]

[profile.release]
opt-level = 3
debug = false
Expand Down
62 changes: 62 additions & 0 deletions examples/observability.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Wiring observability for `whatsapp-rust`.
//!
//! Run with:
//! cargo run --example observability --features tracing
//!
//! The library only *emits* `tracing` spans/events (and keeps its existing `log`
//! calls). It never installs a subscriber and never depends on OpenTelemetry —
//! that is the application's job, shown here.
//!
//! Two things happen below:
//!
//! 1. A `tracing-subscriber` is installed. Its default `tracing-log` feature
//! bridges the library's existing `log::{info,warn,error}!` calls into
//! tracing, so they become events attached to the active `wa.*` span.
//! 2. Span/level/target filtering is driven by `RUST_LOG` (EnvFilter), e.g.
//! `RUST_LOG="info,whatsapp_rust=debug,wacore=debug"`. The library groups
//! spans under `wa.*` names and reuses its `target: "Client/AppState"`-style
//! targets, so you can filter per area.
//!
//! IMPORTANT: do NOT enable the `log` feature on the `tracing` crate together
//! with a log->tracing bridge — that recurses. This crate already pins
//! `tracing` with `default-features = false` so the hazard cannot happen.
//!
//! PII note: the bridged `log` lines surface alongside the redacted `wa.*` spans.
//! The library renders JIDs and Signal addresses in its own log messages through
//! `Jid::observe()` / `observe_protocol_address()` (phone numbers become
//! `pn#<keyed-token>`), so the `whatsapp_rust`/`wacore` log lines carry the same
//! redaction as the span fields. Your own application code is a separate leak
//! path: any raw JID/phone you log reaches the exporter under your own targets,
//! and dropping the library targets does nothing for it — scrub your app's logs
//! with `Jid::observe()` too. The `tracing-pii` cargo feature (off) renders raw
//! numbers for local debugging only.

fn main() {
use tracing_subscriber::prelude::*;

let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,whatsapp_rust=debug"));

tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer())
// ── OpenTelemetry (OTLP) ────────────────────────────────────────────
// Add the application deps `opentelemetry`, `opentelemetry-otlp` and
// `tracing-opentelemetry`, then append a layer here:
//
// let tracer = opentelemetry_otlp::new_pipeline()
// .tracing()
// .with_exporter(opentelemetry_otlp::new_exporter().tonic())
// .install_batch(opentelemetry_sdk::runtime::Tokio)?;
// .with(tracing_opentelemetry::layer().with_tracer(tracer))
//
// Every `wa.*` span is then exported as an OTLP span with its fields
// (chat/peer/msg_id are already privacy-redacted via `Jid::observe()`).
.init();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid bridging raw PII logs into traces

When an app copies this OTLP example with tracing enabled, .init() installs the default tracing-log bridge, so the existing log! messages are exported alongside the new redacted span fields. Those legacy logs still format JIDs with normal Display rather than Jid::observe() (for example src/client/sessions.rs:247/:256 logs jid directly), so a phone-number JID can be emitted as raw PII even though the surrounding wa.* fields are redacted. Disable the log bridge for production tracing or scrub the existing log messages before recommending this wiring.

Useful? React with 👍 / 👎.


tracing::info!("observability initialized — RUST_LOG drives filtering");

// From here you would build and run a `whatsapp_rust::Client` as usual; all
// connect / recv / decrypt / send / iq / appstate / pair / media spans and
// the bridged log events will flow into the subscriber above.
}
12 changes: 12 additions & 0 deletions src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ impl MessageContext {
Some(Self::from_arc(Arc::clone(msg), info, client))
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.send_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
pub async fn send_message(
&self,
message: wa::Message,
Expand Down Expand Up @@ -94,6 +95,7 @@ impl MessageContext {
}
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.edit_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
pub async fn edit_message(
&self,
original_message_id: impl Into<String>,
Expand All @@ -109,6 +111,7 @@ impl MessageContext {
}

/// Delete a message for everyone in the chat.
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.revoke_message", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
pub async fn revoke_message(
&self,
message_id: String,
Expand All @@ -122,6 +125,7 @@ impl MessageContext {
/// React to the incoming message. An empty `emoji` removes a previous
/// reaction. The target key (including the group/status participant) is
/// taken from [`MessageContext::message_key`].
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.bot.react", level = "debug", skip_all, fields(chat = %self.info.source.chat.observe()), err(Debug)))]
pub async fn react(&self, emoji: &str) -> Result<crate::send::SendResult, anyhow::Error> {
self.client
.send_reaction(&self.info.source.chat, self.message_key(), emoji)
Expand Down Expand Up @@ -220,6 +224,10 @@ impl Bot {
self.client.clone()
}

#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.bot.run", level = "debug", skip_all, err(Debug))
)]
pub async fn run(&mut self) -> Result<BotHandle> {
if let Some(receiver) = self.sync_task_receiver.take() {
let worker_client = Arc::downgrade(&self.client);
Expand Down Expand Up @@ -701,6 +709,10 @@ impl<B, T, H, R> BotBuilder<B, T, H, R> {
// ── build() — only available when all 4 required fields are Provided ─────

impl BotBuilder<Provided, Provided, Provided, Provided> {
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.bot.build", level = "debug", skip_all, err(Debug))
)]
pub async fn build(self) -> std::result::Result<Bot, BotBuilderError> {
// Destructure to extract required fields — typestate guarantees all are Some.
let (Some(runtime), Some(backend), Some(transport_factory), Some(http_client)) = (
Expand Down
10 changes: 10 additions & 0 deletions src/client/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ impl Client {
}

/// Public entry point for processing [`MajorSyncTask`] from the sync channel.
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.appstate.sync_task", level = "debug", skip_all)
)]
pub async fn process_sync_task(self: &Arc<Self>, task: crate::sync_task::MajorSyncTask) {
match task {
crate::sync_task::MajorSyncTask::HistorySync {
Expand All @@ -36,6 +40,7 @@ impl Client {
}
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.fetch", level = "debug", skip_all, fields(name = ?name), err(Debug)))]
pub(crate) async fn fetch_app_state_with_retry(&self, name: WAPatchName) -> anyhow::Result<()> {
// In-flight dedup: skip if this collection is already being synced.
// Matches WA Web's WAWebSyncdCollectionsStateMachine which tracks in-flight syncs
Expand Down Expand Up @@ -113,6 +118,7 @@ impl Client {
/// Sync multiple collections in a single IQ request, re-fetching those with `has_more_patches`.
/// Matches WA Web's `serverSync()` outer loop (`3JJWKHeu5-P.js:54278-54305`).
/// Max 5 iterations (WA Web's `C=5` constant).
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.sync_batched", level = "debug", skip_all, fields(count = collections.len()), err(Debug)))]
pub(crate) async fn sync_collections_batched(
&self,
collections: Vec<WAPatchName>,
Expand Down Expand Up @@ -359,6 +365,7 @@ impl Client {
Ok(())
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.sync", level = "debug", skip_all, fields(name = ?name, full_sync = full_sync), err(Debug)))]
pub(crate) async fn process_app_state_sync_task(
&self,
name: WAPatchName,
Expand Down Expand Up @@ -560,6 +567,7 @@ impl Client {
}
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.request_keys", level = "debug", skip_all, fields(count = raw_key_ids.len()), err(Debug)))]
async fn request_app_state_keys(&self, raw_key_ids: &[Vec<u8>]) -> Result<(), anyhow::Error> {
if raw_key_ids.is_empty() {
return Ok(());
Expand Down Expand Up @@ -604,6 +612,7 @@ impl Client {
/// Send an app state patch to the server for a given collection.
///
/// Builds the IQ stanza and sends it. Returns the updated hash state.
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.send_patch", level = "debug", skip_all, fields(name = %collection_name, count = mutations.len()), err(Debug)))]
pub(crate) async fn send_app_state_patch(
&self,
collection_name: &str,
Expand Down Expand Up @@ -733,6 +742,7 @@ impl Client {
}
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.appstate.clean_dirty", level = "debug", skip_all, fields(bit = ?bit), err(Debug)))]
pub async fn clean_dirty_bits(
&self,
bit: wacore::iq::dirty::DirtyBit,
Expand Down
Loading
Loading