diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/analytics.ts similarity index 79% rename from packages/analytics/modules/analytics/client.ts rename to packages/analytics/modules/analytics/analytics.ts index 538d05bd..f51b6551 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/analytics.ts @@ -1,94 +1,33 @@ -import { defineWxtPlugin } from 'wxt/sandbox'; -import { useAppConfig } from 'wxt/client'; -import { storage } from 'wxt/storage'; -import { +import { UAParser } from 'ua-parser-js'; +import type { Analytics, AnalyticsConfig, AnalyticsPageViewEvent, + AnalyticsStorageItem, AnalyticsTrackEvent, BaseAnalyticsEvent, } from './types'; -import uaParser from 'ua-parser-js'; -export let analytics: Analytics; -const ANALYTICS_PORT = 'wxt-analytics'; -const interactiveTags = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']); -const interactiveRoles = new Set([ - 'button', - 'link', - 'checkbox', - 'menuitem', - 'tab', - 'radio', -]); +const ANALYTICS_PORT = '@wxt-dev/analytics'; -export default defineWxtPlugin(() => { - const isBackground = globalThis.window == null; // TODO: Support MV2 - analytics = isBackground - ? createBackgroundAnalytics() - : createAnalyticsForwarder(); -}); +export function createAnalytics(config: AnalyticsConfig): Analytics { + if (globalThis.chrome?.runtime?.id) + throw Error( + 'Cannot use WXT analytics in contexts without access to the browser.runtime APIs', + ); -function createAnalyticsForwarder(): Analytics { - const port = chrome.runtime.connect({ name: ANALYTICS_PORT }); - const sessionId = Date.now(); - const getMetadata = (): ForwardMetadata => ({ - 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, - }); + // 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); - const methodForwarder = - (fn: string) => - (...args: any[]) => - port.postMessage({ fn, args: [...args, getMetadata()] }); - - 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 || - (!interactiveTags.has(element.tagName) && - !interactiveRoles.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; + return createFrontendAnalytics(); } /** - * Background analytics is responsible for reporting events triggered in the - * background, and for reporting forwarded events from the other contexts. + * Creates an analytics client in the background responsible for uploading events to the server to avoid CORS errors. */ -function createBackgroundAnalytics(): Analytics { - const config = (useAppConfig() as any).analytics as - | AnalyticsConfig - | undefined; - +function createBackgroundAnalytics(config: AnalyticsConfig): Analytics { // User properties storage const userIdStorage = config?.userId ?? defineStorageItem('local:wxt-analytics:user-id'); @@ -104,7 +43,7 @@ function createBackgroundAnalytics(): Analytics { // Cached values const platformInfo = chrome.runtime.getPlatformInfo(); - const userAgent = uaParser(); + const userAgent = UAParser(); let userId = Promise.resolve(userIdStorage.getValue()).then( (id) => id ?? globalThis.crypto.randomUUID(), ); @@ -131,6 +70,7 @@ function createBackgroundAnalytics(): Analytics { screen: meta.screen, referrer: meta.referrer, language: meta.language, + url: meta.url, }, user: { id: await userId, @@ -177,7 +117,11 @@ function createBackgroundAnalytics(): Analytics { const baseEvent = await getBaseEvent(forwardMeta); const event: AnalyticsPageViewEvent = { ...baseEvent, - page: { url }, + page: { + url, + location: globalThis.location?.href, + title: globalThis.document?.title, + }, }; if (config?.debug) console.debug('[analytics] page', event); if (await enabled.getValue()) { @@ -232,6 +176,60 @@ function createBackgroundAnalytics(): Analytics { 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 getMetadata = (): ForwardMetadata => ({ + 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, + }); + + const methodForwarder = + (fn: string) => + (...args: any[]) => + port.postMessage({ fn, args: [...args, getMetadata()] }); + + 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; +} + interface ForwardMetadata { sessionId: number | undefined; timestamp: number; @@ -240,3 +238,30 @@ interface ForwardMetadata { referrer: string | undefined; url: string | undefined; } + +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', +]); diff --git a/packages/analytics/modules/analytics/index.ts b/packages/analytics/modules/analytics/index.ts index 0e0a5d86..1256fc98 100644 --- a/packages/analytics/modules/analytics/index.ts +++ b/packages/analytics/modules/analytics/index.ts @@ -1,9 +1,8 @@ import 'wxt'; import 'wxt/sandbox'; -import { addWxtPlugin, defineWxtModule } from 'wxt/modules'; -import { resolve, dirname } from 'node:path'; -import { AnalyticsConfig } from './types'; -import { fileURLToPath } from 'url'; +import { addAlias, defineWxtModule } from 'wxt/modules'; +import { resolve } from 'node:path'; +import type { AnalyticsConfig } from './types'; declare module 'wxt/sandbox' { export interface WxtAppConfig { @@ -11,27 +10,34 @@ declare module 'wxt/sandbox' { } } -const _dirname = dirname(fileURLToPath(import.meta.url)); - -const pluginId = process.env.NPM - ? '@wxt-dev/analytics/client' - : resolve(_dirname, 'client.ts'); - export default defineWxtModule({ name: 'analytics', - imports: [{ name: 'analytics', from: pluginId }], + imports: [{ name: 'analytics', from: '#analytics' }], setup(wxt) { - // Add a plugin - addWxtPlugin( - wxt, - resolve(_dirname, process.env.NPM ? 'client.mjs' : 'client.ts'), - ); - - wxt.hooks.hook('build:manifestGenerated', (_, manifest) => { + // Add required permissions + wxt.hook('build:manifestGenerated', (_, manifest) => { manifest.permissions ??= []; if (!manifest.permissions.includes('storage')) { manifest.permissions.push('storage'); } }); + + // Generate #analytics module + const analyticsModulePath = resolve( + wxt.config.wxtDir, + 'analytics/index.ts', + ); + const analyticsModuleCode = ` + import { createAnalytics } from '@wxt-dev/analytics/client'; + + export const analytics = createAnalytics(useAppConfig().analytics); + `; + addAlias(wxt, '#analytics', analyticsModulePath); + wxt.hook('prepare:types', async (_, entries) => { + entries.push({ + path: analyticsModulePath, + text: analyticsModuleCode, + }); + }); }, }); diff --git a/packages/analytics/modules/analytics/plugin.ts b/packages/analytics/modules/analytics/plugin.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts index ef578133..13f57b49 100644 --- a/packages/analytics/modules/analytics/types.ts +++ b/packages/analytics/modules/analytics/types.ts @@ -27,18 +27,18 @@ export interface AnalyticsConfig { /** * Configure how the enabled flag is persisted */ - enabled?: AnalyticsStorageItemConfig; + enabled?: AnalyticsStorageItem; /** * Configure how the user Id is persisted */ - userId?: AnalyticsStorageItemConfig; + userId?: AnalyticsStorageItem; /** * Configure how user properties are persisted */ - userProperties?: AnalyticsStorageItemConfig>; + userProperties?: AnalyticsStorageItem>; } -export interface AnalyticsStorageItemConfig { +export interface AnalyticsStorageItem { getValue: () => T | Promise; setValue?: (newValue: T) => void | Promise; } @@ -62,13 +62,13 @@ export interface BaseAnalyticsEvent { /** `Date.now()` of when the event was reported */ timestamp: number; /** `"1920x1080"` */ - screen?: string; + screen: string | undefined; /** `document.referrer` */ - referrer?: string; + referrer: string | undefined; /** `navigator.language` */ - language?: string; + language: string | undefined; /** `location.href` */ - url?: string; + url: string | undefined; }; user: { id: string; @@ -78,8 +78,8 @@ export interface BaseAnalyticsEvent { export interface AnalyticsPageInfo { url: string; - title?: string; - location?: string; + title: string | undefined; + location: string | undefined; } export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent { diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json index 47d6ba41..4c52c24a 100644 --- a/packages/analytics/tsconfig.json +++ b/packages/analytics/tsconfig.json @@ -1,6 +1,8 @@ { "extends": ["../../tsconfig.base.json", "./.wxt/tsconfig.json"], "compilerOptions": { + "paths": {}, "types": ["chrome"] - } + }, + "exclude": ["node_modules", "dist"] }