Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const CONNECT_SRC_HOSTS = [
'https://pass.celopg.eco',
'https://*.rainbow.me',
'https://us-central1-staked-celo-bot.cloudfunctions.net',
'https://eu.i.posthog.com',
'https://eu-assets.i.posthog.com',
];
const FRAME_SRC_HOSTS = [
'https://*.walletconnect.com',
Expand All @@ -38,12 +40,12 @@ const IMG_SRC_HOSTS = [
'https://app.safe.global',
'https://pass.celopg.eco',
];
const SCRIPTS_SRC_HOSTS = ['https://*.safe.global'];
const SCRIPTS_SRC_HOSTS = ['https://*.safe.global', 'https://eu-assets.i.posthog.com'];

const cspHeader = `
default-src 'self';
script-src 'self' ${isDev ? "'unsafe-eval'" : SCRIPTS_SRC_HOSTS.join(' ')};
script-src-elem 'self' 'unsafe-inline';
script-src-elem 'self' 'unsafe-inline' https://eu-assets.i.posthog.com;
style-src 'self' 'unsafe-inline';
connect-src 'self' ${CONNECT_SRC_HOSTS.join(' ')};
img-src 'self' blob: data: ${IMG_SRC_HOSTS.join(' ')};
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@metamask/jazzicon": "^2.0.0",
"@metamask/post-message-stream": "6.1.2",
"@metamask/providers": "10.2.1",
"@posthog/react": "^1.8.2",
"@rainbow-me/rainbowkit": "2.2.9",
"@slack/socket-mode": "^2.0.5",
"@slack/web-api": "^7.13.0",
Expand All @@ -40,6 +41,7 @@
"next": "15.5.9",
"octokit": "^5.0.3",
"postgres": "^3.4.7",
"posthog-js": "^1.363.5",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-minimal-pie-chart": "^8.4.1",
Expand Down
2 changes: 1 addition & 1 deletion src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function Page() {
<Amount valueWei={totalBalance} className="-mt-1 text-3xl md:text-4xl" />
</div>
{isVoteSigner ? (
<div className="align-right flex flex-col items-end">
<div className="ph-no-capture align-right flex flex-col items-end">
<h2 className="font-medium">Vote Signer For</h2>
<span className="hidden font-mono text-sm md:flex">{signingFor}</span>
<span className="font-mono text-sm md:hidden">{shortenAddress(signingFor!)}</span>
Expand Down
64 changes: 53 additions & 11 deletions src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use server';

import { eq, sql } from 'drizzle-orm';
import { unstable_cache } from 'next/cache';
import database from 'src/config/database';
import { analyticsEventsTable } from 'src/db/schema';
import {
Expand Down Expand Up @@ -89,19 +90,60 @@ export interface BridgeClickCount {
count: number;
}

async function getBridgeClickedCountsFromDb(): Promise<BridgeClickCount[]> {
const bridgeClickCounts = await database
.select({
bridgeId: sql<string>`properties->>'bridgeId'`,
count: sql<number>`count(distinct "sessionId")::int`,
})
.from(analyticsEventsTable)
.where(eq(analyticsEventsTable.eventName, 'bridge_clicked'))
.groupBy(sql`properties->>'bridgeId'`)
.orderBy(sql`count(distinct "sessionId") desc`);

return bridgeClickCounts;
}

const getBridgeClickedCountsFromPostHog = unstable_cache(
async (): Promise<BridgeClickCount[]> => {
const res = await fetch(
`https://eu.posthog.com/api/projects/${process.env.POSTHOG_PROJECT_ID}/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTHOG_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: {
kind: 'HogQLQuery',
query: `
SELECT properties.bridgeId, count(distinct properties.$session_id) AS cnt
FROM events
WHERE event = 'bridge_clicked'
GROUP BY properties.bridgeId
ORDER BY cnt DESC
`,
},
}),
},
);

if (!res.ok) throw new Error(`PostHog API error: ${res.status}`);

const data = await res.json();
return data.results.map(([bridgeId, count]: [string, number]) => ({ bridgeId, count }));
},
['bridge-clicked-counts'],
{ revalidate: 300 },
);

export async function getBridgeClickedCounts(): Promise<BridgeClickCount[]> {
const source = process.env.BRIDGE_COUNTS_SOURCE ?? 'database';
try {
const bridgeClickCounts = await database
.select({
bridgeId: sql<string>`properties->>'bridgeId'`,
count: sql<number>`count(distinct "sessionId")::int`,
})
.from(analyticsEventsTable)
.where(eq(analyticsEventsTable.eventName, 'bridge_clicked'))
.groupBy(sql`properties->>'bridgeId'`)
.orderBy(sql`count(distinct "sessionId") desc`);

return bridgeClickCounts;
return await (source === 'posthog'
? getBridgeClickedCountsFromPostHog()
: getBridgeClickedCountsFromDb());
Comment on lines +64 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to DB counts when PostHog is unavailable

When BRIDGE_COUNTS_SOURCE is set to posthog, any transient PostHog failure (rate limit, bad key, network issue) currently drops into the shared catch path and returns an empty list, which makes bridge rankings collapse to all-zero even though the app is still writing analytics events to the local database. This introduces an avoidable production reliability regression for the bridge page; in the PostHog path, failures should fall back to getBridgeClickedCountsFromDb() instead of returning [].

Useful? React with 👍 / 👎.

} catch (error) {
// eslint-disable-next-line no-console
console.error('Error fetching bridge click counts:', error);
Expand Down
91 changes: 75 additions & 16 deletions src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use client';
import { PostHogProvider } from '@posthog/react';
import { Analytics } from '@vercel/analytics/react';
import { PropsWithChildren } from 'react';
import posthog from 'posthog-js';
import { PropsWithChildren, useEffect } from 'react';
import { ToastContainer, Zoom } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { MiniPayNoCeloBanner } from 'src/components/banner/MiniPayNoCeloBanner';
Expand All @@ -11,32 +13,89 @@ import { Header } from 'src/components/nav/Header';
import { LegalRestrict } from 'src/components/police';
import { WagmiContext } from 'src/config/wagmi';
import { TransactionModal } from 'src/features/transactions/TransactionModal';
import { scrubEventUrlProperties } from 'src/utils/posthog';
import { useIsSsr } from 'src/utils/ssr';
import ENSProvider from 'src/utils/useAddressToLabel';
import HistoryProvider from 'src/utils/useHistory';
import StakingModeProvider from 'src/utils/useStakingMode';
import 'src/vendor/inpage-metamask.js';
import 'src/vendor/polyfill';

function PHProvider({ children }: PropsWithChildren) {
useEffect(() => {
if (posthog.__loaded) return;
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN as string, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
person_profiles: 'never',
autocapture: true,
Comment thread
shazarre marked this conversation as resolved.
mask_all_text: true,
Comment on lines +27 to +31
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable PostHog session replay in client config

This initialization never opts users out of session recording, so replay capture remains on by default unless the PostHog project is separately configured to disable it. In production environments where replay is enabled server-side, this can still collect DOM snapshots while the code and new privacy page state replay is disabled, creating a privacy/compliance gap. Set disable_session_recording: true (or equivalent explicit replay-off config) in this posthog.init call.

Useful? React with 👍 / 👎.

mask_all_element_attributes: true,
capture_pageview: true,
capture_pageleave: true,
persistence: 'sessionStorage',
defaults: '2026-01-30',
internal_or_test_user_hostname: null,
debug: false,
loaded: (posthog) => {
if (process.env.NODE_ENV === 'development') {
posthog.debug();
}
},
before_send: (event) => {
if (event !== null) {
delete event.properties['$ip'];
scrubEventUrlProperties(event.properties);
}

return event;
},
});
}, []);

useEffect(() => {
const markNoCapture = (mutations: MutationRecord[]) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches('[data-rk] [role="dialog"]')) {
node.classList.add('ph-no-capture');
}
node.querySelectorAll('[data-rk] [role="dialog"]').forEach((el) => {
el.classList.add('ph-no-capture');
});
}
}
};

const observer = new MutationObserver(markNoCapture);
observer.observe(document.body, { childList: true, subtree: true });
Comment thread
shazarre marked this conversation as resolved.
return () => observer.disconnect();
}, []);

return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}

export function App({ children }: PropsWithChildren<any>) {
return (
<ErrorBoundary>
<SafeHydrate>
<WagmiContext>
<HistoryProvider>
<StakingModeProvider>
<ENSProvider>
<LegalRestrict>
<BodyLayout>{children}</BodyLayout>
</LegalRestrict>
<TransactionModal />
<ErrorBoundaryInline>
<ToastContainer transition={Zoom} position="bottom-right" limit={12} />
</ErrorBoundaryInline>
</ENSProvider>
</StakingModeProvider>
</HistoryProvider>
</WagmiContext>
<PHProvider>
<WagmiContext>
<HistoryProvider>
<StakingModeProvider>
<ENSProvider>
<LegalRestrict>
<BodyLayout>{children}</BodyLayout>
</LegalRestrict>
<TransactionModal />
<ErrorBoundaryInline>
<ToastContainer transition={Zoom} position="bottom-right" limit={12} />
</ErrorBoundaryInline>
</ENSProvider>
</StakingModeProvider>
</HistoryProvider>
</WagmiContext>
</PHProvider>
</SafeHydrate>
<Analytics />
</ErrorBoundary>
Expand Down
8 changes: 5 additions & 3 deletions src/features/wallet/WalletDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function WalletDropdown() {
{address && isConnected ? (
<DropdownModal
button={() => (
<div className="flex items-center justify-center space-x-1">
<div className="ph-no-capture flex items-center justify-center space-x-1">
<Identicon address={address} size={26} />
<AddressLabel address={address} />
</div>
Expand Down Expand Up @@ -124,7 +124,7 @@ function DropdownContent({

return (
<div className="flex min-w-[18rem] flex-col items-center space-y-3">
<div className="flex flex-col items-center">
<div className="ph-no-capture flex flex-col items-center">
<Identicon address={address} size={34} />
<button title="Click to copy" onClick={onClickCopy} className="flex flex-col text-sm">
<AddressLabel address={address} hiddenIfNoLabel shortener={() => shortAddress} />
Expand Down Expand Up @@ -203,7 +203,9 @@ function ValueRow({
<div className="flex flex-row justify-between">
<label className="text-sm">{label} </label>
{address && (
<span className="font-mono text-xs text-taupe-600">&hellip;{address?.slice(-4)}</span>
<span className="ph-no-capture font-mono text-xs text-taupe-600">
&hellip;{address?.slice(-4)}
</span>
)}
</div>
<Amount value={value} valueWei={valueWei} className="text-xl" />
Expand Down
74 changes: 74 additions & 0 deletions src/utils/posthog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { scrubAddressesFromUrl, scrubEventUrlProperties } from './posthog';

const ADDR = '0xAbCdEf1234567890abcdef1234567890AbCdEf12';
const ADDR2 = '0x1111111111111111111111111111111111111111';

describe('scrubAddressesFromUrl', () => {
it('replaces a single address in a path', () => {
expect(scrubAddressesFromUrl(`/validators/${ADDR}`)).toBe('/validators/[address]');
});

it('replaces multiple addresses', () => {
expect(scrubAddressesFromUrl(`/from/${ADDR}/to/${ADDR2}`)).toBe('/from/[address]/to/[address]');
});

it('is case-insensitive', () => {
expect(scrubAddressesFromUrl(`/delegate/${ADDR.toLowerCase()}`)).toBe('/delegate/[address]');
});

it('leaves strings without addresses unchanged', () => {
expect(scrubAddressesFromUrl('/governance/proposals')).toBe('/governance/proposals');
});

it('does not match a hex string shorter than 40 chars', () => {
const short = '0x1234abcd';
expect(scrubAddressesFromUrl(`/foo/${short}`)).toBe(`/foo/${short}`);
});

it('handles a full URL', () => {
expect(scrubAddressesFromUrl(`https://example.com/validators/${ADDR}?ref=home`)).toBe(
`https://example.com/validators/[address]?ref=home`,
);
});
});

describe('scrubEventUrlProperties', () => {
it('scrubs $current_url', () => {
const props: Record<string, unknown> = { $current_url: `/validators/${ADDR}` };
scrubEventUrlProperties(props);
expect(props['$current_url']).toBe('/validators/[address]');
});

it('scrubs $pathname', () => {
const props: Record<string, unknown> = { $pathname: `/delegate/${ADDR}` };
scrubEventUrlProperties(props);
expect(props['$pathname']).toBe('/delegate/[address]');
});

it('scrubs $referrer and $initial_referrer', () => {
const props: Record<string, unknown> = {
$referrer: `https://example.com/validators/${ADDR}`,
$initial_referrer: `https://example.com/delegate/${ADDR2}`,
};
scrubEventUrlProperties(props);
expect(props['$referrer']).toBe('https://example.com/validators/[address]');
expect(props['$initial_referrer']).toBe('https://example.com/delegate/[address]');
});

it('ignores non-string property values', () => {
const props: Record<string, unknown> = { $current_url: 42 };
scrubEventUrlProperties(props);
expect(props['$current_url']).toBe(42);
});

it('leaves unrelated properties untouched', () => {
const props: Record<string, unknown> = {
$current_url: '/governance',
walletType: 'MetaMask',
};
scrubEventUrlProperties(props);
expect(props['$current_url']).toBe('/governance');
expect(props['walletType']).toBe('MetaMask');
});
});
17 changes: 17 additions & 0 deletions src/utils/posthog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const EVM_ADDRESS_IN_PATH_RE = /0x[a-fA-F0-9]{40}/gi;
const REPLACEMENT = '[address]';

const SCRUBBED_URL_PROPS = ['$current_url', '$pathname', '$referrer', '$initial_referrer'] as const;

export function scrubAddressesFromUrl(url: string): string {
return url.replace(EVM_ADDRESS_IN_PATH_RE, REPLACEMENT);
}

export function scrubEventUrlProperties(properties: Record<string, unknown>): void {
for (const prop of SCRUBBED_URL_PROPS) {
const value = properties[prop];
if (typeof value === 'string') {
properties[prop] = scrubAddressesFromUrl(value);
}
}
}
6 changes: 4 additions & 2 deletions src/utils/useTrackEvent.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { usePostHog } from '@posthog/react';
import { useCallback, useEffect, useState } from 'react';
import { AnalyticsEventMap, AnalyticsEventName } from 'src/types/analytics';
import { v4 as uuidv4, validate as validateUUID } from 'uuid';
import { trackEvent } from './analytics';

const SESSION_STORAGE_KEY = 'analytics_session_id';

// React hook for analytics tracking with session management
export function useTrackEvent() {
const posthog = usePostHog();
const [sessionId, setSessionId] = useState<string>('');

useEffect(() => {
Expand Down Expand Up @@ -40,8 +41,9 @@ export function useTrackEvent() {
const track = useCallback(
<T extends AnalyticsEventName>(eventName: T, properties: AnalyticsEventMap[T]) => {
trackEvent(eventName, properties, sessionId);
posthog?.capture(eventName, properties);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact address-like analytics fields before PostHog capture

useTrackEvent now forwards every analytics payload to PostHog, but many event payloads include raw on-chain addresses (for example groupAddress/delegateeAddress in src/types/analytics.ts). The only sanitizer added in this change (scrubEventUrlProperties) runs in before_send and only rewrites URL-related keys, so these address fields are sent unchanged to a third-party endpoint whenever those events fire. This bypasses the privacy hardening added for autocapture and should be fixed by hashing/removing address-like custom properties before calling capture.

Useful? React with 👍 / 👎.

},
[sessionId],
[sessionId, posthog],
);

return track;
Expand Down
Loading
Loading