Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
92 changes: 76 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,90 @@ 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
Loading
Loading