305 lines
9.0 KiB
TypeScript
305 lines
9.0 KiB
TypeScript
import { UAParser } from 'ua-parser-js';
|
|
import type {
|
|
Analytics,
|
|
AnalyticsConfig,
|
|
AnalyticsEventMetadata,
|
|
AnalyticsPageViewEvent,
|
|
AnalyticsProvider,
|
|
AnalyticsStorageItem,
|
|
AnalyticsTrackEvent,
|
|
BaseAnalyticsEvent,
|
|
} from './types';
|
|
import { browser } from '@wxt-dev/browser';
|
|
import { isBackground } from '@wxt-dev/is-background';
|
|
|
|
type AnalyticsMessage = {
|
|
[K in keyof Analytics]: {
|
|
fn: K;
|
|
args: Parameters<Analytics[K]>;
|
|
};
|
|
}[keyof Analytics];
|
|
|
|
type AnalyticsMethod =
|
|
| ((...args: Parameters<Analytics[keyof Analytics]>) => void)
|
|
| undefined;
|
|
|
|
type MethodForwarder = <K extends keyof Analytics>(
|
|
fn: K,
|
|
) => (...args: Parameters<Analytics[K]>) => void;
|
|
|
|
const ANALYTICS_PORT = '@wxt-dev/analytics';
|
|
|
|
const INTERACTIVE_TAGS = new Set([
|
|
'A',
|
|
'BUTTON',
|
|
'INPUT',
|
|
'SELECT',
|
|
'TEXTAREA',
|
|
]);
|
|
const INTERACTIVE_ROLES = new Set([
|
|
'button',
|
|
'link',
|
|
'checkbox',
|
|
'menuitem',
|
|
'tab',
|
|
'radio',
|
|
]);
|
|
|
|
export function createAnalytics(config?: AnalyticsConfig): Analytics {
|
|
if (!browser?.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 '<srcDir>/app.config.ts'.",
|
|
);
|
|
}
|
|
|
|
if (isBackground()) 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<string>('wxt-analytics:user-id');
|
|
const userPropertiesStorage =
|
|
config?.userProperties ??
|
|
defineStorageItem<Record<string, string>>(
|
|
'wxt-analytics:user-properties',
|
|
{},
|
|
);
|
|
const enabled =
|
|
config?.enabled ??
|
|
defineStorageItem<boolean>('local:wxt-analytics:enabled', false);
|
|
|
|
// Cached values
|
|
const platformInfo = browser.runtime.getPlatformInfo();
|
|
const userAgent = UAParser();
|
|
let userId = Promise.resolve(userIdStorage.getValue()).then(
|
|
(id) => id ?? globalThis.crypto.randomUUID(),
|
|
);
|
|
let userProperties = userPropertiesStorage.getValue();
|
|
const manifest = browser.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<BaseAnalyticsEvent> => {
|
|
const { arch, os } = 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,
|
|
os,
|
|
browser: userAgent.browser.name,
|
|
browserVersion: userAgent.browser.version,
|
|
...(await userProperties),
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
const analytics = {
|
|
identify: async (
|
|
newUserId: string,
|
|
newUserProperties: Record<string, string> = {},
|
|
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<string, string | undefined>,
|
|
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
|
|
browser.runtime.onConnect.addListener((port) => {
|
|
if (port.name === ANALYTICS_PORT) {
|
|
port.onMessage.addListener(({ fn, args }: AnalyticsMessage) => {
|
|
void (analytics[fn] as AnalyticsMethod)?.(...args);
|
|
});
|
|
}
|
|
});
|
|
|
|
return analytics;
|
|
}
|
|
|
|
/** Creates an analytics client for non-background contexts. */
|
|
function createFrontendAnalytics(): Analytics {
|
|
const port = browser.runtime.connect({ name: ANALYTICS_PORT });
|
|
const sessionId = Date.now();
|
|
const getFrontendMetadata = (): AnalyticsEventMetadata => ({
|
|
sessionId,
|
|
timestamp: Date.now(),
|
|
language: navigator.language,
|
|
referrer: document.referrer || undefined,
|
|
screen: `${window.screen.width}x${window.screen.height}`,
|
|
url: location.href,
|
|
title: document.title || undefined,
|
|
});
|
|
|
|
const methodForwarder: MethodForwarder =
|
|
(fn) =>
|
|
(...args) => {
|
|
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 HTMLElement | null;
|
|
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 as HTMLAnchorElement).href,
|
|
});
|
|
};
|
|
root.addEventListener('click', onClick, { capture: true, passive: true });
|
|
return () => {
|
|
root.removeEventListener('click', onClick);
|
|
};
|
|
},
|
|
};
|
|
return analytics;
|
|
}
|
|
|
|
function defineStorageItem<T>(key: string): AnalyticsStorageItem<T | undefined>;
|
|
function defineStorageItem<T>(
|
|
key: string,
|
|
defaultValue: T,
|
|
): AnalyticsStorageItem<T>;
|
|
function defineStorageItem(
|
|
key: string,
|
|
defaultValue?: unknown,
|
|
): AnalyticsStorageItem<unknown> {
|
|
return {
|
|
getValue: async () =>
|
|
(await browser.storage.local.get<Record<string, unknown>>(key))[key] ??
|
|
defaultValue,
|
|
setValue: (newValue) => browser.storage.local.set({ [key]: newValue }),
|
|
};
|
|
}
|
|
|
|
export function defineAnalyticsProvider<T = never>(
|
|
definition: (
|
|
/** The analytics object. */
|
|
analytics: Analytics,
|
|
/** Config passed into the analytics module from `app.config.ts`. */
|
|
config: AnalyticsConfig,
|
|
/** Provider options */
|
|
options: T,
|
|
) => ReturnType<AnalyticsProvider>,
|
|
): (options: T) => AnalyticsProvider {
|
|
return (options) => (analytics, config) =>
|
|
definition(analytics, config, options);
|
|
}
|