Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ words:
- consolas
- Cpath
- crbug
- cros
- crunchyroll
- cssinjs
- CSUI
Expand Down Expand Up @@ -69,6 +70,7 @@ words:
- Menlo
- menupanel
- metas
- moderok
- mktemp
- mycompany
- nacl
Expand Down
36 changes: 36 additions & 0 deletions packages/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Report analytics events from your web extension extension.
## Supported Analytics Providers

- [Google Analytics 4 (Measurement Protocol)](#google-analytics-4-measurement-protocol)
- [Moderok](#moderok)
- [Umami](#umami)

## Install With WXT
Expand Down Expand Up @@ -120,6 +121,41 @@ export default defineAppConfig({
});
```

### Moderok

[Moderok](https://moderok.dev) is an analytics platform built specifically for browser extensions. It requires no `host_permissions`, works in Manifest V3 service workers, and collects only anonymous usage data.

Sign up at [moderok.dev](https://moderok.dev) to get your app key, then save it to your `.env` file:

```dotenv
WXT_MODEROK_APP_KEY='mk_...'
```

Then add the `moderok` provider to your `<srcDir>/app.config.ts` file:

```ts
import { moderok } from '@wxt-dev/analytics/providers/moderok';

export default defineAppConfig({
analytics: {
providers: [
moderok({
appKey: import.meta.env.WXT_MODEROK_APP_KEY,
// Automatically track first open, install, update, and daily ping events (default: true)
trackLifecycle: true,
// Track when users uninstall the extension (default: false)
trackUninstalls: false,
// Optional: when trackUninstalls is on, redirect users to this page
// after they uninstall (e.g. a feedback survey)
uninstallUrl: 'https://example.com/uninstall',
}),
],
},
});
```

For a full walkthrough — module setup, sending events, and all provider options — see the [Moderok WXT guide](https://docs.moderok.dev/guide/wxt).

### Umami

[Umami](https://umami.is/) is a privacy-first, open source analytics platform.
Expand Down
10 changes: 7 additions & 3 deletions packages/analytics/modules/analytics/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,13 @@ function createBackgroundAnalytics(
// Cached values
const platformInfo = browser.runtime.getPlatformInfo();
const userAgent = UAParser();
let userId = Promise.resolve(userIdStorage.getValue()).then(
(id) => id ?? globalThis.crypto.randomUUID(),
);
let userId = Promise.resolve(userIdStorage.getValue()).then(async (id) => {
if (id != null) return id;
// Persist the generated ID so it's stable across service worker restarts.
const generatedId = globalThis.crypto.randomUUID();
await userIdStorage.setValue?.(generatedId);
return generatedId;
});
let userProperties = userPropertiesStorage.getValue();
const manifest = browser.runtime.getManifest();

Expand Down
267 changes: 267 additions & 0 deletions packages/analytics/modules/analytics/providers/moderok.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { defineAnalyticsProvider } from '../client';
import { browser } from '@wxt-dev/browser';
import type { BaseAnalyticsEvent } from '../types';

const SDK_VERSION = 'wxt/0.1.0';
const DEFAULT_ENDPOINT = 'https://api.moderok.dev/v1/events';
const PING_STORAGE_KEY = 'moderok:last-ping-date';
const FIRST_OPEN_STORAGE_KEY = 'moderok:first-open';

export interface ModerokProviderOptions {
appKey: string;
endpoint?: string;
trackLifecycle?: boolean;
trackUninstalls?: boolean;
uninstallUrl?: string;
}

const OS_MAP: Record<string, string> = {
mac: 'MacOS',
win: 'Windows',
linux: 'Linux',
cros: 'ChromeOS',
android: 'Android',
};

const BROWSER_MAP: Record<string, string> = {
chrome: 'chrome',
edge: 'edge',
firefox: 'firefox',
chromium: 'other_chromium',
};

function mapOs(wxtOs: string | undefined): string {
if (!wxtOs) return 'unknown';
return OS_MAP[wxtOs.toLowerCase()] ?? 'unknown';
}

function mapBrowser(wxtBrowser: string | undefined): string {
if (!wxtBrowser) return 'unknown';
return BROWSER_MAP[wxtBrowser.toLowerCase()] ?? 'unknown';
}

function detectSource(meta: BaseAnalyticsEvent['meta']): string {
if (meta.sessionId == null) return 'background';

const url = meta.url;
if (!url) return 'unknown';
const isExtensionUrl =
url.startsWith('chrome-extension://') || url.startsWith('moz-extension://');
if (!isExtensionUrl) return 'content_script';

const path = url.toLowerCase();
if (path.includes('popup')) return 'popup';
if (path.includes('option')) return 'options';
if (
path.includes('sidepanel') ||
path.includes('side-panel') ||
path.includes('side_panel')
)
return 'side_panel';

return 'extension_page';
}

function buildContext(event: BaseAnalyticsEvent, extensionId: string) {
return {
sdkVersion: SDK_VERSION,
extensionId: extensionId || 'unknown',
extensionVersion: event.user.properties.version || 'unknown',
browser: mapBrowser(event.user.properties.browser),
browserVersion: event.user.properties.browserVersion || 'unknown',
os: mapOs(event.user.properties.os),
locale: event.meta.language || 'unknown',
source: detectSource(event.meta),
};
}

function sendEvent(
endpoint: string,
appKey: string,
event: {
name: string;
userId: string;
timestamp: number;
context: ReturnType<typeof buildContext>;
properties?: Record<string, string | number | boolean>;
},
debug: boolean,
) {
const payload = {
appKey,
sentAt: Date.now(),
events: [
{
id: globalThis.crypto.randomUUID(),
name: event.name,
timestamp: event.timestamp,
userId: event.userId,
context: event.context,
...(event.properties && Object.keys(event.properties).length > 0
? { properties: event.properties }
: {}),
},
],
};

if (debug) {
console.debug('[@wxt-dev/analytics][moderok] Sending:', payload);
}

return fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'text/plain;charset=UTF-8' },
body: JSON.stringify(payload),
});
}

function cleanProperties(
raw: Record<string, string | undefined> | undefined,
): Record<string, string> | undefined {
if (!raw) return undefined;
const clean: Record<string, string> = {};
for (const [key, value] of Object.entries(raw)) {
if (value != null) clean[key] = value;
}
return Object.keys(clean).length > 0 ? clean : undefined;
}

function utcDateStamp(): string {
return new Date().toISOString().slice(0, 10);
}

function buildUninstallUrl(
endpoint: string,
appKey: string,
userId: string,
redirectUrl?: string,
): string {
const url = new URL(endpoint);
url.pathname = url.pathname.replace(/\/[^/]*$/, '/uninstall');
url.searchParams.set('app', appKey);
url.searchParams.set('uid', userId);
if (redirectUrl) url.searchParams.set('redirect', redirectUrl);
return url.toString();
}

export const moderok = defineAnalyticsProvider<ModerokProviderOptions>(
(analytics, config, options) => {
const endpoint = options.endpoint ?? DEFAULT_ENDPOINT;
const debug = config.debug ?? false;
const trackLifecycle = options.trackLifecycle ?? true;
const extensionId = browser.runtime.id ?? '';
let firstOpenPromise: Promise<void> | undefined;

const maybeTrackFirstOpen = async (event: BaseAnalyticsEvent) => {
if (!trackLifecycle) return;
if (firstOpenPromise) return firstOpenPromise;

firstOpenPromise = (async () => {
const stored = await browser.storage.local.get(FIRST_OPEN_STORAGE_KEY);
if (stored[FIRST_OPEN_STORAGE_KEY]) return;

const response = await sendEvent(
endpoint,
options.appKey,
{
name: '__first_open',
userId: event.user.id,
timestamp: event.meta.timestamp,
context: buildContext(event, extensionId),
},
debug,
);

if (response.ok) {
await browser.storage.local.set({ [FIRST_OPEN_STORAGE_KEY]: true });
} else {
firstOpenPromise = undefined;
}
})().catch((error) => {
firstOpenPromise = undefined;
throw error;
});

return firstOpenPromise;
};

if (trackLifecycle) {
browser.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
analytics.track('__install');
} else if (details.reason === 'update') {
analytics.track('__update', {
previousVersion: (details as { previousVersion?: string })
.previousVersion,
});
}
});

void (async () => {
const today = utcDateStamp();
const stored = await browser.storage.local.get(PING_STORAGE_KEY);
if (stored[PING_STORAGE_KEY] === today) return;
await browser.storage.local.set({ [PING_STORAGE_KEY]: today });
analytics.track('__daily_ping');
})();
}

let uninstallUrlSet = false;
function maybeSetUninstallUrl(userId: string) {
if (!options.trackUninstalls || uninstallUrlSet) return;
uninstallUrlSet = true;

const url = buildUninstallUrl(
endpoint,
options.appKey,
userId,
options.uninstallUrl,
);
if (url.length <= 1023) {
browser.runtime.setUninstallURL(url);
}
}

return {
identify: () => Promise.resolve(),

page: async (event) => {
maybeSetUninstallUrl(event.user.id);
await maybeTrackFirstOpen(event);
await sendEvent(
endpoint,
options.appKey,
{
name: '__page_view',
userId: event.user.id,
timestamp: event.meta.timestamp,
context: buildContext(event, extensionId),
properties: {
url: event.page.url,
...(event.page.title ? { title: event.page.title } : {}),
...(event.page.location ? { location: event.page.location } : {}),
},
},
debug,
);
},

track: async (event) => {
maybeSetUninstallUrl(event.user.id);
await maybeTrackFirstOpen(event);
await sendEvent(
endpoint,
options.appKey,
{
name: event.event.name.trim(),
userId: event.user.id,
timestamp: event.meta.timestamp,
context: buildContext(event, extensionId),
properties: cleanProperties(event.event.properties),
},
debug,
);
},
};
},
);
4 changes: 4 additions & 0 deletions packages/analytics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
"types": "./dist/providers/google-analytics-4.d.mts",
"default": "./dist/providers/google-analytics-4.mjs"
},
"./providers/moderok": {
"types": "./dist/providers/moderok.d.mts",
"default": "./dist/providers/moderok.mjs"
},
"./providers/umami": {
"types": "./dist/providers/umami.d.mts",
"default": "./dist/providers/umami.mjs"
Expand Down
1 change: 1 addition & 0 deletions packages/analytics/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
'providers/google-analytics-4':
'./modules/analytics/providers/google-analytics-4.ts',
'providers/umami': './modules/analytics/providers/umami.ts',
'providers/moderok': './modules/analytics/providers/moderok.ts',
},
deps: {
neverBundle: ['#analytics'],
Expand Down
Loading