Skip to main content

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 with plugins 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 only Event::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 private channel (50 events). These need a blind-signed token and a persisted, rotating anonymous id, neither of which this client has.
  • Two inbound-only counters. MessageHighRetryCount and MdRetryFromUnknownDevice describe 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. WebcPageResume and WebcStreamModeChange describe 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) derives appVersion, platform, and osVersion from the same ClientProfile the pairing payload is built from, so telemetry and pairing agree at the point WamIdentity is built. ocVersion (0) and appIsBetaRelease (false) are facts, not configuration — this isn’t the official client, and the pairing payload announces the release channel. Build a WamIdentity with from_profile to match a non-web ClientProfile. WamIdentity is captured once, when you build WamConfig, and the plugin never re-reads it. If your application calls Client::set_client_profile after installing WamPlugin, 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 its Client. Decide the ClientProfile before you build either, and build a new Client (with a matching WamIdentity) if it needs to change. service_improvement_opt_out is 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 install WamPlugin at all; setting this field to true is not a substitute for that.
  • store — see Persistence.
  • max_queued_events (default 4096) — 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 the WamStore 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 a wait, 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 with CARGO_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 facadewa_* 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.