From 93010c751a37589b91dde8d31f4c97e7f030f9de Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:45:13 +0530 Subject: [PATCH 1/2] feat(popup): set up PostHog for events data collection --- cspell-dictionary.txt | 3 + esbuild/dev.ts | 4 ++ esbuild/prod.ts | 7 +++ package.json | 1 + pnpm-lock.yaml | 47 +++++++++++++-- src/pages/popup/lib/context.tsx | 19 +++++- src/pages/shared/lib/context.tsx | 100 +++++++++++++++++++++++++++++++ src/shared/defines.ts | 5 ++ src/shared/types.ts | 11 +++- 9 files changed, 188 insertions(+), 9 deletions(-) diff --git a/cspell-dictionary.txt b/cspell-dictionary.txt index 047874471..4bf565f0e 100644 --- a/cspell-dictionary.txt +++ b/cspell-dictionary.txt @@ -5,6 +5,7 @@ Rafiki Chimoney GateHub MMAON +PostHog SPSP webextension @@ -22,6 +23,8 @@ webmonetization jwks requestfinished TOTP +autocapture +pageleave # scripts and 3rd party terms nvmrc diff --git a/esbuild/dev.ts b/esbuild/dev.ts index faa3457ab..7776c467c 100644 --- a/esbuild/dev.ts +++ b/esbuild/dev.ts @@ -32,6 +32,10 @@ export const getDevOptions = ({ CONFIG_LOG_SERVER_ENDPOINT: process.env.LOG_SERVER ? JSON.stringify(process.env.LOG_SERVER) : JSON.stringify(false), + CONFIG_POSTHOG_KEY: JSON.stringify(process.env.POSTHOG_KEY || ''), + CONFIG_POSTHOG_HOST: JSON.stringify( + process.env.POSTHOG_HOST || 'https://us.i.posthog.com', + ), }, }; }; diff --git a/esbuild/prod.ts b/esbuild/prod.ts index 86fad3feb..9cfcbc577 100644 --- a/esbuild/prod.ts +++ b/esbuild/prod.ts @@ -41,6 +41,13 @@ export const getProdOptions = ({ 'https://webmonetization.org/welcome', ), CONFIG_LOG_SERVER_ENDPOINT: JSON.stringify(false), + CONFIG_POSTHOG_KEY: JSON.stringify( + process.env.POSTHOG_KEY || + 'phc_A42pTzb0ySkVYmNBSSfsr3K8BOyyuhR7g8l8hUdd6cv', + ), + CONFIG_POSTHOG_HOST: JSON.stringify( + process.env.POSTHOG_HOST || 'https://eu.i.posthog.com', + ), }, }; }; diff --git a/package.json b/package.json index f3447d152..ee9cf693c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "httpbis-digest-headers": "^1.0.0", "iso8601-duration": "^2.1.3", "loglevel": "^1.9.2", + "posthog-js": "^1.298.0", "react": "^19.2.0", "react-dom": "^19.2.0", "safe-buffer": "5.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e539d7c9..4cc5324c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: loglevel: specifier: ^1.9.2 version: 1.9.2 + posthog-js: + specifier: ^1.298.0 + version: 1.298.0 react: specifier: ^19.2.0 version: 19.2.0 @@ -934,6 +937,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@posthog/core@1.6.0': + resolution: {integrity: sha512-Tbh8UACwbb7jFdDC7wwXHtfNzO+4wKh3VbyMHmp2UBe6w1jliJixexTJNfkqdGZm+ht3M10mcKvGGPnoZ2zLBg==} + '@preact/signals-core@1.8.0': resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} @@ -1777,6 +1783,9 @@ packages: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} engines: {node: '>= 0.8'} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1801,8 +1810,8 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} crypto-browserify@3.12.1: @@ -2033,6 +2042,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fflate@0.4.8: + resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2512,6 +2524,7 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} @@ -2933,6 +2946,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + posthog-js@1.298.0: + resolution: {integrity: sha512-Zwzsf7TO8qJ6DFLuUlQSsT/5OIOcxSBZlKOSk3satkEnwKdmnBXUuxgVXRHrvq1kj7OB2PVAPgZiQ8iHHj9DRA==} + preact@10.25.4: resolution: {integrity: sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==} @@ -3486,6 +3502,9 @@ packages: walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + web-vitals@4.2.4: + resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==} + webextension-polyfill@0.12.0: resolution: {integrity: sha512-97TBmpoWJEE+3nFBQ4VocyCdLKfw54rFaJ6EVQYLBCXqCIpLSZkwGgASpv4oPt9gdKCJ80RJlcmNzNn008Ag6Q==} @@ -4440,6 +4459,10 @@ snapshots: dependencies: playwright: 1.56.1 + '@posthog/core@1.6.0': + dependencies: + cross-spawn: 7.0.6 + '@preact/signals-core@1.8.0': {} '@preact/signals@1.3.1(preact@10.25.4)': @@ -5258,6 +5281,8 @@ snapshots: depd: 2.0.0 keygrip: 1.1.0 + core-js@3.47.0: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -5292,7 +5317,7 @@ snapshots: create-require@1.1.1: optional: true - cross-spawn@7.0.3: + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 @@ -5507,7 +5532,7 @@ snapshots: execa@5.1.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 6.0.1 human-signals: 2.1.0 is-stream: 2.0.1 @@ -5552,6 +5577,8 @@ snapshots: dependencies: bser: 2.1.1 + fflate@0.4.8: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5563,7 +5590,7 @@ snapshots: foreground-child@3.1.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 signal-exit: 4.1.0 fraction.js@4.3.7: {} @@ -6662,6 +6689,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-js@1.298.0: + dependencies: + '@posthog/core': 1.6.0 + core-js: 3.47.0 + fflate: 0.4.8 + preact: 10.25.4 + web-vitals: 4.2.4 + preact@10.25.4: {} prettier@3.6.2: {} @@ -7247,6 +7282,8 @@ snapshots: dependencies: makeerror: 1.0.12 + web-vitals@4.2.4: {} + webextension-polyfill@0.12.0: {} webidl-conversions@7.0.0: {} diff --git a/src/pages/popup/lib/context.tsx b/src/pages/popup/lib/context.tsx index 28e918d81..6c8d2a5d4 100644 --- a/src/pages/popup/lib/context.tsx +++ b/src/pages/popup/lib/context.tsx @@ -5,7 +5,10 @@ import { type PopupToBackgroundMessage, type BackgroundToPopupMessage, } from '@/shared/messages'; -import { useBrowser } from '@/pages/shared/lib/context'; +import { + TelemetryContextProvider, + useBrowser, +} from '@/pages/shared/lib/context'; import { dispatch } from './store'; export { useBrowser, useTranslation } from '@/pages/shared/lib/context'; @@ -13,13 +16,19 @@ export { useBrowser, useTranslation } from '@/pages/shared/lib/context'; export function WaitForStateLoad({ children }: React.PropsWithChildren) { const message = useMessage(); const [isLoading, setIsLoading] = React.useState(true); + const [telemetryConfig, setTelemetryConfig] = React.useState<{ + uid: string; + isOptedIn?: boolean; + }>({ uid: '' }); React.useEffect(() => { async function get() { const response = await message.send('GET_DATA_POPUP'); if (response.success) { - dispatch({ type: 'SET_DATA_POPUP', data: response.payload }); + const data = response.payload; + dispatch({ type: 'SET_DATA_POPUP', data }); + setTelemetryConfig({ uid: data.uid, isOptedIn: data.consentTelemetry }); setIsLoading(false); } } @@ -31,7 +40,11 @@ export function WaitForStateLoad({ children }: React.PropsWithChildren) { return 'Loading'; } - return <>{children}; + return ( + + {children} + + ); } const MessageContext = React.createContext< diff --git a/src/pages/shared/lib/context.tsx b/src/pages/shared/lib/context.tsx index 346bd94da..6129fceb0 100644 --- a/src/pages/shared/lib/context.tsx +++ b/src/pages/shared/lib/context.tsx @@ -1,10 +1,13 @@ import React, { type PropsWithChildren } from 'react'; +import { PostHog } from 'posthog-js/dist/module.no-external'; +import { POSTHOG_KEY, POSTHOG_HOST } from '@/shared/defines'; import type { Browser } from 'webextension-polyfill'; import { tFactory, type ErrorWithKeyLike, type Translation, } from '@/shared/helpers'; +import type { Storage } from '@/shared/types'; // #region Browser const BrowserContext = React.createContext({} as Browser); @@ -41,3 +44,100 @@ export const TranslationContextProvider = ({ children }: PropsWithChildren) => { ); }; // #endregion + +// #region Telemetry + +// Reduce dependency on full PostHog SDK as we only wish to use a few things. +type Telemetry = { + capture: PostHog['capture']; + captureException: PostHog['captureException']; + optInOut: (isOptedIn: boolean) => void; +}; + +const mockTelemetry: Telemetry = { + capture: () => void 0, + captureException: () => void 0, + optInOut: () => {}, +}; +const TelemetryContext = React.createContext(mockTelemetry); + +const setupPosthog = (distinctId: string, isOptedIn: boolean) => { + return new PostHog().init(POSTHOG_KEY, { + api_host: POSTHOG_HOST, + opt_out_capturing_by_default: !isOptedIn, + opt_out_persistence_by_default: true, + autocapture: true, + capture_pageview: 'history_change', + capture_pageleave: 'if_capture_pageview', + persistence: 'localStorage', + bootstrap: { + distinctID: distinctId, + // Prevent fetching feature flags. + featureFlags: {}, + featureFlagPayloads: {}, + // We don't identify users, so marks as identified to avoid API requests. + isIdentifiedID: true, + }, + before_send(event) { + if (!event) return null; + // We use hash-based routing, so ensure hashes are tracked. + if (event.properties?.$current_url) { + const parsed = new URL(event.properties.$current_url); + if (parsed.hash) { + event.properties.$pathname = parsed.pathname + parsed.hash; + } + } + return event; + }, + disable_external_dependency_loading: true, + disable_session_recording: true, + disable_surveys: true, + capture_performance: false, + capture_heatmaps: false, + // Prevent fetching flags and along with it, any remote config. + advanced_disable_flags: true, + }); +}; + +export const TelemetryContextProvider = ({ + uid, + isOptedIn, + children, +}: React.PropsWithChildren<{ + uid: string; + isOptedIn?: Storage['consentTelemetry']; +}>) => { + if (!POSTHOG_KEY) { + // biome-ignore lint/suspicious/noConsole: It is always added in production builds, so it's safe. Warning here helps us debug better. + console.warn('PostHog key not found. Telemetry will not be enabled.'); + return ( + + {children} + + ); + } + + // While isOptedIn is undefined or false, we won't capture data. + const posthog = setupPosthog(uid, isOptedIn === true); + + const telemetry: Telemetry = { + capture: posthog.capture.bind(posthog), + captureException: posthog.captureException.bind(posthog), + optInOut(isOptedIn) { + if (isOptedIn) { + posthog.opt_in_capturing(); + } else { + posthog.opt_out_capturing(); + } + }, + }; + + return ( + + {children} + + ); +}; + +export const useTelemetry = () => React.useContext(TelemetryContext); +// #endregion diff --git a/src/shared/defines.ts b/src/shared/defines.ts index b2b2926fe..4ef631edb 100644 --- a/src/shared/defines.ts +++ b/src/shared/defines.ts @@ -3,7 +3,12 @@ import type { LogLevelDesc } from 'loglevel'; declare const CONFIG_LOG_LEVEL: LogLevelDesc; declare const CONFIG_LOG_SERVER_ENDPOINT: string | false; declare const CONFIG_OPEN_PAYMENTS_REDIRECT_URL: string; +declare const CONFIG_POSTHOG_KEY: string; +declare const CONFIG_POSTHOG_HOST: string; export const LOG_LEVEL = CONFIG_LOG_LEVEL; export const LOG_SERVER_ENDPOINT = CONFIG_LOG_SERVER_ENDPOINT; export const OPEN_PAYMENTS_REDIRECT_URL = CONFIG_OPEN_PAYMENTS_REDIRECT_URL; + +export const POSTHOG_KEY = CONFIG_POSTHOG_KEY; +export const POSTHOG_HOST = CONFIG_POSTHOG_HOST; diff --git a/src/shared/types.ts b/src/shared/types.ts index a8ca0f088..59e95c1d1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -84,6 +84,12 @@ export interface Storage { */ consent?: number; + /** + * Whether the user has provided consent to analytics and telemetry. + * @default undefined implies user has never provided consent. + */ + consentTelemetry?: boolean; + /** If a wallet is connected or not */ connected: boolean; /** Whether the extension (actually any sort of payment) is enabled */ @@ -164,7 +170,10 @@ export type PopupStore = Omit< }>; }; -export type AppStore = Pick & { +export type AppStore = Pick< + Storage, + 'publicKey' | 'connected' | 'consent' | 'consentTelemetry' +> & { transientState: PopupTransientState; }; From 4579fbd141710b1df3068579003015e56987fdc9 Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:36:22 +0530 Subject: [PATCH 2/2] feat(app): set up PostHog for events data collection --- src/background/services/background.ts | 15 ++++++++++----- src/pages/app/lib/context.tsx | 19 ++++++++++++++++--- src/pages/app/lib/store.ts | 2 +- src/shared/types.ts | 2 +- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 10dcc165d..a164cb929 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -154,14 +154,19 @@ export class Background { } async getAppData(): Promise { - const { connected, publicKey, consent } = await this.storage.get([ - 'connected', - 'publicKey', - 'consent', - ]); + const { connected, publicKey, uid, consent, consentTelemetry } = + await this.storage.get([ + 'connected', + 'publicKey', + 'consent', + 'uid', + 'consentTelemetry', + ]); return { + uid, consent, + consentTelemetry, connected, publicKey, transientState: this.storage.getPopupTransientState(), diff --git a/src/pages/app/lib/context.tsx b/src/pages/app/lib/context.tsx index 28f6f386e..867c32a9c 100644 --- a/src/pages/app/lib/context.tsx +++ b/src/pages/app/lib/context.tsx @@ -5,7 +5,10 @@ import { MessageManager, type AppToBackgroundMessage, } from '@/shared/messages'; -import { useBrowser } from '@/pages/shared/lib/context'; +import { + TelemetryContextProvider, + useBrowser, +} from '@/pages/shared/lib/context'; import { dispatch } from './store'; export { useBrowser, useTranslation } from '@/pages/shared/lib/context'; @@ -13,13 +16,19 @@ export { useBrowser, useTranslation } from '@/pages/shared/lib/context'; export function WaitForStateLoad({ children }: React.PropsWithChildren) { const message = useMessage(); const [isLoading, setIsLoading] = React.useState(true); + const [telemetryConfig, setTelemetryConfig] = React.useState<{ + uid: string; + isOptedIn?: boolean; + }>({ uid: '' }); React.useEffect(() => { async function get() { const response = await message.send('GET_DATA_APP'); if (response.success) { - void dispatch({ type: 'SET_DATA_APP', data: response.payload }); + const data = response.payload; + dispatch({ type: 'SET_DATA_APP', data }); + setTelemetryConfig({ uid: data.uid, isOptedIn: data.consentTelemetry }); setIsLoading(false); } } @@ -31,7 +40,11 @@ export function WaitForStateLoad({ children }: React.PropsWithChildren) { return 'Loading'; } - return <>{children}; + return ( + + {children} + + ); } const MessageContext = React.createContext< diff --git a/src/pages/app/lib/store.ts b/src/pages/app/lib/store.ts index e5320979b..2513c8ff9 100644 --- a/src/pages/app/lib/store.ts +++ b/src/pages/app/lib/store.ts @@ -14,7 +14,7 @@ export const store = proxy({ // easier access to the store via this hook export const useAppState = () => useSnapshot(store); -export const dispatch = async ({ type, data }: Actions) => { +export const dispatch = ({ type, data }: Actions) => { switch (type) { case 'SET_DATA_APP': for (const key of Object.keys(data) as Array) { diff --git a/src/shared/types.ts b/src/shared/types.ts index 59e95c1d1..0b9fb98a1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -172,7 +172,7 @@ export type PopupStore = Omit< export type AppStore = Pick< Storage, - 'publicKey' | 'connected' | 'consent' | 'consentTelemetry' + 'publicKey' | 'connected' | 'uid' | 'consent' | 'consentTelemetry' > & { transientState: PopupTransientState; };