From 91e7ead5f17460171667169bc63e8bd6fe77726e Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:09:24 +0000 Subject: [PATCH] feat(frontend): add privacy-respecting error and product analytics Co-authored-by: LSUDOKO <173903994+LSUDOKO@users.noreply.github.com> --- frontend/.env.example | 9 + frontend/app/app/page.tsx | 38 +- frontend/app/components/PostHogProvider.tsx | 41 + frontend/app/layout.tsx | 9 +- frontend/lib/analytics.ts | 72 + frontend/lib/scrub.test.ts | 52 + frontend/lib/scrub.ts | 37 + frontend/next-env.d.ts | 4 +- frontend/next.config.mjs | 8 +- frontend/package-lock.json | 3375 +++++++++++++++++-- frontend/package.json | 2 + frontend/sentry.client.config.ts | 11 + frontend/sentry.edge.config.ts | 11 + frontend/sentry.server.config.ts | 11 + frontend/tsconfig.tsbuildinfo | 2 +- 15 files changed, 3383 insertions(+), 299 deletions(-) create mode 100644 frontend/app/components/PostHogProvider.tsx create mode 100644 frontend/lib/analytics.ts create mode 100644 frontend/lib/scrub.test.ts create mode 100644 frontend/lib/scrub.ts create mode 100644 frontend/sentry.client.config.ts create mode 100644 frontend/sentry.edge.config.ts create mode 100644 frontend/sentry.server.config.ts diff --git a/frontend/.env.example b/frontend/.env.example index b0a87d5..e3dd09a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -14,3 +14,12 @@ NEXT_PUBLIC_TOKEN_OUT=0xC67DCE33D7A8efA5FfEB961899C73fe01bCe9273 # FTSO feed the trigger watches. Default is FLR/USD. NEXT_PUBLIC_FEED_ID=0x01464c522f55534400000000000000000000000000 + +# Sentry error tracking +# Leave empty in local dev and CI to disable. +NEXT_PUBLIC_SENTRY_DSN= + +# PostHog funnel analytics +# Leave empty in local dev and CI to disable. +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com diff --git a/frontend/app/app/page.tsx b/frontend/app/app/page.tsx index 6b7b372..1c4ad92 100644 --- a/frontend/app/app/page.tsx +++ b/frontend/app/app/page.tsx @@ -25,6 +25,7 @@ import { } from "@/lib/wraith"; import { Ticker } from "@/app/components/Ticker"; import { ActivityLog } from "@/app/components/ActivityLog"; +import { trackEvent, setPersonProperties, trackError } from "@/lib/analytics"; const WRAITH_ADDRESS = (process.env.NEXT_PUBLIC_WRAITH_ADDRESS ?? "") as Address; const FXRP_ADDRESS = (process.env.NEXT_PUBLIC_FXRP_ADDRESS ?? "") as Address; @@ -76,6 +77,14 @@ export default function Home() { const [minOut, setMinOut] = useState("150"); const [xrplAddress, setXrplAddress] = useState(""); const [days, setDays] = useState("7"); + const [composeStarted, setComposeStarted] = useState(false); + + const startCompose = useCallback(() => { + if (!composeStarted) { + setComposeStarted(true); + trackEvent("order_compose_started", { wallet_connected: Boolean(account) }); + } + }, [composeStarted, account]); const say = (message: string, kind: "info" | "error" = "info") => { setStatus(message); @@ -134,6 +143,16 @@ export default function Home() { return () => clearInterval(interval); }, [loadOrders]); + useEffect(() => { + setPersonProperties({ wallet_connected: Boolean(account) }); + }, [account]); + + useEffect(() => { + if (wrongNetwork) { + trackEvent("wrong_network_shown", { wallet_connected: Boolean(account) }); + } + }, [wrongNetwork, account]); + const stats = useMemo(() => { const escrowed = orders .filter((o) => o.state === "sealed") @@ -165,6 +184,7 @@ export default function Home() { setWrongNetwork(chainId !== coston2.id); say(chainId === coston2.id ? "Wallet connected." : "Wallet connected, but it is on the wrong network."); } catch (error) { + trackError(error); say(error instanceof Error ? error.message.split("\n")[0] : "Could not connect.", "error"); } } @@ -243,8 +263,10 @@ export default function Home() { setLastTx(hash); say("Sealed. Your trigger never touched the chain in the clear."); + trackEvent("order_sealed", { wallet_connected: true }); await loadOrders(); } catch (error) { + trackError(error); const message = error instanceof Error ? error.message : String(error); say(message.split("\n")[0], "error"); } finally { @@ -270,8 +292,10 @@ export default function Home() { setLastTx(hash); say(`Order ${orderId} cancelled. Escrow refunded.`); + trackEvent("order_cancelled", { wallet_connected: true }); await loadOrders(); } catch (error) { + trackError(error); const message = error instanceof Error ? error.message : String(error); say(message.split("\n")[0], "error"); } finally { @@ -334,19 +358,19 @@ export default function Home() {
Trigger
- { setDirection(e.target.value as Direction); startCompose(); }}> setThreshold(e.target.value)} + onChange={(e) => { setThreshold(e.target.value); startCompose(); }} inputMode="decimal" aria-label="Trigger price" required @@ -357,13 +381,13 @@ export default function Home() {
Then
- { setAction(e.target.value as ActionKind); startCompose(); }}> setMinOut(e.target.value)} + onChange={(e) => { setMinOut(e.target.value); startCompose(); }} inputMode="decimal" aria-label={action === "swap" ? "Minimum output" : "Lots to redeem"} required @@ -376,7 +400,7 @@ export default function Home() { XRPL destination setXrplAddress(e.target.value)} + onChange={(e) => { setXrplAddress(e.target.value); startCompose(); }} placeholder="r…" pattern="r[1-9A-HJ-NP-Za-km-z]{24,34}" title="An XRPL classic address, starting with r" @@ -389,7 +413,7 @@ export default function Home() { Expires in (days) setDays(e.target.value)} + onChange={(e) => { setDays(e.target.value); startCompose(); }} inputMode="numeric" pattern="[0-9]+" required diff --git a/frontend/app/components/PostHogProvider.tsx b/frontend/app/components/PostHogProvider.tsx new file mode 100644 index 0000000..cc6b7c0 --- /dev/null +++ b/frontend/app/components/PostHogProvider.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { usePathname, useSearchParams } from "next/navigation"; + +export function PostHogProvider({ children }: { children: React.ReactNode }) { + useEffect(() => { + const key = process.env.NEXT_PUBLIC_POSTHOG_KEY; + const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"; + + if (key) { + posthog.init(key, { + api_host: host, + person_profiles: "identified_only", + capture_pageview: false, // We'll handle this manually to ensure it's SPA friendly + }); + } + }, []); + + return <>{children}; +} + +export function PostHogPageView() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + if (process.env.NEXT_PUBLIC_POSTHOG_KEY && typeof window !== "undefined") { + let url = window.origin + pathname; + if (searchParams.toString()) { + url = url + `?${searchParams.toString()}`; + } + posthog.capture("$pageview", { + $current_url: url, + }); + } + }, [pathname, searchParams]); + + return null; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 23017b8..3c580ed 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import { Archivo, JetBrains_Mono } from "next/font/google"; import { Nav } from "@/app/components/Nav"; +import { PostHogProvider, PostHogPageView } from "@/app/components/PostHogProvider"; import "./globals.css"; const archivo = Archivo({ @@ -46,7 +48,12 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* Fixed grain: breaks the digital flatness of large dark fields. */}