Adding Feature Usage Telemetry to Wasm Modules
This page answers one task: a WebAssembly module ships many features — formats, filters, options, code paths — and nobody knows which ones users actually use. You want lightweight, privacy-respecting usage telemetry that answers “is this feature used, how often, and by how many users?” so you can prioritise work and remove dead code that costs download size.
Prerequisites
- [ ] A JavaScript wrapper through which the application calls the module.
- [ ] An analytics or metrics endpoint that accepts aggregated counters.
- [ ] A privacy policy and consent mechanism appropriate for your users.
What to measure and what not to
Usage telemetry answers product questions, so measure features, not data. Good signals are counts: how many sessions called export_pdf, how often the
“HEIC” decoder path ran, which compression levels users pick, how many calls failed with each error code. Useful context is coarse: app version, module
version, browser family, device class. Bad signals are anything derived from user content — file names, text, image contents, exact sizes that could
fingerprint a file — and anything that identifies a person.
For WebAssembly modules there are two places to count. The wrapper sees every call into the module, its arguments’ shapes and its outcome, which covers exported features. Inside the module, counters can record which internal code paths ran — which codec branch, which fallback — that the wrapper cannot see. Both are cheap if counters are plain integers incremented locally and sent rarely.
Step 1 — count calls in the wrapper
Wrap each export once with a counting function:
const counts = new Map();
function counted(name, fn) {
return (...args) => {
try {
const r = fn(...args);
bump(`${name}:ok`);
return r;
} catch (e) {
bump(`${name}:err:${e?.code ?? e?.name ?? "unknown"}`);
throw e;
}
};
}
const bump = (k) => counts.set(k, (counts.get(k) ?? 0) + 1);
export const encode = counted("encode", wasm.encode);
export const decode = counted("decode", wasm.decode);
Record options that represent features (format chosen, quality preset) as part of the key — encode:webp, encode:avif — but never raw values that could
identify content.
Step 2 — count internal paths inside the module
For paths the wrapper cannot see, keep a small array of counters in the module and export a function that copies them out:
use core::sync::atomic::{AtomicU32, Ordering::Relaxed};
pub enum Path { DecodeProgressive = 0, DecodeBaseline, Cmyk, IccProfile, FallbackScalar, _Count }
static COUNTERS: [AtomicU32; Path::_Count as usize] = [const { AtomicU32::new(0) }; Path::_Count as usize];
#[inline] pub fn hit(p: Path) { COUNTERS[p as usize].fetch_add(1, Relaxed); }
#[wasm_bindgen]
pub fn take_path_counters() -> Vec<u32> {
COUNTERS.iter().map(|c| c.swap(0, Relaxed)).collect()
}
An increment costs a nanosecond or two; place hit() at branch points, not in inner loops. Atomics are only needed for threaded builds, but Relaxed atomics
cost the same as plain increments on a single thread.
Step 3 — aggregate and send rarely
Send one batch every few minutes (and on page hide with navigator.sendBeacon), containing counters merged from the wrapper and the module, plus coarse context:
function flush() {
const paths = Array.from(wasm.take_path_counters());
const payload = {
v: APP_VERSION, mod: MODULE_HASH, browser: browserFamily(), device: deviceClass(),
calls: Object.fromEntries(counts), paths: Object.fromEntries(PATH_NAMES.map((n, i) => [n, paths[i]])),
};
if (Object.keys(payload.calls).length || paths.some(Boolean)) navigator.sendBeacon("/telemetry", JSON.stringify(payload));
counts.clear();
}
setInterval(flush, 10 * 60_000);
addEventListener("visibilitychange", () => document.visibilityState === "hidden" && flush());
No per-call events leave the device; the server sees only counts per batch. Sampling — sending from a random fraction of sessions — reduces volume further if needed and adds plausible deniability for rare features.
Step 4 — respect consent and privacy
Gate telemetry on consent where required, document what is collected, and make the payload easy to inspect (a debug setting that logs it to the console). Strip anything identifying: no user IDs unless your policy covers them, no IP-based enrichment you do not need, coarse device classes instead of detailed user agents. For sensitive products, consider privacy-preserving aggregation techniques, or limit telemetry to opt-in beta users.
Step 5 — use the data
Usage data is only valuable if it changes decisions. Review it when planning: features with near-zero usage are candidates for removal (and their code for deletion, which shrinks the module); heavily used paths deserve optimisation; error counts per export show where reliability work pays off. Compare by module version to confirm that a change had the intended effect — for example, that a new fast path now handles most calls.
Removing features safely
Before deleting a rarely used feature, check its usage over a long enough window (rare features may be critical for a few users, such as annual reports), and look at who uses it, coarsely (one enterprise customer may account for all calls). Deprecate visibly first, then remove, then confirm the module shrank by re-running size analysis. Usage telemetry turns “nobody uses this, probably” into evidence.
Counting users, not just calls
Call counts overstate features used heavily by a few people. “How many users used this?” needs a per-session or per-user notion of presence, which can be collected without identifying anyone: record in each batch which features were used at least once in the session (a set of flags), and let the server count sessions per feature. Combining both views — total calls and share of sessions — distinguishes a feature that a small group uses constantly from one that many people use occasionally, which lead to very different decisions. For per-user counts across sessions, an anonymous random identifier stored locally (and reset on request) is a common compromise; avoid it entirely where your privacy commitments do not cover it, and session-level presence will still answer most questions.
Telemetry for server-side Wasm
The same pattern works when modules run on servers or at the edge: wrap calls in the host, keep counters in the module, and export them through the host’s metrics system rather than a beacon. There the data is operational — which plugin exports run, how often each fallback path triggers in production — and fits naturally into existing dashboards and alerts, with module version as a label so releases can be compared directly.
Testing telemetry
Telemetry code breaks silently: a renamed export stops being counted, a refactor moves a hit() call out of its branch. Add a test that exercises each
counted feature once, flushes, and asserts the payload contains the expected keys with non-zero counts — and nothing else. The “nothing else” part guards
against content accidentally becoming part of a key.
Expected output
Ten-minute batches report calls per export and outcome, internal path counters and coarse context; the dashboard shows that 0.3% of sessions use the TIFF decoder and 72% of decodes take the progressive JPEG path; overhead is unmeasurable in benchmarks; no file names or content are collected; and removing the unused TIFF path saves 140 KB of compressed module size.
Gotchas
- Sending an event per call. Volume and privacy problems. Aggregate locally.
- Content in telemetry keys. File names or values leak data. Use feature names only.
- Counters in inner loops. Measurable overhead. Count at branch points.
- Losing counts on page close. Flush on
visibilitychangewithsendBeacon. - Deciding on short windows. Rare but critical features look unused. Look at long periods.
- Untested telemetry. Renamed exports stop being counted silently. Assert payload keys in a test.
Performance note
Counting at 14 branch points plus wrapper counting added under 0.1% to the module’s benchmark suite; each telemetry batch was about 1 KB.
Frequently Asked Questions
Can I measure feature usage without any network calls? Not across users; local counters only help that user’s diagnostics.
Should the module know about telemetry? Only through plain counters; sending stays in JavaScript.
How do I count in threaded builds?
Use atomic counters, as shown; Relaxed ordering is enough.
Is this the same as error reporting? No — error reporting sends details of failures; usage telemetry sends counts.
How do I know how many users use a feature, not just how often it is called? Send per-session presence flags alongside counts and let the server count sessions per feature.
Does this work for Wasm on servers? Yes — keep counters in the module and export them through the host’s metrics system with the module version as a label.
How do I stop user content from leaking into telemetry keys? Build keys only from a fixed list of feature names and test that the payload contains nothing else.
How long should usage be observed before removing a feature? Long enough to cover periodic use such as month-end or annual tasks — often a full quarter or more — and check who the remaining users are.
Can users see what is sent? Offer a debug setting that logs each payload to the console; it builds trust and helps catch mistakes.
Related
- Recording Wasm call traces for bug reports — detailed opt-in traces.
- Alerting on Wasm load failures — release health.
- Removing unused exports to shrink Wasm — acting on the data.
- Adding context to errors from Wasm — error codes to count.
← Back to Observability & Error Reporting