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.

Usage telemetry from a Wasm module The wrapper counts calls per export and outcome. Inside the module, counters in linear memory record internal paths such as codec branches. Periodically the wrapper reads the module's counters, merges them with its own, and sends one aggregated, anonymous batch with coarse context, after which counters reset. wrapper counts calls per export + outcome module counts paths counters in memory merge periodically e.g. every 10 min send aggregate batch anonymous, coarse context reset counters no per-call events

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.

Per-call events versus aggregated counters Sending an event per call produces large volumes, risks capturing user data in event properties and costs network on every action. Aggregating counters locally and sending one small batch periodically answers the same usage questions with negligible overhead and much less data. event per call high volume risk of content in properties network per action avoid for usage local counters, batched one small payload per interval counts only, coarse context negligible overhead recommended

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 visibilitychange with sendBeacon.
  • 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.

Telemetry data per user per day Kilobytes sent per active user per day with one event per call, and with locally aggregated counters sent every ten minutes. KB per user per day event per call 850 KB aggregated counters 18 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.

← Back to Observability & Error Reporting