Usage Analytics & Feature Flags

Measure and steer an MV3 extension without remote code or creepy tracking: privacy-first event collection from the service worker, GA4 Measurement Protocol, uninstall surveys, remote flags and A/B tests.

Once an extension has real users, two questions arrive quickly: is anyone using the feature we just shipped? and can we turn it off for everyone if it breaks? Web apps answer both with an analytics snippet and a feature-flag SDK loaded from a CDN. Neither works in MV3. Remote scripts are forbidden, so the snippet cannot load; most SDKs assume window, localStorage and XMLHttpRequest, none of which exist in the service worker; and store reviewers scrutinise any data an extension sends, because extensions see far more of a user’s browsing than a website does. Measurement and remote control are still possible — they just have to be built for the extension’s constraints and its users’ trust. This topic sits inside Testing, Debugging & Performance Optimization and its starting point is privacy-preserving usage metrics, which defines what is worth collecting at all.

The approach throughout: collect the least data that answers a specific product question, aggregate on the device where possible, send from the service worker over plain fetch, never send anything derived from the pages the user visits, and treat remote flags as data that selects among packaged behaviours rather than as code.

Measurement and control without remote codeUI surfaces and content scripts emit coarse events to the service worker, which aggregates them locally and sends batches with fetch; the worker also fetches a flags document that selects among behaviours already in the package.Popup / paneltrack('opened')Content scripttrack('saved')Service workeraggregate + consent checkbatched fetch · flags fetchCollectorGA4 MP or your endpointflags.jsonvalidated, cachedPackaged code pathsselected by flags
Events flow out as aggregates; flags flow in as data. No code crosses the network.

Prerequisites checklist

  • A written list of the product questions analytics must answer, and the minimum event set for each.
  • A privacy policy that names every category of data collected, and a store listing whose data disclosure matches it.
  • A consent decision: opt-in, opt-out where lawful, or no collection — and the UI to change it.
  • Host permission (or CORS headers) for the collection and flags endpoints.
  • A random installation identifier generated on install, never derived from hardware or accounts.
  • A kill switch for every risky feature, built before the feature ships.

Manifest registration

 1{
 2  "manifest_version": 3,
 3  "background": { "service_worker": "sw.js", "type": "module" },
 4  "permissions": ["storage", "alarms"],
 5  "host_permissions": [
 6    "https://www.google-analytics.com/*",   // if using GA4 Measurement Protocol
 7    "https://flags.readable.example/*",     // flags document
 8    "https://t.readable.example/*"          // or your own collector
 9  ]
10}

Execution context: parsed at install. Each collector host shows up in the install warning (“Read and change your data on www.google-analytics.com”), which is a cost to weigh; a first-party collector on your own API host avoids adding a third-party name. The flags endpoint needs no permission if it sends CORS headers allowing the extension origin, but host permission is simpler and survives Firefox’s per-install origins.

1. Deciding what to measure

Every event should trace back to a decision someone will make with it. “Count how many users open the side panel per week” informs whether to keep investing in the side panel. “Record every URL the user visits” informs nothing a product team needs and is a privacy disaster. Good extension analytics are coarse and few: feature usage counts, funnel steps through onboarding, error rates by release, retention by install cohort. They never include page URLs, page content, search terms or anything typed.

1// analytics/events.js — the complete, reviewed list of events
2export const EVENTS = Object.freeze({
3  installed:        { props: ["reason"] },              // install | update
4  onboarding_step:  { props: ["step"] },                // 1..4
5  feature_used:     { props: ["feature"] },             // "save" | "summarise" | "highlight"
6  setting_changed:  { props: ["setting"] },             // name only, never the value
7  error_shown:      { props: ["code"] },                // stable error code
8});

Execution context: a module shared by every context. An explicit schema is both documentation and a guard: the track function in step 2 rejects any event or property not listed, so nobody can add track("page_view", { url }) in a hurry. The schema is also what you paste into the privacy policy and the store’s data disclosure form.

What to collect and what never to collectCategories of extension data classified as appropriate for analytics, acceptable with care, or never appropriate, with the reason for each.DataCollect?WhyFeature usage countsYesProduct decisionsRelease and browser versionYes, coarseError triageSetting names changedWith careValues may be personalPage URLs or titlesNeverBrowsing historySelected text, form inputNeverPersonal contentIncognito activityNeverUser intent
If an event could reveal what the user was doing on a website, it does not belong in analytics.

2. Collecting from every context through the worker

Events happen in the popup, the side panel, content scripts and the worker. Route them all to the worker, which checks consent, validates the event against the schema, and adds it to a local queue. Sending happens later, in batches.

 1// any context
 2export function track(name, props = {}) {
 3  chrome.runtime.sendMessage({ type: "analytics:track", name, props }).catch(() => {});
 4}
 5
 6// sw.js
 7chrome.runtime.onMessage.addListener((msg, sender) => {
 8  if (msg?.type !== "analytics:track") return;
 9  if (sender.tab?.incognito) return;                         // never from private windows
10  enqueueEvent(msg.name, msg.props);
11});
12
13async function enqueueEvent(name, props) {
14  const { consent } = await chrome.storage.local.get("consent");
15  if (consent !== "granted") return;
16  const spec = EVENTS[name];
17  if (!spec) return;
18  const clean = Object.fromEntries(Object.entries(props).filter(([k]) => spec.props.includes(k)));
19  const { queue = [] } = await chrome.storage.local.get("queue");
20  queue.push({ name, props: clean, t: Math.floor(Date.now() / 3_600_000) });   // hour resolution
21  await chrome.storage.local.set({ queue: queue.slice(-500) });
22}

Execution context: track runs anywhere; the listener and queue run in the service worker. Persisting the queue in storage means events survive worker eviction. Rounding timestamps to the hour removes precise activity timing, which is a fingerprinting signal, at almost no cost to product analysis. The cap keeps a long offline period from growing storage without bound. Details are in privacy-preserving usage metrics.

3. Sending batches

A daily alarm flushes the queue. GA4’s Measurement Protocol accepts events from any HTTP client, including a service worker; a first-party endpoint gives you full control over retention.

 1chrome.alarms.create("analytics-flush", { periodInMinutes: 24 * 60 });
 2chrome.alarms.onAlarm.addListener(async ({ name }) => {
 3  if (name !== "analytics-flush") return;
 4  const { queue = [], installId } = await chrome.storage.local.get(["queue", "installId"]);
 5  if (!queue.length) return;
 6  const res = await fetch("https://t.readable.example/v1/batch", {
 7    method: "POST",
 8    headers: { "Content-Type": "application/json" },
 9    body: JSON.stringify({ installId, version: chrome.runtime.getManifest().version, events: queue }),
10  });
11  if (res.ok) await chrome.storage.local.set({ queue: [] });
12});

Execution context: the service worker, with the alarm listener at the top level. A failed send leaves the queue intact for the next run. GA4 specifics — measurement ids, API secrets, client_id and engagement time — are covered in sending analytics from a service worker with GA4.

From a click to the collectorThe popup tracks a feature use, the worker checks consent and schema and appends to the stored queue, and once a day an alarm sends the batch and clears the queue on success.PopupService workerstorage.localCollectortrack('feature_used', {feature})consent + schema checkqueue.push(event)daily alarm: POST batch200queue = []
Events are cheap to record and sent rarely — the worker sleeps in between.

4. Remote flags without remote code

Feature flags let you ship code dark, enable it gradually, and turn it off without a store release when something goes wrong. In an extension, a flag must select between code paths already in the package — the flags document is data, fetched and validated like any other remote input.

 1// flags.js
 2const DEFAULTS = { newSummariser: false, highlightV2: false, summariserRollout: 0 };
 3
 4export async function refreshFlags() {
 5  try {
 6    const res = await fetch("https://flags.readable.example/v1/flags.json", { cache: "no-cache" });
 7    const raw = await res.json();
 8    const flags = { ...DEFAULTS };
 9    for (const [k, v] of Object.entries(raw)) if (typeof v === typeof DEFAULTS[k]) flags[k] = v;
10    await chrome.storage.local.set({ flags, flagsAt: Date.now() });
11  } catch { /* keep last known flags */ }
12}
13
14export async function flag(name) {
15  const { flags = DEFAULTS } = await chrome.storage.local.get("flags");
16  return flags[name] ?? DEFAULTS[name];
17}

Execution context: the service worker refreshes on an alarm and on startup; any context reads with flag(). Unknown keys and wrongly typed values are ignored, so a malformed document cannot inject behaviour. The last good flags persist across restarts and offline periods. Rollouts, kill switches and why this stays within store policy are covered in remote feature flags without remote code and running A/B tests in an extension.

5. Learning why users leave

The single most informative signal for an extension is often the moment of uninstall. chrome.runtime.setUninstallURL opens a page of your choosing when the user removes the extension — the place for a two-question survey.

1chrome.runtime.onInstalled.addListener(async () => {
2  const { installId } = await chrome.storage.local.get("installId");
3  const v = chrome.runtime.getManifest().version;
4  await chrome.runtime.setUninstallURL(`https://readable.example/goodbye?v=${v}&i=${installId ?? ""}`);
5});

Execution context: the service worker. The URL is fixed when set, so include only what is useful and non-identifying, and keep it under the 1023-character limit. Firefox supports the same call; Safari does not. Survey design and what to include in the URL are in setting an uninstall survey URL.

Typical uninstall reasons from a short surveyIllustrative distribution of reasons selected in a two-question uninstall survey: not what I expected, too many permissions, slowed browsing, found an alternative, and other.Not what I expected31 % of respo…Too many permissions22 % of respo…Slowed my browser17 % of respo…Found an alternative14 % of respo…Other16 % of respo…
Uninstall reasons often point at the listing and permission warning, not the code.

6. Giving users control

Consent is not a one-time checkbox buried in onboarding. Users should be able to see what the extension collects, change their mind at any time, and have the change take effect immediately — including discarding events already queued but not yet sent.

1// options.js
2const toggle = document.querySelector("#analytics");
3const { consent } = await chrome.storage.local.get("consent");
4toggle.checked = consent === "granted";
5toggle.addEventListener("change", async () => {
6  const next = toggle.checked ? "granted" : "denied";
7  await chrome.storage.local.set({ consent: next, ...(next === "denied" ? { queue: [] } : {}) });
8});

Execution context: the options page. Clearing the queue on withdrawal means nothing collected before the change is sent after it. Next to the toggle, show the event schema in plain language — “which features you use, how often, and which version you run; never the pages you visit” — generated from the same EVENTS object the code enforces, so the description cannot drift from reality. If you use a third-party collector, link its data-deletion process; if you run your own, provide a “delete my data” action keyed by the install id.

7. Reading the numbers responsibly

Extension analytics have biases that web analytics do not. Users who opt in are not representative of users who do not; Safari users may be invisible if you rely on uninstall URLs; daily counts dip on weekends and holidays in ways that look like regressions; and an event that depends on the service worker being awake can be under-counted if the worker is evicted before the event is persisted. Treat every metric as a trend within one population rather than an absolute truth, and compare releases against the same weekday window.

1Healthy signal:        feature_used(save) per active install, week over week, same weekday window
2Misleading signal:     total events per day (driven by install count and opt-in rate)

Execution context: analysis, not code. Normalise by active installs counted the same way — see counting active users without tracking — and annotate dashboards with release dates and flag changes, so a step change can be traced to its cause. When numbers and qualitative feedback disagree, investigate the instrumentation before concluding that users are wrong.

Store policies treat extension telemetry strictly. The Chrome Web Store’s user data policy requires that data collection be disclosed in the listing’s privacy practices, limited to what the single purpose needs, and never sold; collecting browsing activity requires prominent disclosure and consent. AMO requires opt-in consent for most data collection beyond technical crash data, and a declaration in the manifest for newer Firefox versions. In regions with consent laws, opt-in is the safe default everywhere. The practical consequence: build the consent UI first, default to off unless you have a clear legal basis, and make the analytics code a no-op until consent is granted.

Reviewers also look at where data is sent from. A content script that calls a third-party analytics host from inside every page is indistinguishable, in a network trace, from tracking the user’s browsing — even if the payload is innocent. Keeping every outbound analytics request in the service worker, carrying only schema-approved fields, makes the extension’s behaviour easy to audit: one host, one code path, one payload shape. Document that path in a short “data flows” section in your repository so the next reviewer, internal or external, can confirm it in minutes rather than reverse-engineering it from a packet capture.

Security matters as well. Analytics endpoints receive data from every install and are an attractive target; validate on the server, rate-limit by install id, and never trust event payloads for anything but counting. Flags documents should be served over HTTPS from infrastructure you control, and changes to them should go through review like code, because they change behaviour for every user at once.

A useful habit is to review analytics and flags together at each release: which events did the release add, which flags did it introduce, and which old ones can now be removed. Telemetry and flags both accumulate silently, and a quarterly clean-up keeps the schema small enough that a reviewer — or a curious user reading your privacy page — can understand it in a minute.

MV3 constraints box

  • No remote scripts. Analytics and flag SDKs that inject <script> tags or evaluate code are prohibited; use fetch and data.
  • No window in the worker. Most web analytics libraries assume a page; use protocols (GA4 Measurement Protocol) or your own endpoint.
  • Events must survive eviction. Queue in chrome.storage, flush on an alarm.
  • Content scripts are page-adjacent. Never send analytics from a content script — it runs under page CORS and leaks the page origin.
  • Install warnings name collector hosts. Prefer a first-party endpoint on a host you already request.
  • Disclosures must match code. Store reviewers compare listing disclosures, privacy policy and network traffic.

Cross-browser notes

CapabilityChrome / EdgeFirefoxSafari
fetch to collector from backgroundYesYesYes
runtime.setUninstallURLYesYesNo
Consent requirementDisclosure; consent for sensitive dataOpt-in for most collectionApp Store privacy labels
Manifest data-collection declarationNoYes (recent versions)No
Alarm-driven flushYesYesDelayed when idle

Safari users are covered by the containing app’s App Store privacy label, which must reflect any telemetry the extension sends. Because Safari lacks uninstall URLs, its retention data has to come from aggregate active-user counts instead, as described in counting active users without tracking.

What this section covers

Start with privacy-preserving usage metrics to decide what to collect, then sending analytics from a service worker with GA4 or counting active users without tracking to send it. Setting an uninstall survey URL covers churn. On the control side, remote feature flags without remote code and running A/B tests in an extension cover rollouts and experiments.

Covered elsewhere: crash and error reporting, which is technical telemetry with its own rules, is in error monitoring and crash reporting.