From 348c2d3d621047481893cd72fda69c0933e2896d Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Thu, 1 Oct 2026 17:17:21 -0400 Subject: [PATCH] feat: 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. Sentry keeps errors and traces only. Pages load only the @spoon/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser. Both analytics integrations stay off until their env is set in Infisical prod. --- AGENTS.md | 6 ++ README.md | 4 +- apps/next/next.config.js | 57 ++++++++++-- apps/next/package.json | 3 +- apps/next/src/app/global-error.tsx | 54 ++++++------ apps/next/src/app/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 +- bun.lock | 29 ++++++- docker/Dockerfile | 8 +- docker/compose.yml | 8 +- docs/compose.prod.yml | 4 +- packages/ui/src/index.tsx | 22 ++--- packages/ui/src/utils.ts | 16 ++++ turbo.json | 4 +- 22 files changed, 366 insertions(+), 106 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/AGENTS.md b/AGENTS.md index 37de005..c9b0ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,12 @@ read-only `spoon-ci` machine identity (Gitea secrets `INFISICAL_CLIENT_ID` and `INFISICAL_CLIENT_SECRET`), and hands the result to the scripts as `CI_ENV_FILE`. `scripts/export-env` refuses to run in CI. +- Plausible (`NEXT_PUBLIC_PLAUSIBLE_SRC`, the per-site script) loads through a + same-origin `/pa` proxy and PostHog (`NEXT_PUBLIC_POSTHOG_KEY`/`_HOST`) through + `/ingest`, after the page is idle. Both stay off when unset. Session replay comes + from PostHog; Sentry carries errors and traces only. Client code reads public env + from `@/publicEnv`, and `packages/ui/src/index.tsx` holds re-exports only so + `optimizePackageImports` can load just the modules a page imports. - A push to `main` builds the images, syncs the Convex env, deploys Convex, pushes the images, and asks the VPS to redeploy `podman-spoon.service` (`ssh gib@192.168.2.2 spoon`). A push to `main` is a production deploy. diff --git a/README.md b/README.md index 1f10691..3816660 100644 --- a/README.md +++ b/README.md @@ -549,7 +549,9 @@ not call Infisical. | `NEXT_PUBLIC_CONVEX_URL` | Convex client URL | | `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | Browser-facing worker WS base for the terminal (**build-time** var) | | `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed | -| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint | +| `NEXT_PUBLIC_PLAUSIBLE_SRC` | Plausible per-site script URL (optional) | +| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog project key (optional) | +| `NEXT_PUBLIC_POSTHOG_HOST` | PostHog host (optional) | | `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN | | `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL | | `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization | diff --git a/apps/next/next.config.js b/apps/next/next.config.js index 4d9bb7a..fe17f3c 100644 --- a/apps/next/next.config.js +++ b/apps/next/next.config.js @@ -3,12 +3,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: [ @@ -23,11 +27,54 @@ const config = withPlausibleProxy({ serverActions: { bodySizeLimit: '10mb', }, + // Resolves `import { Button } from '@spoon/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: ['@spoon/ui', 'radix-ui'], }, /** Enables hot reloading for local packages without a build step */ transpilePackages: ['@spoon/backend', '@spoon/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/package.json b/apps/next/package.json index c54c446..db40d1f 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -34,8 +34,9 @@ "monaco-editor": "~0.55.1", "monaco-vim": "~0.4.4", "next": "^16.2.1", - "next-plausible": "^3.12.5", + "next-plausible": "^4.0.0", "next-themes": "^0.4.6", + "posthog-js": "^1.435.6", "react": "catalog:react19", "react-dom": "catalog:react19", "require-in-the-middle": "^7.5.2", diff --git a/apps/next/src/app/global-error.tsx b/apps/next/src/app/global-error.tsx index 0570801..6515e22 100644 --- a/apps/next/src/app/global-error.tsx +++ b/apps/next/src/app/global-error.tsx @@ -7,8 +7,8 @@ import { Geist, Geist_Mono } from 'next/font/google'; import '@/app/styles.css'; import { useEffect } from 'react'; -import { env } from '@/env'; import { generateMetadata } from '@/lib/metadata'; +import { publicEnv } from '@/publicEnv'; import * as Sentry from '@sentry/nextjs'; import PlausibleProvider from 'next-plausible'; @@ -41,32 +41,34 @@ 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/layout.tsx b/apps/next/src/app/layout.tsx index 4afb928..71d55b3 100644 --- a/apps/next/src/app/layout.tsx +++ b/apps/next/src/app/layout.tsx @@ -43,34 +43,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 62ee9d0..7fc94b1 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 21c598e..9e9da9e 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 { isRemoteImageUrl } from '@/lib/avatar'; 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 remoteImageUrl = isRemoteImageUrl(user?.image) ? user?.image : null; const currentImageUrl = useQuery( api.files.getImageUrl, @@ -83,6 +85,7 @@ export const AvatarDropdown = () => {