Chrome Extension Usage Tracking: Which Features People Actually Use
6 min readModerok team
Chrome extension usage tracking in MV3: which events to fire, the click handlers that never fire, and how to turn events into feature adoption.
Chrome extension usage tracking means firing one named event per feature from your own code, because nothing the Chrome Web Store publishes reports feature use. The store's own metrics documentation says the Users stat "only captures installations; it doesn't monitor whether users are active or not", and the breakdowns it does offer cover country, language and operating system, plus item version for weekly user retention. If you want to know whether anyone uses the export button you shipped last month, you emit an event when the export runs and count the distinct users who fired it. This post covers which events to fire, the MV3 handlers that silently never fire, and how to read the result.
TL;DR: Fire one event per user intention, named for what happened rather than what was clicked, with flat properties. Instrument the action itself, not the trigger:
action.onClickeddoes not fire when your action has a popup, and the reserved_execute_actionkeyboard shortcut does not dispatchcommands.onCommand. Calltrack()from the background service worker and relay from UI contexts withchrome.runtime.sendMessage. Then read adoption as distinct users who fired the event over distinct active users, not as a raw event count.
What the store reports, and what it cannot
Three places a usage number can come from, and only one of them reaches features:
| Source | What it gives you | Feature level? |
|---|---|---|
| Web Store developer dashboard | Installs and Users, with breakdowns by country, language and operating system, and item version on weekly user retention | No |
chrome.runtime.setUninstallURL | A request when someone removes the extension | No |
| Your own events | Whatever names you choose to fire | Yes |
The gap between installs and people is its own problem, covered in how many users does my Chrome extension have. Everything below assumes you have decided to instrument your own code.
Name one event per intention, not per click target
The useful unit is a thing the user meant to do. export_clicked is a good event. button_3_mousedown is not, because in six months you will not remember which button that was, and the name will survive longer than the markup.
Two rules keep a schema readable a year later. Pick one naming style and keep it: snake_case throughout is the simplest. And keep names stable, because renaming an event splits one series into two. When you need a new dimension, add a property instead of a new name:
import { Moderok } from "@moderok/sdk";
Moderok.track("export_clicked", { format: "csv", item_count: 42, scheduled: false });
Properties have to be flat. Moderok's events and properties docs state that each value must be a string, number or boolean, and that nested objects, arrays, null and invalid numbers are dropped, with a console warning when debug: true is set on init(). If you have a list, send a count. If you have an object, send the two fields you will actually filter on.
You also do not need properties for the things the SDK already attaches to every event. Extension version, browser, operating system, locale and the context the event came from (background, popup, options, side panel, content script) travel as event context. They appear in the raw event data rather than as chart breakdowns, so do not spend property keys duplicating them.
Avoid the reserved names the SDK sends on its own. Per the automatic events docs, those are __first_open, __install, __update, __daily_ping and __error.
The handlers that never fire
This is where most first attempts at usage tracking quietly report zero.
Toolbar clicks, when you have a popup. The natural place to count "someone opened my extension" is chrome.action.onClicked. The action API reference is explicit that it will not run: "This event will not fire if the action has a popup." A default_popup in your manifest, or a later action.setPopup() call, is enough. If your extension has a popup, the click is only observable from inside the popup.
Keyboard shortcuts bound to the action. If you declared _execute_action in commands, a chrome.commands.onCommand listener will not see it. The commands reference says the reserved commands "don't dispatch command.onCommand events like standard commands", and its sample code carries the same warning in a comment. A user who only ever opens your extension with the keyboard is invisible to an onCommand counter.
Both cases point at the same rule: instrument the thing that happened, not the input that triggered it. Fire the event in the popup script that runs on open, or in the function that performs the work, and both the mouse path and the keyboard path land in the same series.
Chrome extension usage tracking in MV3: where to call track()
Moderok's patterns docs recommend one place that calls track(), in the background service worker, with UI and content scripts relaying through chrome.runtime.sendMessage. Chrome's messaging guide introduces that call as the way to send a single message to another part of your extension, and optionally get a response.
There is a concrete reason to prefer that shape over calling track() directly from a popup. The queue sends on a timer and when a batch fills: per the configuration reference, flushInterval defaults to 30000 ms and batchSize to 20 events. A popup that fires a single event and closes a moment later may hit neither threshold before its JavaScript context goes away. The background worker is the context most likely to still be around.
// popup.js
document.querySelector("#export").addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "usage", name: "export_clicked", props: { format: "csv" } });
runExport();
});
// background.js (service worker, registered at top level)
import { Moderok } from "@moderok/sdk";
Moderok.init({ appKey: "mk_your_app_key_here" });
chrome.runtime.onMessage.addListener((message) => {
if (message?.type === "usage") {
Moderok.track(message.name, message.props);
}
// No return value: this listener never responds, so the port can close.
});
The worker being asleep is not a problem here. Chrome's lifecycle documentation lists shutdown "after 30 seconds of inactivity" and says an incoming event revives a dormant worker, and your message is an incoming event. What the pattern does not buy you is a delivery guarantee: retryable failures are persisted and retried with backoff, but a queue with caps is still a queue with caps.
Read adoption, not event counts
A raw count of export_clicked tells you about your heaviest user. The number that answers "does anyone use this" has a denominator:
Feature adoption = distinct users who fired the event in a window / distinct active users in the same window.
Both halves need the same window and the same identifier, which is why it pays to settle what "active" means before you start comparing. Measuring daily, weekly and monthly actives works through that choice and the once-per-UTC-day heartbeat behind it.
Moderok reports custom events next to the install, update, uninstall and active-user numbers you would divide by, and the product page lists what the dashboard covers.
Two failure modes to watch for. A feature that reports near zero may simply be fired from a context you never instrumented, so check the event context before concluding anything about users. And a feature whose adoption looks flat across a release may be fine: the chart dimensions are aggregate, so confirming a version effect means reading the version on the raw events rather than expecting a breakdown.
Keep properties small and boring
Per the same configuration reference, a single event's JSON is capped at 8 KiB and oversized events are skipped, with a warning when debug is on. That cap is generous for counters and tight for content, which is the right incentive. Whatever you pass to track() is what gets sent, so keep page URLs, selected text and form values out of properties and send a shape or a count instead: field_count: 7 rather than the fields.
If you would rather not build the pipeline, the Moderok SDK is 5.7 kB gzipped, has zero runtime dependencies and needs only the storage permission: the events and properties guide has the naming and property rules in full.