Overview
WAM (WhatsApp Metrics) is the telemetry the official WhatsApp client uploads about itself: a binary buffer of numbered events, preceded by buffer-level globals, sent under<iq xmlns="w:stats">. whatsapp-rust has never sent any of it. Two workspace crates now make a small, honest subset of it possible:
Neither crate is in the default build, and neither is published to crates.io yet — add them as
git dependencies the same way you would passkey or voip ahead of a release that carries them.
This is the client reporting on itself, not a message-content feature. Read Privacy and PII before enabling it in an application that handles user data you don’t control.
Why a plugin, not a built-in subsystem
Native plugins exist precisely for this shape of extension: something that wants to watch work the core already does, rather than claim a stanza tag, notification type, or IQ namespace of its own. WAM claims none of those, so it attaches through the plugin host’s event-observation capability instead of becoming a feature-gated part of the core. The practical consequence is that a default build — and a build withplugins enabled but this plugin not installed — carries none of this. Installing WamPlugin is what turns on:
- Three additional core events flowing to a subscriber for the life of the client (
DecryptedPayload,EncDecryptFailed,RawNode— the last is filtered to<receipt>stanzas). - The plugin’s own buffering, flush, and upload task.
Enabling it
Cargo.toml
WamPlugin requests the CoreEvents, Tasks, and Iq capabilities — nothing else. Construct a fresh WamPlugin per Client; like every plugin, it’s install-once for that client’s lifetime.
The honesty rule
An event is emitted only when every field it writes is honestly derivable from activity this client actually observed. No invented value, no sentinel, no placeholder standing in for something the client can’t actually see. A field this client doesn’t know is left absent — the wire format distinguishes absent from zero, so the server can tell the difference. That rule, not implementation effort, is why the catalog carries 436 events and the plugin emits seven of them.What it emits
Each event is derived from the core event stream, at the unit it actually counts:
All seven upload on the
regular channel. A conformance test (plugins/wam/src/parity.rs) encodes every one of them at its maximum through the real codec and checks every field id that reaches the wire against what WA Web itself writes at that call site, so a field this plugin writes that the official client doesn’t is a build failure, not a runtime surprise.
What it does not emit, and why
- Anything derived from sending a message (18 events).
MessageSend,MediaUpload2,StatusPost,E2eMessageSend, and the rest of that family describe an outgoing message’s type, media, per-device encryption count, and stage timings. The core currently publishes onlyEvent::SentFrame— the marshaled bytes of a stanza after the write — which carries none of that semantic information. Re-deriving a 95-field event from a frame would be reconstruction, not observation. - The
privatechannel (50 events). These need a blind-signed token and a persisted, rotating anonymous id, neither of which this client has. - Two inbound-only counters.
MessageHighRetryCountandMdRetryFromUnknownDevicedescribe values this client already tracks internally, but nothing puts them on the event bus yet — a plugin can only see events, not internal counters. - Two browser-only lifecycle facts.
WebcPageResumeandWebcStreamModeChangedescribe WA Web’s own page/stream model, which this client has no equivalent of. - Beaconing. The official client gives a small fraction of clients a per-event sequence number, rolled once per UTC day. Getting that right needs a durable, per-event counter that survives every restart — without one, a process that restarts several times a day would roll several times and skew any cohort built on the “once per day” assumption. Not implementing it is the honest answer to not having that counter, not an oversight.
Configuration
-
identity— what this client says about itself in a buffer’s globals.WamIdentity::web()(the default) derivesappVersion,platform, andosVersionfrom the sameClientProfilethe pairing payload is built from, so telemetry and pairing agree at the pointWamIdentityis built.ocVersion(0) andappIsBetaRelease(false) are facts, not configuration — this isn’t the official client, and the pairing payload announces the release channel. Build aWamIdentitywithfrom_profileto match a non-webClientProfile.WamIdentityis captured once, when you buildWamConfig, and the plugin never re-reads it. If your application callsClient::set_client_profileafter installingWamPlugin, the pairing payload and the WAM identity can drift apart, and there’s no way to update the running plugin’s copy — a plugin is install-once for the lifetime of itsClient. Decide theClientProfilebefore you build either, and build a newClient(with a matchingWamIdentity) if it needs to change.service_improvement_opt_outis left absent by default because the library has no way to read the account’s actual preference. Setting it only records a value in the buffer’s globals — it does not suppress anything this plugin sends. If your application knows the user opted out of telemetry, don’t installWamPluginat all; setting this field totrueis not a substitute for that. -
store— see Persistence. -
max_queued_events(default4096) — a bound on memory, not throughput. Events are small and the queue drains every few seconds under normal operation; this only protects against unbounded growth if the flush task stalls.
Persistence
Two things need to survive a restart: the per-channel sequence number, and buffers the server hasn’t accepted yet. Both go through theWamStore trait, which the plugin owns — there is no storage capability in the plugin host, deliberately: a capability is a promise about every plugin, and one plugin needing a key-value store isn’t that.
InMemoryWamStore is the default. With it, a restart renumbers the sequence from 1 (what a fresh browser profile does anyway) and loses any buffer that hadn’t been delivered yet — telemetry that was already best-effort. An application that wants durability implements WamStore against its own database; WamApi::stats().store_is_durable reports which one is in effect. A store that fails outright — it cannot be read, cannot issue a sequence number, or refuses to hold a buffer — costs the same buffer or event a durable store’s ordinary unavailability would, and the runtime reports the loss itself once a later buffer exists to carry it (WamClientErrors, above), rather than only moving a local counter nothing ever uploads.
Diagnostics
WamApi::stats() returns a WamStats snapshot:
A snapshot is approximate under concurrency and, matching the rest of the client’s observability surface, carries no JID, phone number, or message body.
Privacy and PII
Every derivation reads the stanza envelope, never the decoded message content — the same boundary the official client’s own receive metrics are built from. Nothing here carries a JID, phone number, or message body as a field value. If you enable this plugin, you’re opting your deployment into sending the same categories of client-health telemetry the official WhatsApp client sends; review the event table above and your applicable privacy obligations before shipping it.Upload behavior
Buffers accumulate for a few seconds before being written, and are sent on a timer or once a size threshold is crossed. A failed upload is retried with a backoff (starting at one second, doubling, capped at two minutes) — except when the server’s response classifies the buffer itself as refused (a 4xx that isn’t await, 408, or 429), in which case it’s dropped rather than retried forever.
The best-effort flush at shutdown happens inside the flush loop itself, not in a separate shutdown callback. WamPlugin requests the install-scoped Tasks capability for this loop, so it runs once for the life of the client and is cancelled only at terminal teardown — a reconnect never touches it. The loop is spawned with spawn_cooperative rather than the host’s default abort-on-cancel. That makes shutdown turn the loop’s sleep into an error instead of dropping the loop where it was suspended. The loop then makes one final pass itself: it observes a WebWamForceFlush event, then delivers or retains whatever buffer was already being filled, before returning. The same writer that was accumulating events is the one that flushes them, so nothing hands a parked buffer to a second writer. The plugin host still only waits up to its configured task-drain deadline (5 seconds by default) for that final pass to finish. Teardown can block on it for up to the deadline, never longer, and whatever doesn’t finish in time is left for the store to retain instead.
Cost
Measured on a stripped release build withCARGO_PROFILE_RELEASE_STRIP=false, the same build binary-size CI gates on:
Installing the plugin also has a per-message cost that recurs rather than being one-time: it holds open three lease-gated core events (
DecryptedPayload, EncDecryptFailed, RawNode) for the life of the client, so every decrypted <enc> and every decoded inbound stanza now also crosses the plugin’s event handler. No plaintext is copied in the process — the payload types involved are Bytes and Arc-shared — but the cost of constructing and dispatching the event itself is real and, as of this writing, unbenchmarked.
See also
- Native plugins — the capability model and lifecycle this plugin is built on.
- Metrics with the metrics facade —
wa_*operational metrics for dashboards and alerts; a different thing from WAM, which is WhatsApp’s own client-health telemetry format. - Observability — the PII and snapshot conventions this page’s diagnostics follow.