diff --git a/packages/analytics/README.md b/packages/analytics/README.md index 3c4e81ad9..cf297ad36 100644 --- a/packages/analytics/README.md +++ b/packages/analytics/README.md @@ -8,6 +8,7 @@ Report analytics events from your web extension extension. - [Moderok](#moderok) - [PostHog](#posthog) - [Umami](#umami) +- [extport](#extport) ## Install With WXT @@ -217,6 +218,29 @@ export default defineAppConfig({ }); ``` +### extport + +[extport](https://extport.dev) is an open, self-hostable platform for browser extension developers — publishing, licensing, and analytics across Chrome, Firefox, Edge, and Safari. Its analytics protocol is a single anonymous daily ping per install: actives, installs, and churn are all derived server-side, and there are no custom events by design (pair another provider like PostHog or Umami if you need event tracking). + +Create an extension at [dash.extport.dev](https://dash.extport.dev) to get its `ext_…` id, then add the provider to your `/app.config.ts`: + +```ts +// /app.config.ts +import { extport } from '@wxt-dev/analytics/providers/extport'; + +export default defineAppConfig({ + analytics: { + providers: [ + extport({ + extensionId: 'ext_...', + }), + ], + }, +}); +``` + +The provider automatically respects Firefox's built-in data-collection consent (`technicalAndInteraction`) — no extra wiring needed. Self-hosted instances can point elsewhere with the `apiBase` option. + ### Custom Provider If your analytics platform is not supported, you can provide an implementation of the `AnalyticsProvider` type in your `app.config.ts` instead: diff --git a/packages/analytics/modules/analytics/providers/extport.ts b/packages/analytics/modules/analytics/providers/extport.ts new file mode 100644 index 000000000..0ef01be1e --- /dev/null +++ b/packages/analytics/modules/analytics/providers/extport.ts @@ -0,0 +1,126 @@ +import { defineAnalyticsProvider } from '../client'; +import { browser } from '@wxt-dev/browser'; +import type { BaseAnalyticsEvent } from '../types'; + +const DEFAULT_API_BASE = 'https://api.extport.dev'; +const PING_STORAGE_KEY = 'extport:last-ping-date'; + +/** + * Internal event name used to route the daily ping through + * `analytics.track()`, so the module's consent gate (`enabled`) applies to + * it like any other event. + */ +const PING_EVENT = '__extport_ping'; + +export interface ExtportProviderOptions { + /** The extension's extport id (`ext_…`), from https://dash.extport.dev. */ + extensionId: string; + /** Override for self-hosted extport instances. */ + apiBase?: string; +} + +/** + * Firefox 140+ has a built-in data-collection consent mechanism: when + * `permissions.getAll()` reports a `data_collection` array, + * `technicalAndInteraction` must be present in it (the toggle shown in the + * install prompt and in about:addons). When the key is absent the browser + * has no such mechanism and the manifest disclosure governs. Read at ping + * time so revocation needs no listener — the next ping re-checks. + */ +async function browserConsentsToAnalytics(): Promise { + try { + const all = (await browser.permissions.getAll()) as { + data_collection?: string[]; + }; + return ( + all.data_collection === undefined || + all.data_collection.includes('technicalAndInteraction') + ); + } catch { + // Only environments without the permissions API get here — same + // treatment as the key being absent. + return true; + } +} + +/** + * [extport](https://extport.dev)'s analytics protocol has exactly one + * event: an anonymous daily ping per install. Installs, actives, and churn + * are all derived from the ping stream server-side, so this provider + * reports nothing else — custom events are dropped (pair another provider + * like PostHog or Umami if you need event tracking). + */ +export const extport = defineAnalyticsProvider( + (analytics, config, options) => { + const apiBase = options.apiBase ?? DEFAULT_API_BASE; + const debug = config.debug ?? false; + let inflight: Promise | undefined; + + const ping = async (event: BaseAnalyticsEvent) => { + const today = new Date().toISOString().slice(0, 10); + const stored = await browser.storage.local.get(PING_STORAGE_KEY); + if (stored[PING_STORAGE_KEY] === today) return; + if (!(await browserConsentsToAnalytics())) return; + + const payload = { + installId: event.user.id, + extensionId: options.extensionId, + version: event.user.properties.version ?? '0.0.0', + language: event.meta.language, + }; + if (debug) { + console.debug('[@wxt-dev/analytics][extport] Sending:', payload); + } + const response = await fetch(`${apiBase}/api/v1/analytics/ping`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + // Only stamp the day on confirmed delivery — a failed send retries on + // the next background wake-up. The server is idempotent per install + // per UTC day, so a retry can never double-count. + if (response.ok) { + await browser.storage.local.set({ [PING_STORAGE_KEY]: today }); + } + }; + + const maybePing = (event: BaseAnalyticsEvent) => { + inflight ??= ping(event) + .catch((error) => { + if (debug) { + console.debug('[@wxt-dev/analytics][extport] Ping failed:', error); + } + }) + .finally(() => { + inflight = undefined; + }); + return inflight; + }; + + // The provider is initialized from the background, so initialization + // itself marks a background wake-up — the natural moment for a daily + // ping. onInstalled makes install day exact instead of waiting for the + // next wake-up, and permissions.onAdded catches a Firefox user turning + // the data-collection toggle on later the same day. + browser.runtime.onInstalled.addListener(() => { + void analytics.track(PING_EVENT); + }); + browser.permissions.onAdded?.addListener(() => { + void analytics.track(PING_EVENT); + }); + void analytics.track(PING_EVENT); + + return { + identify: () => Promise.resolve(), + page: () => Promise.resolve(), + track: async (event) => { + if (event.event.name === PING_EVENT) return maybePing(event); + if (debug) { + console.debug( + `[@wxt-dev/analytics][extport] No custom events in the extport protocol — "${event.event.name}" dropped`, + ); + } + }, + }; + }, +); diff --git a/packages/analytics/package.json b/packages/analytics/package.json index ffe7a7bfb..049f9a6e4 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -66,6 +66,10 @@ "./providers/posthog": { "types": "./dist/providers/posthog.d.mts", "default": "./dist/providers/posthog.mjs" + }, + "./providers/extport": { + "types": "./dist/providers/extport.d.mts", + "default": "./dist/providers/extport.mjs" } }, "module": "./dist/index.mjs", diff --git a/packages/analytics/tsdown.config.ts b/packages/analytics/tsdown.config.ts index 4b2a4e62c..2a817d9b7 100644 --- a/packages/analytics/tsdown.config.ts +++ b/packages/analytics/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'providers/umami': './modules/analytics/providers/umami.ts', 'providers/moderok': './modules/analytics/providers/moderok.ts', 'providers/posthog': './modules/analytics/providers/posthog.ts', + 'providers/extport': './modules/analytics/providers/extport.ts', }, deps: { neverBundle: ['#analytics'],