Template
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 @gib/ui modules they import, and client code reads public env from publicEnv.ts so zod stays out of the browser.
This commit is contained in:
1 parent
818b41fb21
commit
55b253257b
14 files changed
+318
-102
No files matched your search
@@ -3,12 +3,16 @@ import { createJiti } from 'jiti';
|
|||||||
import { withPlausibleProxy } from 'next-plausible';
|
import { withPlausibleProxy } from 'next-plausible';
|
||||||
|
|
||||||
const jiti = createJiti(import.meta.url);
|
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} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = withPlausibleProxy({
|
const baseConfig = {
|
||||||
customDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
|
||||||
})({
|
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -23,11 +27,54 @@ const config = withPlausibleProxy({
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '10mb',
|
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 */
|
/** Enables hot reloading for local packages without a build step */
|
||||||
transpilePackages: ['@gib/backend', '@gib/ui'],
|
transpilePackages: ['@gib/backend', '@gib/ui'],
|
||||||
typescript: { ignoreBuildErrors: true },
|
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 = {
|
const sentryConfig = {
|
||||||
// For all available options, see:
|
// For all available options, see:
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import { useEffect } from 'react';
|
|||||||
import Footer from '@/components/layout/footer';
|
import Footer from '@/components/layout/footer';
|
||||||
import Header from '@/components/layout/header';
|
import Header from '@/components/layout/header';
|
||||||
import { ConvexClientProvider } from '@/components/providers';
|
import { ConvexClientProvider } from '@/components/providers';
|
||||||
import { env } from '@/env';
|
|
||||||
import { generateMetadata } from '@/lib/metadata';
|
import { generateMetadata } from '@/lib/metadata';
|
||||||
|
import { publicEnv } from '@/publicEnv';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import PlausibleProvider from 'next-plausible';
|
import PlausibleProvider from 'next-plausible';
|
||||||
|
|
||||||
@@ -44,39 +44,41 @@ const GlobalError = ({ error, reset = undefined }: GlobalErrorProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
return (
|
const page = (
|
||||||
<PlausibleProvider
|
<ThemeProvider
|
||||||
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
attribute='class'
|
||||||
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<html lang='en' suppressHydrationWarning>
|
<ConvexClientProvider>
|
||||||
<body
|
<main className='flex min-h-screen flex-col items-center'>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Header />
|
||||||
>
|
<NextError statusCode={0} />
|
||||||
<ThemeProvider
|
{reset !== undefined && (
|
||||||
attribute='class'
|
<Button onClick={() => reset()}>Try Again</Button>
|
||||||
defaultTheme='system'
|
)}
|
||||||
enableSystem
|
<Toaster />
|
||||||
disableTransitionOnChange
|
<Footer />
|
||||||
>
|
</main>
|
||||||
<ConvexClientProvider>
|
<main className='flex min-h-[90vh] flex-col items-center'>
|
||||||
<main className='flex min-h-screen flex-col items-center'>
|
<Toaster />
|
||||||
<Header />
|
</main>
|
||||||
<NextError statusCode={0} />
|
</ConvexClientProvider>
|
||||||
{reset !== undefined && (
|
</ThemeProvider>
|
||||||
<Button onClick={() => reset()}>Try Again</Button>
|
);
|
||||||
)}
|
return (
|
||||||
<Toaster />
|
<html lang='en' suppressHydrationWarning>
|
||||||
<Footer />
|
<body
|
||||||
</main>
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
<main className='flex min-h-[90vh] flex-col items-center'>
|
>
|
||||||
<Toaster />
|
{publicEnv.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
|
||||||
</main>
|
<PlausibleProvider>{page}</PlausibleProvider>
|
||||||
</ConvexClientProvider>
|
) : (
|
||||||
</ThemeProvider>
|
page
|
||||||
</body>
|
)}
|
||||||
</html>
|
</body>
|
||||||
</PlausibleProvider>
|
</html>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default GlobalError;
|
export default GlobalError;
|
||||||
@@ -36,34 +36,38 @@ const RootLayout = ({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) => {
|
}>) => {
|
||||||
|
const app = (
|
||||||
|
<ThemeProvider
|
||||||
|
attribute='class'
|
||||||
|
defaultTheme='system'
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<ConvexClientProvider>
|
||||||
|
<div className='flex min-h-screen flex-col'>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</ConvexClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<ConvexAuthNextjsServerProvider>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<PlausibleProvider
|
<html lang='en' suppressHydrationWarning>
|
||||||
domain={env.NEXT_PUBLIC_SITE_URL.trim().replace(/^https?:\/\//, '')}
|
<body
|
||||||
customDomain={env.NEXT_PUBLIC_PLAUSIBLE_URL}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<html lang='en' suppressHydrationWarning>
|
{/* Plausible loads its per-site script through the same-origin
|
||||||
<body
|
proxy in next.config.js; without that config it stays off. */}
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
{env.NEXT_PUBLIC_PLAUSIBLE_SRC ? (
|
||||||
>
|
<PlausibleProvider>{app}</PlausibleProvider>
|
||||||
<ThemeProvider
|
) : (
|
||||||
attribute='class'
|
app
|
||||||
defaultTheme='system'
|
)}
|
||||||
enableSystem
|
</body>
|
||||||
disableTransitionOnChange
|
</html>
|
||||||
>
|
|
||||||
<ConvexClientProvider>
|
|
||||||
<div className='flex min-h-screen flex-col'>
|
|
||||||
<Header />
|
|
||||||
{children}
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
<Toaster />
|
|
||||||
</ConvexClientProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
</PlausibleProvider>
|
|
||||||
</ConvexAuthNextjsServerProvider>
|
</ConvexAuthNextjsServerProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { resetAnalyticsIdentity } from '@/lib/analytics';
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut } from 'lucide-react';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export const SignOutForm = () => {
|
|||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
try {
|
try {
|
||||||
await signOut();
|
await signOut();
|
||||||
|
resetAnalyticsIdentity();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sign out error:', error);
|
console.error('Sign out error:', error);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { resetAnalyticsIdentity, useAnalyticsIdentity } from '@/lib/analytics';
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
import { useConvexAuth, useQuery } from 'convex/react';
|
import { useConvexAuth, useQuery } from 'convex/react';
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export const AvatarDropdown = () => {
|
|||||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||||
const { signOut } = useAuthActions();
|
const { signOut } = useAuthActions();
|
||||||
const user = useQuery(api.auth.getUser, {});
|
const user = useQuery(api.auth.getUser, {});
|
||||||
|
useAnalyticsIdentity(user?._id);
|
||||||
const currentImageUrl = useQuery(
|
const currentImageUrl = useQuery(
|
||||||
api.files.getImageUrl,
|
api.files.getImageUrl,
|
||||||
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
user?.image ? { storageId: user.image as Id<'_storage'> } : 'skip',
|
||||||
@@ -77,6 +79,7 @@ export const AvatarDropdown = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void signOut().then(() => {
|
void signOut().then(() => {
|
||||||
|
resetAnalyticsIdentity();
|
||||||
router.push('/');
|
router.push('/');
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { env } from '@/env';
|
import { publicEnv } from '@/publicEnv';
|
||||||
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
|
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
|
||||||
import { ConvexReactClient } from 'convex/react';
|
import { ConvexReactClient } from 'convex/react';
|
||||||
|
|
||||||
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
|
const convex = new ConvexReactClient(publicEnv.NEXT_PUBLIC_CONVEX_URL);
|
||||||
|
|
||||||
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
|
export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
|
||||||
<ConvexAuthNextjsProvider client={convex}>
|
<ConvexAuthNextjsProvider client={convex}>
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export const env = createEnv({
|
|||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_SITE_URL: z.url(),
|
NEXT_PUBLIC_SITE_URL: z.url(),
|
||||||
NEXT_PUBLIC_CONVEX_URL: z.url(),
|
NEXT_PUBLIC_CONVEX_URL: z.url(),
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: z.url(),
|
// Analytics are optional: absent values leave the integration off.
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SRC: z.url().optional(),
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST: z.url().optional(),
|
||||||
NEXT_PUBLIC_SENTRY_DSN: z.string(),
|
NEXT_PUBLIC_SENTRY_DSN: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_URL: z.string(),
|
NEXT_PUBLIC_SENTRY_URL: z.string(),
|
||||||
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
NEXT_PUBLIC_SENTRY_ORG: z.string(),
|
||||||
@@ -34,7 +37,9 @@ export const env = createEnv({
|
|||||||
CI: process.env.CI,
|
CI: process.env.CI,
|
||||||
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
|
||||||
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
|
||||||
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
|
NEXT_PUBLIC_PLAUSIBLE_SRC: process.env.NEXT_PUBLIC_PLAUSIBLE_SRC,
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
|
||||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
|
NEXT_PUBLIC_SENTRY_URL: process.env.NEXT_PUBLIC_SENTRY_URL,
|
||||||
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG,
|
||||||
|
|||||||
@@ -1,27 +1,35 @@
|
|||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
import { env } from '@/env';
|
import { startAnalytics } from '@/lib/analytics';
|
||||||
|
import { publicEnv } from '@/publicEnv';
|
||||||
import * as Sentry from '@sentry/nextjs';
|
import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
// Session replay is left to PostHog, so Sentry carries no replay integration.
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: publicEnv.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
integrations: [
|
|
||||||
Sentry.replayIntegration({
|
|
||||||
maskAllText: false,
|
|
||||||
blockAllMedia: false,
|
|
||||||
}),
|
|
||||||
Sentry.feedbackIntegration({
|
|
||||||
colorScheme: 'system',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
||||||
sendDefaultPii: true,
|
sendDefaultPii: false,
|
||||||
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
// https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate
|
||||||
tracesSampleRate: 1,
|
// A tenth of page loads is plenty for performance trends and keeps the
|
||||||
|
// self-hosted Sentry light.
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
enableLogs: true,
|
enableLogs: true,
|
||||||
// https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration
|
|
||||||
replaysSessionSampleRate: 0.5,
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The feedback widget is a floating button nobody needs during the first
|
||||||
|
// paint, so its code is split into its own chunk and added once the page has
|
||||||
|
// loaded.
|
||||||
|
const addFeedbackWidget = async () => {
|
||||||
|
const { feedbackIntegration } = await import('@sentry/nextjs');
|
||||||
|
Sentry.addIntegration(feedbackIntegration({ colorScheme: 'system' }));
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') void addFeedbackWidget();
|
||||||
|
else
|
||||||
|
window.addEventListener('load', () => void addFeedbackWidget(), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
startAnalytics();
|
||||||
|
|
||||||
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards
|
||||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { PostHog } from 'posthog-js';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { aliasPosthogScript } from '@/lib/posthog-scripts';
|
||||||
|
import { publicEnv } from '@/publicEnv';
|
||||||
|
|
||||||
|
// PostHog stays off until a project key and host are configured. Calling
|
||||||
|
// posthog-js before init logs an error on every call, so every use goes
|
||||||
|
// through this flag.
|
||||||
|
export const analyticsEnabled = Boolean(
|
||||||
|
publicEnv.NEXT_PUBLIC_POSTHOG_KEY && publicEnv.NEXT_PUBLIC_POSTHOG_HOST,
|
||||||
|
);
|
||||||
|
|
||||||
|
let client: PostHog | undefined;
|
||||||
|
// Identity calls made before PostHog has loaded, replayed in order once it has.
|
||||||
|
const pending: ((posthog: PostHog) => void)[] = [];
|
||||||
|
|
||||||
|
const withPosthog = (call: (posthog: PostHog) => void) => {
|
||||||
|
if (client) call(client);
|
||||||
|
else pending.push(call);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs once the page has loaded and the main thread is idle, so analytics
|
||||||
|
// never competes with the first paint or hydration.
|
||||||
|
const afterPageIsIdle = (run: () => void) => {
|
||||||
|
const schedule = () => {
|
||||||
|
if ('requestIdleCallback' in window)
|
||||||
|
window.requestIdleCallback(run, { timeout: 4_000 });
|
||||||
|
else setTimeout(run, 0);
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') schedule();
|
||||||
|
else window.addEventListener('load', schedule, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const initPosthog = async (key: string) => {
|
||||||
|
const { default: posthog } = await import('posthog-js');
|
||||||
|
posthog.init(key, {
|
||||||
|
// Events go through the same-origin /ingest rewrite in next.config.js
|
||||||
|
// rather than straight to the PostHog host, whose name and paths ad
|
||||||
|
// blockers match on. ui_host keeps toolbar links on the real host.
|
||||||
|
api_host: '/ingest',
|
||||||
|
ui_host: publicEnv.NEXT_PUBLIC_POSTHOG_HOST,
|
||||||
|
// Makes pageviews follow App Router client-side navigation.
|
||||||
|
defaults: '2026-08-30',
|
||||||
|
// Autocapture, session replay (typed input masked), heatmaps and web
|
||||||
|
// vitals come from these defaults plus the project settings in PostHog.
|
||||||
|
capture_exceptions: true,
|
||||||
|
// Two extension script names are ad-blocked by name; see
|
||||||
|
// lib/posthog-scripts.ts for the aliasing.
|
||||||
|
strict_script_versioning: false,
|
||||||
|
prepare_external_dependency_script: (script) => {
|
||||||
|
script.src = aliasPosthogScript(script.src);
|
||||||
|
return script;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Keeps local runs separate from production in the same project.
|
||||||
|
posthog.register({
|
||||||
|
environment: new URL(publicEnv.NEXT_PUBLIC_SITE_URL).hostname,
|
||||||
|
});
|
||||||
|
client = posthog;
|
||||||
|
for (const call of pending.splice(0)) call(posthog);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called once from instrumentation-client. posthog-js is downloaded and
|
||||||
|
// initialised only after the page is idle, which keeps it out of the first
|
||||||
|
// load; identity calls made in the meantime are queued above.
|
||||||
|
export const startAnalytics = () => {
|
||||||
|
const key = publicEnv.NEXT_PUBLIC_POSTHOG_KEY;
|
||||||
|
if (!analyticsEnabled || !key) return;
|
||||||
|
afterPageIsIdle(() => void initPosthog(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ties this browser's PostHog person to the signed-in Convex user. The id is
|
||||||
|
// opaque, so no name or email leaves the app. Mounted by the header avatar,
|
||||||
|
// which renders on every page while someone is signed in.
|
||||||
|
export const useAnalyticsIdentity = (userId: string | undefined) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyticsEnabled || !userId) return;
|
||||||
|
withPosthog((posthog) => posthog.identify(userId));
|
||||||
|
}, [userId]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts a fresh anonymous id on sign-out so the next person on this browser
|
||||||
|
// is never merged into the last one.
|
||||||
|
export const resetAnalyticsIdentity = () => {
|
||||||
|
if (analyticsEnabled) withPosthog((posthog) => posthog.reset());
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// posthog-js loads optional features (session replay, dead clicks) as extra
|
||||||
|
// scripts from <api_host>/static/<name>.js. Two of those file names sit on the
|
||||||
|
// default ad-blocker lists by name alone (EasyPrivacy carries the literal
|
||||||
|
// substrings /posthog-recorder.js and /dead-clicks-autocapture.js), so the
|
||||||
|
// same-origin proxy does not save them: the browser refuses the request before
|
||||||
|
// it leaves. The client asks for the alias instead, and next.config.js rewrites
|
||||||
|
// the alias back to the real file on the way out. Nothing here changes what is
|
||||||
|
// loaded, only what it is called on the wire. The aliases are deliberately
|
||||||
|
// meaningless so a broader rule added later has nothing to match.
|
||||||
|
export const posthogScriptAliases: Record<string, string> = {
|
||||||
|
'posthog-recorder': 'ext-a',
|
||||||
|
'dead-clicks-autocapture': 'ext-b',
|
||||||
|
};
|
||||||
|
|
||||||
|
const staticScript = /\/static\/([a-z0-9-]+)\.js(?=\?|$)/;
|
||||||
|
|
||||||
|
// Rewrites a PostHog extension script URL to its alias, or returns it
|
||||||
|
// unchanged when the file is not on the list.
|
||||||
|
export const aliasPosthogScript = (src: string) =>
|
||||||
|
src.replace(staticScript, (match, name: string) =>
|
||||||
|
name in posthogScriptAliases
|
||||||
|
? `/static/${posthogScriptAliases[name]}.js`
|
||||||
|
: match,
|
||||||
|
);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { env } from '@/env';
|
||||||
|
|
||||||
|
type PublicEnv = Pick<
|
||||||
|
typeof env,
|
||||||
|
| 'NEXT_PUBLIC_SITE_URL'
|
||||||
|
| 'NEXT_PUBLIC_CONVEX_URL'
|
||||||
|
| 'NEXT_PUBLIC_PLAUSIBLE_SRC'
|
||||||
|
| 'NEXT_PUBLIC_POSTHOG_KEY'
|
||||||
|
| 'NEXT_PUBLIC_POSTHOG_HOST'
|
||||||
|
| 'NEXT_PUBLIC_SENTRY_DSN'
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Empty strings count as unset, matching `emptyStringAsUndefined` in env.ts.
|
||||||
|
const read = (value: string | undefined) => (value === '' ? undefined : value);
|
||||||
|
|
||||||
|
// The browser-side NEXT_PUBLIC_* values for client components and
|
||||||
|
// instrumentation-client. Next inlines each literal `process.env.X` at build
|
||||||
|
// time, and env.ts has already validated them by then (next.config.js imports
|
||||||
|
// it), so reading them here keeps zod and the env schema out of every
|
||||||
|
// visitor's first download. Server code keeps importing `@/env`.
|
||||||
|
/* eslint-disable no-restricted-properties -- the one sanctioned client-side reader, see AGENTS.md */
|
||||||
|
export const publicEnv = {
|
||||||
|
NEXT_PUBLIC_SITE_URL: read(process.env.NEXT_PUBLIC_SITE_URL),
|
||||||
|
NEXT_PUBLIC_CONVEX_URL: read(process.env.NEXT_PUBLIC_CONVEX_URL),
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SRC: read(process.env.NEXT_PUBLIC_PLAUSIBLE_SRC),
|
||||||
|
NEXT_PUBLIC_POSTHOG_KEY: read(process.env.NEXT_PUBLIC_POSTHOG_KEY),
|
||||||
|
NEXT_PUBLIC_POSTHOG_HOST: read(process.env.NEXT_PUBLIC_POSTHOG_HOST),
|
||||||
|
NEXT_PUBLIC_SENTRY_DSN: read(process.env.NEXT_PUBLIC_SENTRY_DSN),
|
||||||
|
} as PublicEnv;
|
||||||
|
/* eslint-enable no-restricted-properties */
|
||||||
@@ -3,7 +3,8 @@ import * as Sentry from '@sentry/nextjs';
|
|||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
|
||||||
tracesSampleRate: 1,
|
// One in ten requests keeps traces useful without loading the home server.
|
||||||
|
tracesSampleRate: 0.1,
|
||||||
enableLogs: true,
|
enableLogs: true,
|
||||||
debug: false,
|
debug: false,
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
import { cx } from 'class-variance-authority';
|
// Re-exports only. The Next app lists @gib/ui in `optimizePackageImports`,
|
||||||
import { twMerge } from 'tailwind-merge';
|
// which resolves each named import straight to its own module so a page that
|
||||||
|
// uses Button never loads Chart (and Recharts). That only works while nothing
|
||||||
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs));
|
// is declared in this file.
|
||||||
|
export { cn, ccn } from './utils';
|
||||||
export const ccn = ({
|
|
||||||
context,
|
|
||||||
className,
|
|
||||||
on = '',
|
|
||||||
off = '',
|
|
||||||
}: {
|
|
||||||
context: boolean;
|
|
||||||
className: string;
|
|
||||||
on: string;
|
|
||||||
off: string;
|
|
||||||
}) => twMerge(className, context ? on : off);
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { cx } from 'class-variance-authority';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export const cn = (...inputs: Parameters<typeof cx>) => twMerge(cx(inputs));
|
||||||
|
|
||||||
|
export const ccn = ({
|
||||||
|
context,
|
||||||
|
className,
|
||||||
|
on = '',
|
||||||
|
off = '',
|
||||||
|
}: {
|
||||||
|
context: boolean;
|
||||||
|
className: string;
|
||||||
|
on: string;
|
||||||
|
off: string;
|
||||||
|
}) => twMerge(className, context ? on : off);
|
||||||
Reference in new issue
Block a user