WXT Analytics: The @wxt-dev/analytics Providers Compared
6 min readModerok team
WXT analytics compared: what each @wxt-dev/analytics provider sends from an MV3 background service worker, read from the package source.
WXT analytics means one package, @wxt-dev/analytics, and it ships four providers. Version 0.5.7, published 4 October 2026, exports ./providers/google-analytics-4, ./providers/umami, ./providers/posthog and ./providers/moderok, the last two added in 0.5.6 and 0.5.5 (npm registry metadata, as of October 2026). All four work in an MV3 background service worker, because WXT loads no vendor browser SDK: each provider file builds a payload and posts it with fetch. What separates them is what arrives without you writing it. Only the Moderok provider sends extension lifecycle events, and only the PostHog provider sends exceptions.
TL;DR: Pick on automatic events and errors, not on MV3 support: WXT already solved MV3 for every provider. Only the Moderok provider sends lifecycle events,
__installand__updatefrombrowser.runtime.onInstalled, plus a once per UTC day__daily_pingand a one time__first_open(moderok.ts, as of October 2026). Only the PostHog provider defines anexceptionmethod, and only when you passerrorTracking(posthog.ts, as of October 2026). GA4 and Umami give you a transport and nothing else. And all four send nothing until you deal with theenabledflag, which defaults tofalse.
The 30-second comparison
Each row is a fact about the WXT provider file linked in the cell, as of October 2026 (opened 23 October 2026), describing WXT's provider code, not the vendors' products.
| Question | Google Analytics 4 | Umami | PostHog | Moderok |
|---|---|---|---|---|
| Required options | apiSecret, measurementId (src) | apiUrl, websiteId, domain (src) | apiKey (src) | appKey (src) |
| Host option, and its default | apiUrl, https://www.google-analytics.com (src) | apiUrl, required, no default (src) | apiHost, https://us.i.posthog.com (src) | endpoint, https://api.moderok.dev/v1/events (src) |
Registers onInstalled or setUninstallURL | Neither (src) | Neither (src) | Neither (src) | Both, behind trackLifecycle and trackUninstalls (src) |
Defines an exception method | No: (src) | No: (src) | Yes: When errorTracking (src) | No: (src) |
Round 1Does each provider run in an MV3 service worker?
Yes, all four. Adding @wxt-dev/analytics/module to modules in wxt.config.ts gives you an #analytics import for reporting events (wxt.dev/analytics, as of October 2026).
The module hooks build:manifestGenerated to run manifest.permissions.push('storage') when storage is absent, and entrypoints:resolved to add a background entry when none exists (module source, as of October 2026).
Calls from a popup or content script are not sent from there: the client opens a long lived port with browser.runtime.connect({ name: ANALYTICS_PORT }), and the background listens with browser.runtime.onConnect.addListener and invokes the matching method (client source, as of October 2026). Every request therefore leaves the background service worker, which is why the usual objection to a vendor's web SDK, that it reaches for document, does not apply here.
Tie. MV3 support is a property of the module, not of the provider you pick.
Round 2Who supplies the user id?
WXT does, for all four. The client falls back to defineStorageItem<string>('wxt-analytics:user-id'), and when nothing is stored it generates one with crypto.randomUUID() and saves it (client source, as of October 2026).
Each provider then maps that id to its own identity field. GA4 sends it as client_id: data.user.id, which Google's reference marks "Required. Identifier for a user instance of a web client" (Measurement Protocol reference, as of October 2026). PostHog sends it as distinct_id and the Moderok provider as userId (moderok.ts, as of October 2026). Umami sends no id of its own, and its API docs note that /api/send needs no authentication token but "you need to send a proper User-Agent HTTP header or your request won't be registered" (Umami docs, as of October 2026).
Tie. The identity is WXT's storage item in every case, so switching providers does not change who a "user" is.
Round 3Extension lifecycle events
The first real split. An extension's most useful moments are install, update, first open and whether anyone came back today, and three of the four send none of them.
Only providers/moderok.ts registers a lifecycle listener. Inside if (trackLifecycle) it adds browser.runtime.onInstalled.addListener and calls analytics.track('__install') or analytics.track('__update', { previousVersion }) on details.reason. A stored UTC date stamp gates a once per day analytics.track('__daily_ping'), and a stored flag gates a single __first_open, only marked sent when the response is ok. trackLifecycle defaults to true; trackUninstalls does not, and when on it calls browser.runtime.setUninstallURL(url) once, only if the built URL is 1023 characters or shorter (moderok.ts, as of October 2026).
With GA4, Umami or PostHog you write that yourself: the onInstalled listener, the branch on details.reason, a definition of a daily active, the setUninstallURL call. None of it is hard, but it decides whether you can answer retention questions later. Moderok's docs describe those events as how you "see installs, updates, first open, daily activity, and errors in the dashboard" (automatic events); through this provider the error half needs the SDK, and uninstalls need trackUninstalls on.
Moderok, by default. The other three can get there, with your code.
Round 4Errors and exceptions
The Analytics interface exposes captureException(error, properties?), and the provider contract makes the matching handler optional: exception?: (event: AnalyticsExceptionEvent) => Promise<void> (types source, as of October 2026).
Exactly one provider implements it. providers/posthog.ts defines exception only when options.errorTracking is truthy and sends a $exception event (posthog.ts, as of October 2026). The GA4, Umami and Moderok provider files return only identify, page and track, so captureException() reaches no handler.
For Moderok that is documented: the provider sends lifecycle, page view and custom events only, and error capture lives in @moderok/sdk, which runs alongside it against the same project. The SDK records uncaught exceptions and unhandled promise rejections as __error by default; console.error mirroring needs captureConsoleErrors: true, and chrome.runtime.lastError needs a captureLastError() call (error tracking).
PostHog, if errors must come from the provider. Moderok vs PostHog covers how the two differ outside WXT.
Three gotchas in WXT analytics worth knowing before you ship
Analytics is off until you turn it on. The client falls back to defineStorageItem<boolean>('local:wxt-analytics:enabled', false), and the background checks if (await enabled.getValue()) before calling any provider (client source, as of October 2026). Nothing throws. And enabled is a storage item, not a boolean: enabled?: AnalyticsStorageItem<boolean> (types source, as of October 2026), so app.config.ts takes the object form enabled: { getValue: () => true }, or leave the default and call analytics.setEnabled(true) after a consent prompt (WXT integration guide).
debug: true silently stops GA4 data. The GA4 provider picks its path with config.debug ? '/debug/mp/collect' : '/mp/collect' (google-analytics-4.ts, as of October 2026). That is Google's validation server, and Google's own page warns: "Events sent to the validation server don't show up in reports" (validating events, as of October 2026). A debug build that looks healthy can be reporting nothing.
Event properties are strings. AnalyticsTrackEvent types them as properties?: Record<string, string | undefined> (types source, as of October 2026), so a count must be stringified:
import { analytics } from '#analytics';
await analytics.track('summarize_clicked', {
surface: 'popup',
selection_chars: String(selection.length),
});
Moderok's own SDK accepts flat string, number or boolean values (events), so the same event keeps its number there. The docs are explicit that the provider "is a separate implementation built for WXT's provider model" (WXT integration guide), not a wrapper.
When GA4, Umami or PostHog is the right choice
Providers compose, so none of this is either/or: the providers array in app.config.ts takes more than one (types source).
Pick GA4 when the extension is one more data stream in a property you already run: the provider wants a measurementId, "The identifier for a Data Stream" (Measurement Protocol reference, as of October 2026).
Pick Umami when the host has to be yours: its provider takes a required apiUrl, and its send endpoint needs no auth token (Umami docs, as of October 2026).
Pick PostHog when the provider itself has to report exceptions: "Error tracking runs on the exceptions your app sends to PostHog" (PostHog docs, as of October 2026), and with errorTracking on it is the only one of the four that sends them.
Moderok answers the extension shaped questions: how many installed, how many updated, how many came back today. Wiring the provider takes the install, the module in wxt.config.ts, a WXT_MODEROK_APP_KEY in .env, the provider in app.config.ts, and the enabled flag handled, all of it in the WXT integration guide. For the wider picture there is the Moderok product page and a roundup of extension analytics tools outside WXT.