From f39376a887dba0d0ce981607b33e75e5a2888d9e Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 1 Oct 2026 14:42:29 -0400 Subject: [PATCH] Add PostHog, move Plausible to v4, and drop Sentry session replay PostHog loads after the page is idle through a same-origin /ingest proxy and follows the signed-in Convex user. Plausible uses the per-site script through a /pa proxy, clear of Payload's /api routes. Sentry keeps errors and traces only. Pages load only the @gib/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser. --- apps/next/next.config.js | 57 ++++++++++-- apps/next/src/app/(frontend)/global-error.tsx | 68 ++++++++------- apps/next/src/app/(frontend)/layout.tsx | 56 ++++++------ .../layout/auth/profile/sign-out.tsx | 2 + .../layout/header/controls/AvatarDropdown.tsx | 3 + .../providers/ConvexClientProvider.tsx | 4 +- apps/next/src/env.ts | 9 +- apps/next/src/instrumentation-client.ts | 40 +++++---- apps/next/src/lib/analytics.ts | 86 +++++++++++++++++++ apps/next/src/lib/posthog-scripts.ts | 24 ++++++ apps/next/src/publicEnv.ts | 30 +++++++ apps/next/src/sentry.server.config.ts | 3 +- packages/ui/src/index.tsx | 22 ++--- packages/ui/src/utils.ts | 16 ++++ 14 files changed, 318 insertions(+), 102 deletions(-) create mode 100644 apps/next/src/lib/analytics.ts create mode 100644 apps/next/src/lib/posthog-scripts.ts create mode 100644 apps/next/src/publicEnv.ts create mode 100644 packages/ui/src/utils.ts diff --git a/apps/next/next.config.js b/apps/next/next.config.js index 5142d3a..4868567 100644 --- a/apps/next/next.config.js +++ b/apps/next/next.config.js @@ -4,12 +4,16 @@ import { createJiti } from 'jiti'; import { withPlausibleProxy } from 'next-plausible'; const jiti = createJiti(import.meta.url); -await jiti.import('./src/env'); +const { env } = /** @type {typeof import('./src/env')} */ ( + await jiti.import('./src/env') +); +const { posthogScriptAliases } = + /** @type {typeof import('./src/lib/posthog-scripts')} */ ( + await jiti.import('./src/lib/posthog-scripts') + ); /** @type {import("next").NextConfig} */ -const config = withPlausibleProxy({ - customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL, -})({ +const baseConfig = { output: 'standalone', images: { remotePatterns: [ @@ -24,11 +28,54 @@ const config = withPlausibleProxy({ serverActions: { bodySizeLimit: '10mb', }, + // Resolves `import { Button } from '@gib/ui'` to the button module alone, + // so marketing pages never pull in admin-only Radix widgets or Recharts. + // radix-ui is the umbrella package the shadcn components import from. + // lucide-react, date-fns and @tabler/icons-react are on Next's default list. + optimizePackageImports: ['@gib/ui', 'radix-ui'], }, /** Enables hot reloading for local packages without a build step */ transpilePackages: ['@gib/backend', '@gib/ui'], typescript: { ignoreBuildErrors: true }, -}); + // PostHog's capture paths end in a slash (/e/, /i/v0/e/), and Next's + // built-in trailing-slash redirect would answer those proxied POSTs with a + // redirect. It is turned off here and replaced by the redirect below, which + // strips trailing slashes everywhere except /ingest. + skipTrailingSlashRedirect: true, + redirects: async () => [ + { + source: '/:path((?!ingest(?:/|$)).+)/', + destination: '/:path', + permanent: true, + }, + ], + // Same-origin proxy for PostHog, so blocklists that match the PostHog host + // never see the request. The aliased extension scripts (blocked by file + // name, see src/lib/posthog-scripts.ts) map back to their real files first. + rewrites: async () => { + const host = env.NEXT_PUBLIC_POSTHOG_HOST; + if (!host) return []; + return [ + ...Object.entries(posthogScriptAliases).map(([name, alias]) => ({ + source: `/ingest/static/${alias}.js`, + destination: `${host}/static/${name}.js`, + })), + { source: '/ingest/:path*', destination: `${host}/:path*` }, + ]; + }, +}; + +// Serves Plausible's per-site script from this origin so ad blockers see a +// first-party request. next-plausible 4 takes the full script URL. Its default +// event path is /api/event, which sits inside Payload's /api routes, so both +// paths move under /pa. +const config = env.NEXT_PUBLIC_PLAUSIBLE_SRC + ? withPlausibleProxy({ + src: env.NEXT_PUBLIC_PLAUSIBLE_SRC, + scriptPath: '/pa/script.js', + apiPath: '/pa/event', + })(baseConfig) + : baseConfig; const sentryConfig = { // For all available options, see: diff --git a/apps/next/src/app/(frontend)/global-error.tsx b/apps/next/src/app/(frontend)/global-error.tsx index b4867d0..afc6bc9 100644 --- a/apps/next/src/app/(frontend)/global-error.tsx +++ b/apps/next/src/app/(frontend)/global-error.tsx @@ -10,8 +10,8 @@ import { useEffect } from 'react'; import Footer from '@/components/layout/footer'; import Header from '@/components/layout/header'; import { ConvexClientProvider } from '@/components/providers'; -import { env } from '@/env'; import { generateMetadata } from '@/lib/metadata'; +import { publicEnv } from '@/publicEnv'; import * as Sentry from '@sentry/nextjs'; import PlausibleProvider from 'next-plausible'; @@ -44,39 +44,41 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => { useEffect(() => { Sentry.captureException(error); }, [error]); - return ( - - - - - -
-
- - {reset !== undefined && ( - - )} - -
-
-
- -
-
-
- - -
+ +
+
+ + {reset !== undefined && ( + + )} + +
+
+
+ +
+
+ + ); + return ( + + + {publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? ( + {page} + ) : ( + page + )} + + ); }; export default GlobalError; diff --git a/apps/next/src/app/(frontend)/layout.tsx b/apps/next/src/app/(frontend)/layout.tsx index 53f715a..7085715 100644 --- a/apps/next/src/app/(frontend)/layout.tsx +++ b/apps/next/src/app/(frontend)/layout.tsx @@ -36,34 +36,38 @@ const RootLayout = ({ }: Readonly<{ children: React.ReactNode; }>) => { + const app = ( + + +
+
+ {children} +
+
+ +
+
+ ); return ( - - - - - -
-
- {children} -
-
- -
-
- - -
+ + + {/* Plausible loads its per-site script through the same-origin + proxy in next.config.js; without that config it stays off. */} + {env.NEXT_PUBLIC_PLAUSIBLE_SRC ? ( + {app} + ) : ( + app + )} + +
); }; diff --git a/apps/next/src/components/layout/auth/profile/sign-out.tsx b/apps/next/src/components/layout/auth/profile/sign-out.tsx index 0cad885..edb10d5 100644 --- a/apps/next/src/components/layout/auth/profile/sign-out.tsx +++ b/apps/next/src/components/layout/auth/profile/sign-out.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { resetAnalyticsIdentity } from '@/lib/analytics'; import { useAuthActions } from '@convex-dev/auth/react'; import { LogOut } from 'lucide-react'; @@ -22,6 +23,7 @@ export const SignOutForm = () => { setIsSigningOut(true); try { await signOut(); + resetAnalyticsIdentity(); router.push('/'); } catch (error) { console.error('Sign out error:', error); diff --git a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx index c3dbd1e..ca07c86 100644 --- a/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx +++ b/apps/next/src/components/layout/header/controls/AvatarDropdown.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; +import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics'; import { useAuthActions } from '@convex-dev/auth/react'; import { useConvexAuth, useQuery } from 'convex/react'; @@ -23,6 +24,7 @@ export const AvatarDropdown = () => { const { isLoading, isAuthenticated } = useConvexAuth(); const { signOut } = useAuthActions(); const user = useQuery(api.auth.getUser, {}); + useAnalyticsIdentity(user?._id); const currentImageUrl = useQuery( api.files.getImageUrl, user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip', @@ -77,6 +79,7 @@ export const AvatarDropdown = () => {