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;
};