diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 653b081e4..2e971e4ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: default: wxt type: choice options: + - analytics - auto-icons - i18n - module-react diff --git a/.github/workflows/sync-releases.yml b/.github/workflows/sync-releases.yml index 78426417c..3d0663fa8 100644 --- a/.github/workflows/sync-releases.yml +++ b/.github/workflows/sync-releases.yml @@ -7,6 +7,7 @@ on: default: wxt type: choice options: + - analytics - auto-icons - i18n - module-react diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 79d05b0de..5cce266ef 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -14,6 +14,7 @@ import { version as i18nVersion } from '../../packages/i18n/package.json'; import { version as autoIconsVersion } from '../../packages/auto-icons/package.json'; import { version as unocssVersion } from '../../packages/unocss/package.json'; import { version as storageVersion } from '../../packages/storage/package.json'; +import { version as analyticsVersion } from '../../packages/analytics/package.json'; import knowledge from 'vitepress-knowledge'; const title = 'Next-gen Web Extension Framework'; @@ -24,6 +25,14 @@ const ogTitle = `${title}${titleSuffix}`; const ogUrl = 'https://wxt.dev'; const ogImage = 'https://wxt.dev/social-preview.png'; +const otherPackages = { + analytics: analyticsVersion, + 'auto-icons': autoIconsVersion, + i18n: i18nVersion, + storage: storageVersion, + unocss: unocssVersion, +}; + // https://vitepress.dev/reference/site-config export default defineConfig({ extends: knowledge({ @@ -109,12 +118,12 @@ export default defineConfig({ 'https://github.com/wxt-dev/wxt/blob/main/packages/wxt/CHANGELOG.md', ), ]), - navItem('Other Packages', [ - navItem(`@wxt-dev/storage — ${storageVersion}`, '/storage'), - navItem(`@wxt-dev/auto-icons — ${autoIconsVersion}`, '/auto-icons'), - navItem(`@wxt-dev/i18n — ${i18nVersion}`, '/i18n'), - navItem(`@wxt-dev/unocss — ${unocssVersion}`, '/unocss'), - ]), + navItem( + 'Other Packages', + Object.entries(otherPackages).map(([name, version]) => + navItem(`@wxt-dev/${name} — ${version}`, `/${name}`), + ), + ), ]), ], diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 000000000..6537a274e --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1 @@ + diff --git a/docs/assets/init-demo.gif b/docs/assets/init-demo.gif index ee6e9ed87..59f8427b0 100644 Binary files a/docs/assets/init-demo.gif and b/docs/assets/init-demo.gif differ diff --git a/packages/analytics/README.md b/packages/analytics/README.md new file mode 100644 index 000000000..d0355f0d0 --- /dev/null +++ b/packages/analytics/README.md @@ -0,0 +1,269 @@ +# WXT Analytics + +Report analytics events from your web extension extension. + +## Supported Analytics Providers + +- [Google Analytics 4 (Measurement Protocol)](#google-analytics-4-measurement-protocol) +- [Umami](#umami) + +## Install With WXT + +1. Install the NPM package: + ```bash + pnpm i @wxt-dev/analytics + ``` +2. In your `wxt.config.ts`, add the WXT module: + ```ts + export default defineConfig({ + modules: ['@wxt-dev/analytics/module'], + }); + ``` +3. In your `/app.config.ts`, add a provider: + + ```ts + // /app.config.ts + import { umami } from '@wxt-dev/analytics/providers/umami'; + + export default defineAppConfig({ + analytics: { + debug: true, + providers: [ + // ... + ], + }, + }); + ``` + +4. Then use the `#analytics` module to report events: + + ```ts + import { analytics } from '#analytics'; + + await analytics.track('some-event'); + await analytics.page(); + await analytics.identify('some-user-id'); + analytics.autoTrack(document.body); + ``` + +## Install Without WXT + +1. Install the NPM package: + ```bash + pnpm i @wxt-dev/analytics + ``` +2. Create an `analytics` instance: + + ```ts + // utils/analytics.ts + import { createAnalytics } from '@wxt-dev/analytics'; + + export const analytics = createAnalytics({ + providers: [ + // ... + ], + }); + ``` + +3. Import your analytics module in the background to initialize the message listener: + ```ts + // background.ts + import './utils/analytics'; + ``` +4. Then use your `analytics` instance to report events: + + ```ts + import { analytics } from './utils/analytics'; + + await analytics.track('some-event'); + await analytics.page(); + await analytics.identify('some-user-id'); + analytics.autoTrack(document.body); + ``` + +## Providers + +### Google Analytics 4 (Measurement Protocol) + +The [Measurement Protocol](https://developers.google.com/analytics/devguides/collection/protocol/ga4) is an alternative to GTag for reporting events to Google Analytics for MV3 extensions. + +> [Why use the Measurement Protocol instead of GTag?](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#measurement-protocol) + +Follow [Google's documentation](https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4#setup-credentials) to obtain your credentials and put them in your `.env` file: + +```dotenv +WXT_GA_API_SECRET='...' +``` + +Then add the `googleAnalytics4` provider to your `/app.config.ts` file: + +```ts +import { googleAnalytics4 } from '@wxt-dev/analytics/providers/google-analytics-4'; + +export default defineAppConfig({ + analytics: { + providers: [ + googleAnalytics4({ + apiSecret: import.meta.env.WXT_GA_API_SECRET, + measurementId: '...', + }), + ], + }, +}); +``` + +### Umami + +[Umami](https://umami.is/) is a privacy-first, open source analytics platform. + +In Umami's dashboard, create a new website. The website's name and domain can be anything. Obviously, an extension doesn't have a domain, so make one up if you don't have one. + +After the website has been created, save the website ID and domain to your `.env` file: + +```dotenv +WXT_UMAMI_WEBSITE_ID='...' +WXT_UMAMI_DOMAIN='...' +``` + +Then add the `umami` provider to your `/app.config.ts` file: + +```ts +import { umami } from '@wxt-dev/analytics/providers/umami'; + +export default defineAppConfig({ + analytics: { + providers: [ + umami({ + apiUrl: 'https:///api', + websiteId: import.meta.env.WXT_UMAMI_WEBSITE_ID, + domain: import.meta.env.WXT_UMAMI_DOMAIN, + }), + ], + }, +}); +``` + +### Custom Provider + +If your analytics platform is not supported, you can provide an implementation of the `AnalyticsProvider` type in your `app.config.ts` instead: + +```ts +import { defineAnalyticsProvider } from '@wxt-dev/analytics/client'; + +interface CustomAnalyticsOptions { + // ... +} + +const customAnalytics = defineAnalyticsProvider( + (analytics, analyticsConfig, providerOptions) => { + // ... + }, +); + +export default defineAppConfig({ + analytics: { + providers: [ + customAnalytics({ + // ... + }), + ], + }, +}); +``` + +Example `AnalyticsProvider` implementations can be found at [`./modules/analytics/providers`](https://github.com/wxt-dev/wxt/tree/main/packages/analytics/modules/analytics/providers). + +## User Properties + +User ID and properties are stored in `browser.storage.local`. To change this or customize where these values are stored, use the `userId` and `userProperties` config: + +```ts +// app.config.ts +import { storage } from 'wxt/storage'; + +export default defineAppConfig({ + analytics: { + userId: storage.defineItem('local:custom-user-id-key'), + userProperties: storage.defineItem('local:custom-user-properties-key'), + }, +}); +``` + +To set the values at runtime, use the `identify` function: + +```ts +await analytics.identify(userId, userProperties); +``` + +Alternatively, a common pattern is to use a random string as the user ID. This keeps the actual user information private, while still providing useful metrics in your analytics platform. This can be done very easily using WXT's storage API: + +```ts +// app.config.ts +import { storage } from 'wxt/storage'; + +export default defineAppConfig({ + analytics: { + userId: storage.defineItem('local:custom-user-id-key', { + init: () => crypto.randomUUID(), + }), + }, +}); +``` + +If you aren't using `wxt` or `@wxt-dev/storage`, you can define custom implementations for the `userId` and `userProperties` config: + +```ts +const analytics = createAnalytics({ + userId: { + getValue: () => ..., + setValue: (userId) => ..., + } +}) +``` + +## Auto-track UI events + +Call `analytics.autoTrack(container)` to automatically track UI events so you don't have to manually add them. Currently it: + +- Tracks clicks to elements inside the `container` + +In your extension's HTML pages, you'll want to call it with `document`: + +```ts +analytics.autoTrack(document); +``` + +But in content scripts, you usually only care about interactions with your own UI: + +```ts +const ui = createIntegratedUi({ + // ... + onMount(container) { + analytics.autoTrack(container); + }, +}); +ui.mount(); +``` + +## Enabling/Disabling + +By default, **analytics is disabled**. You can configure how the value is stored (and change the default value) via the `enabled` config: + +```ts +// app.config.ts +import { storage } from 'wxt/storage'; + +export default defineAppConfig({ + analytics: { + enabled: storage.defineItem('local:analytics-enabled', { + fallback: true, + }), + }, +}); +``` + +At runtime, you can call `setEnabled` to change the value: + +```ts +analytics.setEnabled(true); +``` diff --git a/packages/analytics/app.config.ts b/packages/analytics/app.config.ts new file mode 100644 index 000000000..757be2844 --- /dev/null +++ b/packages/analytics/app.config.ts @@ -0,0 +1,20 @@ +import { defineAppConfig } from 'wxt/sandbox'; +import { googleAnalytics4 } from './modules/analytics/providers/google-analytics-4'; +import { umami } from './modules/analytics/providers/umami'; + +export default defineAppConfig({ + analytics: { + debug: true, + providers: [ + googleAnalytics4({ + apiSecret: '...', + measurementId: '...', + }), + umami({ + apiUrl: 'https://umami.aklinker1.io/api', + domain: 'analytics.wxt.dev', + websiteId: '8f1c2aa4-fad3-406e-a5b2-33e8d4501716', + }), + ], + }, +}); diff --git a/packages/analytics/build.config.ts b/packages/analytics/build.config.ts new file mode 100644 index 000000000..a74ed9af5 --- /dev/null +++ b/packages/analytics/build.config.ts @@ -0,0 +1,21 @@ +import { defineBuildConfig } from 'unbuild'; +import { resolve } from 'node:path'; + +// Build module and plugins +export default defineBuildConfig({ + rootDir: resolve(__dirname, 'modules/analytics'), + outDir: resolve(__dirname, 'dist'), + entries: [ + { input: 'index.ts', name: 'module' }, + { input: 'client.ts', name: 'index' }, + 'background-plugin.ts', + 'types.ts', + 'providers/google-analytics-4.ts', + 'providers/umami.ts', + ], + externals: ['#analytics'], + replace: { + 'import.meta.env.NPM': 'true', + }, + declaration: true, +}); diff --git a/packages/analytics/entrypoints/popup/index.html b/packages/analytics/entrypoints/popup/index.html new file mode 100644 index 000000000..6725f56e6 --- /dev/null +++ b/packages/analytics/entrypoints/popup/index.html @@ -0,0 +1,17 @@ + + + + + + Popup + + + + + + + + diff --git a/packages/analytics/entrypoints/popup/main.ts b/packages/analytics/entrypoints/popup/main.ts new file mode 100644 index 000000000..55a996bdf --- /dev/null +++ b/packages/analytics/entrypoints/popup/main.ts @@ -0,0 +1,9 @@ +import { analytics } from '#analytics'; + +declare const enabledCheckbox: HTMLInputElement; + +analytics.autoTrack(document); + +enabledCheckbox.oninput = () => { + void analytics.setEnabled(enabledCheckbox.checked); +}; diff --git a/packages/analytics/modules/analytics/background-plugin.ts b/packages/analytics/modules/analytics/background-plugin.ts new file mode 100644 index 000000000..f3ab40766 --- /dev/null +++ b/packages/analytics/modules/analytics/background-plugin.ts @@ -0,0 +1,3 @@ +import '#analytics'; + +export default () => {}; diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts new file mode 100644 index 000000000..f97457a56 --- /dev/null +++ b/packages/analytics/modules/analytics/client.ts @@ -0,0 +1,288 @@ +import { UAParser } from 'ua-parser-js'; +import type { + Analytics, + AnalyticsConfig, + AnalyticsPageViewEvent, + AnalyticsStorageItem, + AnalyticsTrackEvent, + BaseAnalyticsEvent, + AnalyticsEventMetadata, + AnalyticsProvider, +} from './types'; + +const ANALYTICS_PORT = '@wxt-dev/analytics'; + +export function createAnalytics(config?: AnalyticsConfig): Analytics { + if (typeof chrome === 'undefined' || !chrome?.runtime?.id) + throw Error( + 'Cannot use WXT analytics in contexts without access to the browser.runtime APIs', + ); + if (config == null) { + console.warn( + "[@wxt-dev/analytics] Config not provided to createAnalytics. If you're using WXT, add the 'analytics' property to '/app.config.ts'.", + ); + } + + // TODO: This only works for standard WXT extensions, add a more generic + // background script detector that works with non-WXT projects. + if (location.pathname === '/background.js') + return createBackgroundAnalytics(config); + + return createFrontendAnalytics(); +} + +/** + * Creates an analytics client in the background responsible for uploading events to the server to avoid CORS errors. + */ +function createBackgroundAnalytics( + config: AnalyticsConfig | undefined, +): Analytics { + // User properties storage + const userIdStorage = + config?.userId ?? defineStorageItem('wxt-analytics:user-id'); + const userPropertiesStorage = + config?.userProperties ?? + defineStorageItem>( + 'wxt-analytics:user-properties', + {}, + ); + const enabled = + config?.enabled ?? + defineStorageItem('local:wxt-analytics:enabled', false); + + // Cached values + const platformInfo = chrome.runtime.getPlatformInfo(); + const userAgent = UAParser(); + let userId = Promise.resolve(userIdStorage.getValue()).then( + (id) => id ?? globalThis.crypto.randomUUID(), + ); + let userProperties = userPropertiesStorage.getValue(); + const manifest = chrome.runtime.getManifest(); + + const getBackgroundMeta = () => ({ + timestamp: Date.now(), + // Don't track sessions for the background, it can be running + // indefinitely, and will inflate session duration stats. + sessionId: undefined, + language: navigator.language, + referrer: undefined, + screen: undefined, + url: location.href, + title: undefined, + }); + + const getBaseEvent = async ( + meta: AnalyticsEventMetadata, + ): Promise => { + const platform = await platformInfo; + return { + meta, + user: { + id: await userId, + properties: { + version: config?.version ?? manifest.version_name ?? manifest.version, + wxtMode: import.meta.env.MODE, + wxtBrowser: import.meta.env.BROWSER, + arch: platform.arch, + os: platform.os, + browser: userAgent.browser.name, + browserVersion: userAgent.browser.version, + ...(await userProperties), + }, + }, + }; + }; + + const analytics = { + identify: async ( + newUserId: string, + newUserProperties: Record = {}, + meta: AnalyticsEventMetadata = getBackgroundMeta(), + ) => { + // Update in-memory cache for all providers + userId = Promise.resolve(newUserId); + userProperties = Promise.resolve(newUserProperties); + // Persist user info to storage + await Promise.all([ + userIdStorage.setValue?.(newUserId), + userPropertiesStorage.setValue?.(newUserProperties), + ]); + // Notify providers + const event = await getBaseEvent(meta); + if (config?.debug) console.debug('[@wxt-dev/analytics] identify', event); + if (await enabled.getValue()) { + await Promise.allSettled( + providers.map((provider) => provider.identify(event)), + ); + } else if (config?.debug) { + console.debug( + '[@wxt-dev/analytics] Analytics disabled, identify() not uploaded', + ); + } + }, + page: async ( + location: string, + meta: AnalyticsEventMetadata = getBackgroundMeta(), + ) => { + const baseEvent = await getBaseEvent(meta); + const event: AnalyticsPageViewEvent = { + ...baseEvent, + page: { + url: meta?.url ?? globalThis.location?.href, + location, + title: meta?.title ?? globalThis.document?.title, + }, + }; + if (config?.debug) console.debug('[@wxt-dev/analytics] page', event); + if (await enabled.getValue()) { + await Promise.allSettled( + providers.map((provider) => provider.page(event)), + ); + } else if (config?.debug) { + console.debug( + '[@wxt-dev/analytics] Analytics disabled, page() not uploaded', + ); + } + }, + track: async ( + eventName: string, + eventProperties?: Record, + meta: AnalyticsEventMetadata = getBackgroundMeta(), + ) => { + const baseEvent = await getBaseEvent(meta); + const event: AnalyticsTrackEvent = { + ...baseEvent, + event: { name: eventName, properties: eventProperties }, + }; + if (config?.debug) console.debug('[@wxt-dev/analytics] track', event); + if (await enabled.getValue()) { + await Promise.allSettled( + providers.map((provider) => provider.track(event)), + ); + } else if (config?.debug) { + console.debug( + '[@wxt-dev/analytics] Analytics disabled, track() not uploaded', + ); + } + }, + setEnabled: async (newEnabled) => { + await enabled.setValue?.(newEnabled); + }, + autoTrack: () => { + // Noop, background doesn't have a UI + return () => {}; + }, + } satisfies Analytics; + + const providers = + config?.providers?.map((provider) => provider(analytics, config)) ?? []; + + // Listen for messages from the rest of the extension + chrome.runtime.onConnect.addListener((port) => { + if (port.name === ANALYTICS_PORT) { + port.onMessage.addListener(({ fn, args }) => { + // @ts-expect-error: Untyped fn key + void analytics[fn]?.(...args); + }); + } + }); + + return analytics; +} + +/** + * Creates an analytics client for non-background contexts. + */ +function createFrontendAnalytics(): Analytics { + const port = chrome.runtime.connect({ name: ANALYTICS_PORT }); + const sessionId = Date.now(); + const getFrontendMetadata = (): AnalyticsEventMetadata => ({ + sessionId, + timestamp: Date.now(), + language: navigator.language, + referrer: globalThis.document?.referrer || undefined, + screen: globalThis.window + ? `${globalThis.window.screen.width}x${globalThis.window.screen.height}` + : undefined, + url: location.href, + title: document.title || undefined, + }); + + const methodForwarder = + (fn: string) => + (...args: any[]) => { + port.postMessage({ fn, args: [...args, getFrontendMetadata()] }); + }; + + const analytics: Analytics = { + identify: methodForwarder('identify'), + page: methodForwarder('page'), + track: methodForwarder('track'), + setEnabled: methodForwarder('setEnabled'), + autoTrack: (root) => { + const onClick = (event: Event) => { + const element = event.target as any; + if ( + !element || + (!INTERACTIVE_TAGS.has(element.tagName) && + !INTERACTIVE_ROLES.has(element.getAttribute('role'))) + ) + return; + + void analytics.track('click', { + tagName: element.tagName?.toLowerCase(), + id: element.id || undefined, + className: element.className || undefined, + textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length + href: element.href, + }); + }; + root.addEventListener('click', onClick, { capture: true, passive: true }); + return () => { + root.removeEventListener('click', onClick); + }; + }, + }; + return analytics; +} + +function defineStorageItem( + key: string, + defaultValue?: NonNullable, +): AnalyticsStorageItem { + return { + getValue: async () => + (await chrome.storage.local.get(key))[key] ?? defaultValue, + setValue: (newValue) => chrome.storage.local.set({ [key]: newValue }), + }; +} + +const INTERACTIVE_TAGS = new Set([ + 'A', + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', +]); +const INTERACTIVE_ROLES = new Set([ + 'button', + 'link', + 'checkbox', + 'menuitem', + 'tab', + 'radio', +]); + +export function defineAnalyticsProvider( + definition: ( + /** The analytics object. */ + analytics: Analytics, + /** Config passed into the analytics module from `app.config.ts`. */ + config: AnalyticsConfig, + /** Provider options */ + options: T, + ) => ReturnType, +): (options: T) => AnalyticsProvider { + return (options) => (analytics, config) => + definition(analytics, config, options); +} diff --git a/packages/analytics/modules/analytics/index.ts b/packages/analytics/modules/analytics/index.ts new file mode 100644 index 000000000..a6f76562e --- /dev/null +++ b/packages/analytics/modules/analytics/index.ts @@ -0,0 +1,91 @@ +import 'wxt'; +import 'wxt/sandbox'; +import { + addAlias, + addViteConfig, + addWxtPlugin, + defineWxtModule, +} from 'wxt/modules'; +import { relative, resolve } from 'node:path'; +import type { AnalyticsConfig } from './types'; + +declare module 'wxt/sandbox' { + export interface WxtAppConfig { + analytics: AnalyticsConfig; + } +} + +export default defineWxtModule({ + name: 'analytics', + imports: [{ name: 'analytics', from: '#analytics' }], + setup(wxt) { + // Paths + const wxtAnalyticsFolder = resolve(wxt.config.wxtDir, 'analytics'); + const wxtAnalyticsIndex = resolve(wxtAnalyticsFolder, 'index.ts'); + const clientModuleId = import.meta.env.NPM + ? '@wxt-dev/analytics' + : resolve(wxt.config.modulesDir, 'analytics/client'); + const pluginModuleId = import.meta.env.NPM + ? '@wxt-dev/analytics/background-plugin' + : resolve(wxt.config.modulesDir, 'analytics/background-plugin'); + + // Add required permissions + wxt.hook('build:manifestGenerated', (_, manifest) => { + manifest.permissions ??= []; + if (!manifest.permissions.includes('storage')) { + manifest.permissions.push('storage'); + } + }); + + // Generate #analytics module + const wxtAnalyticsCode = [ + `import { createAnalytics } from '${ + import.meta.env.NPM + ? clientModuleId + : relative(wxtAnalyticsFolder, clientModuleId) + }';`, + `import { useAppConfig } from 'wxt/client';`, + ``, + `export const analytics = createAnalytics(useAppConfig().analytics);`, + ``, + ].join('\n'); + addAlias(wxt, '#analytics', wxtAnalyticsIndex); + wxt.hook('prepare:types', async (_, entries) => { + entries.push({ + path: wxtAnalyticsIndex, + text: wxtAnalyticsCode, + }); + }); + + // Ensure there is a background entrypoint + wxt.hook('entrypoints:resolved', (_, entrypoints) => { + const hasBackground = entrypoints.find( + (entry) => entry.type === 'background', + ); + if (!hasBackground) { + entrypoints.push({ + type: 'background', + inputPath: 'virtual:user-background', + name: 'background', + options: {}, + outputDir: wxt.config.outDir, + skipped: false, + }); + } + }); + + // Ensure analytics is initialized in every context, mainly the background. + // TODO: Once there's a way to filter which entrypoints a plugin is applied to, only apply this to the background + addWxtPlugin(wxt, pluginModuleId); + + // Fix issues with dependencies + addViteConfig(wxt, () => ({ + optimizeDeps: { + // Ensure the "#analytics" import is processed by vite in the background plugin + exclude: ['@wxt-dev/analytics'], + // Ensure the CJS subdependency is preprocessed into ESM + include: ['@wxt-dev/analytics > ua-parser-js'], + }, + })); + }, +}); diff --git a/packages/analytics/modules/analytics/providers/google-analytics-4.ts b/packages/analytics/modules/analytics/providers/google-analytics-4.ts new file mode 100644 index 000000000..66931d7ad --- /dev/null +++ b/packages/analytics/modules/analytics/providers/google-analytics-4.ts @@ -0,0 +1,73 @@ +import { defineAnalyticsProvider } from '../client'; +import type { BaseAnalyticsEvent } from '../types'; + +const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100; + +export interface GoogleAnalytics4ProviderOptions { + apiSecret: string; + measurementId: string; +} + +export const googleAnalytics4 = + defineAnalyticsProvider( + (_, config, options) => { + const send = async ( + data: BaseAnalyticsEvent, + eventName: string, + eventProperties: Record | undefined, + ): Promise => { + const url = new URL( + config?.debug ? '/debug/mp/collect' : '/mp/collect', + 'https://www.google-analytics.com', + ); + if (options.apiSecret) + url.searchParams.set('api_secret', options.apiSecret); + if (options.measurementId) + url.searchParams.set('measurement_id', options.measurementId); + + const userProperties = { + language: data.meta.language, + screen: data.meta.screen, + ...data.user.properties, + }; + const mappedUserProperties = Object.fromEntries( + Object.entries(userProperties).map(([name, value]) => [ + name, + value == null ? undefined : { value }, + ]), + ); + + await fetch(url.href, { + method: 'POST', + body: JSON.stringify({ + client_id: data.user.id, + consent: { + ad_user_data: 'DENIED', + ad_personalization: 'DENIED', + }, + user_properties: mappedUserProperties, + events: [ + { + name: eventName, + params: { + session_id: data.meta.sessionId, + engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC, + ...eventProperties, + }, + }, + ], + }), + }); + }; + + return { + identify: () => Promise.resolve(), // No-op, user data uploaded in page/track + page: (event) => + send(event, 'page_view', { + page_title: event.page.title, + page_location: event.page.location, + }), + track: (event) => send(event, event.event.name, event.event.properties), + }; + }, + ); diff --git a/packages/analytics/modules/analytics/providers/umami.ts b/packages/analytics/modules/analytics/providers/umami.ts new file mode 100644 index 000000000..99a4e4914 --- /dev/null +++ b/packages/analytics/modules/analytics/providers/umami.ts @@ -0,0 +1,70 @@ +import { defineAnalyticsProvider } from '../client'; + +export interface UmamiProviderOptions { + apiUrl: string; + websiteId: string; + domain: string; +} + +export const umami = defineAnalyticsProvider( + (_, config, options) => { + const send = (payload: UmamiPayload) => { + if (config.debug) { + console.debug('[@wxt-dev/analytics] Sending event to Umami:', payload); + } + return fetch(`${options.apiUrl}/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ type: 'event', payload }), + }); + }; + + return { + identify: () => Promise.resolve(), // No-op, user data uploaded in page/track + page: async (event) => { + await send({ + name: 'page_view', + website: options.websiteId, + url: event.page.url, + hostname: options.domain, + language: event.meta.language ?? '', + referrer: event.meta.referrer ?? '', + screen: event.meta.screen ?? '', + title: event.page.title ?? '', + data: event.user.properties, + }); + }, + track: async (event) => { + await send({ + name: event.event.name, + website: options.websiteId, + url: event.meta.url ?? '/', + title: '', + hostname: options.domain, + language: event.meta.language ?? '', + referrer: event.meta.referrer ?? '', + screen: event.meta.screen ?? '', + data: { + ...event.event.properties, + ...event.user.properties, + }, + }); + }, + }; + }, +); + +/** @see https://umami.is/docs/api/sending-stats#post-/api/send */ +interface UmamiPayload { + hostname?: string; + language?: string; + referrer?: string; + screen?: string; + title?: string; + url?: string; + website: string; + name: string; + data?: Record; +} diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts new file mode 100644 index 000000000..d14d59f81 --- /dev/null +++ b/packages/analytics/modules/analytics/types.ts @@ -0,0 +1,99 @@ +export interface Analytics { + /** Report a page change. */ + page: (url: string) => void; + /** Report a custom event. */ + track: (eventName: string, eventProperties?: Record) => void; + /** Save information about the user. */ + identify: (userId: string, userProperties?: Record) => void; + /** Automatically setup and track user interactions, returning a function to remove any listeners that were setup. */ + autoTrack: (root: Document | ShadowRoot | Element) => () => void; + /** Calls `config.enabled.setValue`. */ + setEnabled: (enabled: boolean) => void; +} + +export interface AnalyticsConfig { + /** + * Array of providers to send analytics to. + */ + providers: AnalyticsProvider[]; + /** + * Enable debug logs and other provider-specific debugging features. + */ + debug?: boolean; + /** + * Your extension's version, reported alongside events. + * @default browser.runtime.getManifest().version`. + */ + version?: string; + /** + * Configure how the enabled flag is persisted. Defaults to using `browser.storage.local`. + */ + enabled?: AnalyticsStorageItem; + /** + * Configure how the user Id is persisted. Defaults to using `browser.storage.local`. + */ + userId?: AnalyticsStorageItem; + /** + * Configure how user properties are persisted. Defaults to using `browser.storage.local`. + */ + userProperties?: AnalyticsStorageItem>; +} + +export interface AnalyticsStorageItem { + getValue: () => T | Promise; + setValue?: (newValue: T) => void | Promise; +} + +export type AnalyticsProvider = ( + analytics: Analytics, + config: AnalyticsConfig, +) => { + /** Upload a page view event. */ + page: (event: AnalyticsPageViewEvent) => Promise; + /** Upload a custom event. */ + track: (event: AnalyticsTrackEvent) => Promise; + /** Upload information about the user. */ + identify: (event: BaseAnalyticsEvent) => Promise; +}; + +export interface BaseAnalyticsEvent { + meta: AnalyticsEventMetadata; + user: { + id: string; + properties: Record; + }; +} + +export interface AnalyticsEventMetadata { + /** Identifier of the session the event was fired from. */ + sessionId: number | undefined; + /** `Date.now()` of when the event was reported. */ + timestamp: number; + /** Ex: `"1920x1080"`. */ + screen: string | undefined; + /** `document.referrer` */ + referrer: string | undefined; + /** `navigator.language` */ + language: string | undefined; + /** `location.href` */ + url: string | undefined; + /** `document.title` */ + title: string | undefined; +} + +export interface AnalyticsPageInfo { + url: string; + title: string | undefined; + location: string | undefined; +} + +export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent { + page: AnalyticsPageInfo; +} + +export interface AnalyticsTrackEvent extends BaseAnalyticsEvent { + event: { + name: string; + properties?: Record; + }; +} diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 000000000..9c5ed5f82 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,65 @@ +{ + "name": "@wxt-dev/analytics", + "version": "0.4.1", + "description": "Add analytics to your web extension", + "repository": { + "type": "git", + "url": "git+https://github.com/wxt-dev/wxt.git", + "directory": "packages/analytics" + }, + "license": "MIT", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./module": { + "types": "./dist/module.d.mts", + "default": "./dist/module.mjs" + }, + "./background-plugin": { + "types": "./dist/background-plugin.d.mts", + "default": "./dist/background-plugin.mjs" + }, + "./types": { + "types": "./dist/types.d.mts" + }, + "./providers/google-analytics-4": { + "types": "./dist/providers/google-analytics-4.d.mts", + "default": "./dist/providers/google-analytics-4.mjs" + }, + "./providers/umami": { + "types": "./dist/providers/umami.d.mts", + "default": "./dist/providers/umami.mjs" + } + }, + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "dev": "buildc --deps-only -- wxt", + "dev:build": "buildc --deps-only -- wxt build", + "check": "pnpm build && check", + "build": "buildc -- unbuild", + "prepack": "pnpm -s build", + "prepare": "buildc --deps-only -- wxt prepare" + }, + "peerDependencies": { + "wxt": ">=0.19.23" + }, + "devDependencies": { + "@aklinker1/check": "catalog:", + "@types/chrome": "catalog:", + "@types/ua-parser-js": "catalog:", + "publint": "catalog:", + "typescript": "catalog:", + "unbuild": "catalog:", + "wxt": "workspace:*" + }, + "dependencies": { + "ua-parser-js": "catalog:" + } +} diff --git a/packages/analytics/public/.keep b/packages/analytics/public/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 000000000..9d173da1c --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": ["../../tsconfig.base.json", "./.wxt/tsconfig.json"], + "compilerOptions": { + "paths": { + "#analytics": ["./.wxt/analytics/index.ts"] + }, + "types": ["chrome"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/packages/analytics/wxt.config.ts b/packages/analytics/wxt.config.ts new file mode 100644 index 000000000..f4b6f665b --- /dev/null +++ b/packages/analytics/wxt.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'wxt'; + +export default defineConfig({ + // Unimport doesn't look for imports in node_modules, so when developing a + // WXT module, we need to disable this to simplify the build process + imports: false, + + manifest: { + name: 'Analytics Demo', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e02f1fce..99b32c95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: '@types/react-dom': specifier: ^19.0.2 version: 19.0.2 + '@types/ua-parser-js': + specifier: ^0.7.39 + version: 0.7.39 '@types/webextension-polyfill': specifier: ^0.12.1 version: 0.12.1 @@ -249,6 +252,9 @@ catalogs: typescript: specifier: ^5.6.3 version: 5.6.3 + ua-parser-js: + specifier: ^1.0.40 + version: 1.0.40 unbuild: specifier: ^3.5.0 version: 3.5.0 @@ -380,6 +386,34 @@ importers: specifier: workspace:* version: link:packages/wxt + packages/analytics: + dependencies: + ua-parser-js: + specifier: 'catalog:' + version: 1.0.40 + devDependencies: + '@aklinker1/check': + specifier: 'catalog:' + version: 1.4.5(typescript@5.6.3) + '@types/chrome': + specifier: 'catalog:' + version: 0.0.280 + '@types/ua-parser-js': + specifier: 'catalog:' + version: 0.7.39 + publint: + specifier: 'catalog:' + version: 0.2.12 + typescript: + specifier: 'catalog:' + version: 5.6.3 + unbuild: + specifier: 'catalog:' + version: 3.5.0(sass@1.80.7)(typescript@5.6.3)(vue@3.5.13(typescript@5.6.3)) + wxt: + specifier: workspace:* + version: link:../wxt + packages/auto-icons: dependencies: defu: @@ -2249,6 +2283,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/ua-parser-js@0.7.39': + resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/unist@3.0.2': resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} @@ -3383,6 +3420,7 @@ packages: glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -5043,6 +5081,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@1.0.40: + resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} + hasBin: true + ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} @@ -6308,7 +6350,7 @@ snapshots: '@rollup/pluginutils': 5.1.4(rollup@4.34.9) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.2(picomatch@4.0.2) + fdir: 6.4.3(picomatch@4.0.2) is-reference: 1.2.1 magic-string: 0.30.17 picomatch: 4.0.2 @@ -6635,6 +6677,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/ua-parser-js@0.7.39': {} + '@types/unist@3.0.2': {} '@types/web-bluetooth@0.0.20': {} @@ -7350,7 +7394,7 @@ snapshots: citty@0.1.6: dependencies: - consola: 3.2.3 + consola: 3.4.0 cli-boxes@3.0.0: {} @@ -9715,6 +9759,8 @@ snapshots: typescript@5.6.3: {} + ua-parser-js@1.0.40: {} + ufo@1.5.3: {} ufo@1.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6f91b2e21..af0ba87aa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,7 @@ catalog: '@types/prompts': ^2.4.9 '@types/react': ^19.0.1 '@types/react-dom': ^19.0.2 + '@types/ua-parser-js': ^0.7.39 '@types/webextension-polyfill': ^0.12.1 '@vitejs/plugin-react': ^4.3.4 '@vitejs/plugin-vue': ^5.2.1 @@ -90,6 +91,7 @@ catalog: typedoc-plugin-markdown: 4.0.0-next.23 typedoc-vitepress-theme: 1.0.0-next.3 typescript: ^5.6.3 + ua-parser-js: ^1.0.40 unbuild: ^3.5.0 unimport: ^3.13.1 unocss: ^0.64.0 || ^0.65.0 || ^65.0.0 ||^66.0.0