diff --git a/cspell.yml b/cspell.yml index 92fa18b5e..2c16e6347 100644 --- a/cspell.yml +++ b/cspell.yml @@ -32,6 +32,7 @@ words: - consolas - Cpath - crbug + - cros - crunchyroll - cssinjs - CSUI @@ -69,6 +70,7 @@ words: - Menlo - menupanel - metas + - moderok - mktemp - mycompany - nacl diff --git a/packages/analytics/README.md b/packages/analytics/README.md index 1cb90f697..e28c67796 100644 --- a/packages/analytics/README.md +++ b/packages/analytics/README.md @@ -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 @@ -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 `/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. diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts index 3c25cc6bd..e2918bf9b 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/client.ts @@ -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(); diff --git a/packages/analytics/modules/analytics/providers/moderok.ts b/packages/analytics/modules/analytics/providers/moderok.ts new file mode 100644 index 000000000..684c820a3 --- /dev/null +++ b/packages/analytics/modules/analytics/providers/moderok.ts @@ -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 = { + mac: 'MacOS', + win: 'Windows', + linux: 'Linux', + cros: 'ChromeOS', + android: 'Android', +}; + +const BROWSER_MAP: Record = { + 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; + properties?: Record; + }, + 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 | undefined, +): Record | undefined { + if (!raw) return undefined; + const clean: Record = {}; + 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( + (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 | 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, + ); + }, + }; + }, +); diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 1f2b87ca9..bf718a846 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -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" diff --git a/packages/analytics/tsdown.config.ts b/packages/analytics/tsdown.config.ts index 1bc3b18c8..aede7b01c 100644 --- a/packages/analytics/tsdown.config.ts +++ b/packages/analytics/tsdown.config.ts @@ -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'],